Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
301a199
fix: 修复浏览器工具链问题
zuiho-kai Mar 20, 2026
a2410eb
ci: 修复 E2E 测试缓存路径,使用 package.json 替代 package-lock.json
zuiho-kai Mar 21, 2026
316d4fa
ci: 优化缓存策略,加速 CI 运行
zuiho-kai Mar 21, 2026
4160f93
ci: 添加 node_modules 缓存
zuiho-kai Mar 21, 2026
59fdea5
chore: 添加 E2E 测试的 package-lock.json
zuiho-kai Mar 21, 2026
159cd92
chore: 解决 merge conflict,整合缓存优化
zuiho-kai Mar 21, 2026
d536f2e
fix: 修复 E2E 测试文件语法错误
zuiho-kai Mar 21, 2026
e68e8d3
ci: 使用 package-lock.json 作为 node_modules 缓存 key
zuiho-kai Mar 21, 2026
e29a9ab
ci: 添加 mock server 依赖验证步骤
zuiho-kai Mar 21, 2026
2fedfe0
ci: Playwright 缓存 key 也使用 package-lock.json
zuiho-kai Mar 21, 2026
4b95c1f
ci: 添加 mock server 连通性测试
zuiho-kai Mar 21, 2026
d28bbeb
ci: 移除重复的 mock server 启动步骤
zuiho-kai Mar 21, 2026
d69c7d6
test: 添加浏览器控制台日志和延长超时时间
zuiho-kai Mar 21, 2026
7f97588
fix: mock server 绑定到 0.0.0.0 以允许 CI 环境中的连接
zuiho-kai Mar 21, 2026
35b9e41
fix: WebSocket URL 使用 localhost 替代 127.0.0.1
zuiho-kai Mar 21, 2026
d51485f
ci: 添加网络诊断步骤
zuiho-kai Mar 21, 2026
bfebdc9
fix: 修复 Windows CI 上 mock server 启动问题
zuiho-kai Mar 21, 2026
85d2cf2
fix: 使用 process.execPath 启动 mock server,避免 shell 进程问题
zuiho-kai Mar 21, 2026
acbbfd1
fix: 增强 mock server 错误处理和稳定性
zuiho-kai Mar 21, 2026
c8e9a4a
fix: 增强 mock server WebSocket 连接处理
zuiho-kai Mar 21, 2026
197a735
ci: 触发新的 CI 运行
zuiho-kai Mar 21, 2026
7967d5d
fix: 将整个 mock server 包装在 try-catch 中,添加更多错误处理
zuiho-kai Mar 21, 2026
d44009a
simplify: 简化 mock server,添加详细步骤日志
zuiho-kai Mar 21, 2026
39f8ad6
fix: interrupt 后补发 status:idle 修复测试死锁
zuiho-kai Mar 21, 2026
04fb43f
fix: 改用 HTTP 轮询代替 pipe READY 信号修复 mock server EPIPE 崩溃
zuiho-kai Mar 21, 2026
c77e432
fix: E2E 测试时跳过后端启动,避免 mock server 被杀死
zuiho-kai Mar 21, 2026
1af0355
ci: E2E job 禁用 uv 缓存,修复 post-step 缓存路径不存在报错
zuiho-kai Mar 21, 2026
176ecdd
docs: 落盘 DEV-107/DEV-108,E2E 测试隔离 + CI cache post-step 坑
zuiho-kai Mar 21, 2026
ee61ee4
docs: 修复门禁补强跨 session 计数规则,入口门禁 Bug 修复强制 git log
zuiho-kai Mar 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
test-python:
name: Python 测试
runs-on: windows-latest
timeout-minutes: 15

steps:
- name: 检出代码
Expand Down Expand Up @@ -57,18 +58,18 @@ jobs:
test-e2e:
name: E2E 测试
runs-on: windows-latest
timeout-minutes: 20
needs: test-python

steps:
- name: 检出代码
uses: actions/checkout@v4

- name: 安装 uv(启用缓存)
- name: 安装 uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
enable-cache: true
cache-suffix: "pr-ci"
enable-cache: false

- name: 恢复 .venv 缓存
id: cache-venv
Expand All @@ -93,19 +94,41 @@ jobs:
uses: actions/cache@v4
with:
path: tests/e2e/node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('tests/e2e/package.json') }}
key: node-modules-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json') }}

- name: 安装 E2E 依赖
if: steps.cache-node-modules.outputs.cache-hit != 'true'
working-directory: tests/e2e
run: npm ci

- name: 验证 mock server 依赖
working-directory: tests/e2e
run: npm list ws

- name: 诊断网络状态
shell: pwsh
run: |
Write-Host "Checking if port 12393 is in use..."
$connection = Get-NetTCPConnection -LocalPort 12393 -ErrorAction SilentlyContinue
if ($connection) {
Write-Host "Port 12393 is in use by process: $($connection.OwningProcess)"
Get-Process -Id $connection.OwningProcess
} else {
Write-Host "Port 12393 is NOT in use"
}

Write-Host "`nTesting localhost connectivity..."
Test-NetConnection -ComputerName localhost -Port 12393 -WarningAction SilentlyContinue

Write-Host "`nHosts file content:"
Get-Content C:\Windows\System32\drivers\etc\hosts | Select-String -Pattern "localhost"

- name: 缓存 Playwright 浏览器
id: cache-playwright
uses: actions/cache@v4
with:
path: ~\AppData\Local\ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package.json') }}
key: playwright-${{ runner.os }}-${{ hashFiles('tests/e2e/package-lock.json') }}

- name: 安装 Playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
Expand Down
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ worktree:[已就绪(路径/分支名) | 需要创建 → 先执行创建命令
|----------|----------|------|
| 文档更新 | 只改 .md,不涉及代码 | 直接改 |
| 单文件小修 | ≤1 文件,逻辑明确 | 直接实施 → 自测 |
| Bug 修复 | 有报错/复现,≤3 文件 | 读错题本 → 实施 → CR(独立 agent)→ P0/P1 归零 → push → 开 PR |
| Bug 修复 | 有报错/复现,≤3 文件 | 读错题本 → **先 `git log --oneline \| grep -i fix` 数已有 fix 次数,≥2 次立即输出修复门禁** → 实施 → CR(独立 agent)→ P0/P1 归零 → push → 开 PR |
| 多文件功能 | 跨文件但不涉及新 Module | 读错题本 → 方案确认(列验收标准+技术方案+风险点,用户确认后才能实施)→ 实施 → 自验证 → CR(独立 agent)→ P0/P1 归零 → push → 开 PR |
| 新 Module | 不在 spine-now.md 冻结范围 | 走 Module 开发流程 5 步 |

Expand Down Expand Up @@ -147,10 +147,12 @@ worktree:[已就绪(路径/分支名) | 需要创建 → 先执行创建命令

对同一功能/链路提交第 2 次 fix commit 前,**必须先输出修复门禁声明**,再写任何代码。不输出就动手 = DEV-83 违规。

**跨 session 计数规则**:fix 次数必须从 `git log` 里数,不能因新对话而归零。新 session 处理同一链路时,必须先 `git log --oneline | grep -i fix` 检查历史,再判断是否需要输出修复门禁。

```
--- 修复门禁 ---
功能/链路:[一句话描述修的是什么]
已有 fix 次数:[git log --oneline 计数]
已有 fix 次数:[git log --oneline | grep -i fix 计数结果,不能凭记忆]
每次 fix 摘要:[第 N 次改了什么 → 为什么没解决]
平台验证:[已做最小复现 / 已搜 Issues / 不涉及平台 API]
门禁结论:[继续当前路径(≤2次) | 强制停下换路径(≥3次)]
Expand Down
2 changes: 2 additions & 0 deletions docs/error-books/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
| DEV-64 | 构建脚本数据源与运行时环境不一致 | 构建/打包 | 🟡×3 | build-packaging.md |
| DEV-65 | 跨平台路径拼接用了宿主机 path API | 构建/打包 | 🟢 | build-packaging.md |
| DEV-75 | 跨平台 API 降级只做初始化不做运行时兜底 | 构建/打包 | 🟢 | build-packaging.md |
| DEV-108 | CI cache post-step 在实际 step 被跳过时找不到路径报错 | 构建/打包 | 🟢 | build-packaging.md |
| DEV-60 | 隔离对象但共享有状态引用 | 流式/运行时 | 🟢 | streaming-runtime.md |
| DEV-66 | 实时通道断线缓冲未区分消息时效性 | 流式/运行时 | 🟢 | streaming-runtime.md |
| DEV-70 | 流式清洗逻辑未处理标签跨 chunk 拆分 | 流式/运行时 | 🟡×2 | streaming-runtime.md |
Expand Down Expand Up @@ -76,5 +77,6 @@
| DEV-100 | 多问题批量修复不分类并行 | 代码习惯 | 🟢 | flow-code-habit.md |
| DEV-101 | API 边界防御缺失 | 代码习惯 | 🟢 | flow-code-habit.md |
| DEV-104 | 代码搬迁只验功能可用,未检查隐式依赖 | 代码习惯 | 🟢 | flow-code-habit.md |
| DEV-107 | E2E 测试未隔离被测 App 的自启动后端,mock server 被杀死 | 代码习惯 | 🟢 | flow-code-habit.md |
| REC-1~6 | 记录员典型错误(讨论没落盘等) | 通用/记录 | 🟢 | error-book-recorder.md |
| DEV-94 | README 完成状态与实际启用条件/实现方案不一致 | 代码习惯 | 🟢 | flow-code-habit.md |
6 changes: 6 additions & 0 deletions docs/error-books/build-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
✅ 路径拼接涉及目标平台时,用 `path.win32.join` / `path.posix.join` 显式选择,不依赖宿主机默认的 `path.join`。检查点:函数参数里有 `platform` → 路径 API 必须跟着走
> 归因:`path.join` 的行为取决于 Node 运行的 OS 而非业务逻辑的目标 OS,混淆了"构建环境"与"目标环境"

### DEV-108 CI cache post-step 在实际 step 被跳过时仍执行,找不到缓存路径报错 `🟢`

❌ GitHub Actions 中 `setup-uv` 配置 `enable-cache: true`,但当 `.venv` 缓存命中时 `uv sync` 被跳过,uv 从未下载任何包,缓存目录不存在,post-step 自动保存缓存时报错 → 整个 job 标记为 failure
✅ 若某 job 内的"真实执行步骤"可能被条件跳过(如 `if: cache-hit != 'true'`),则该步骤依赖的缓存动作必须设 `enable-cache: false` 或加 `if` 守卫,避免 post-step 找不到目录
> 归因 C:新场景。CI 报错只出现在 post-step,与主流程失败现象混淆,排查成本高。PR #68

### DEV-75 跨平台 API 降级只做初始化不做运行时兜底 `🟢`

❌ 初始化时按平台降级(`setIgnoreMouseEvents(false)`),但运行时 IPC 仍无条件调用平台不支持的选项(`forward: true`),降级形同虚设
Expand Down
6 changes: 6 additions & 0 deletions docs/error-books/flow-code-habit.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@
✅ 写 API 端点时:①读 service 完整签名透传所有参数 ②数值字段加范围校验 ③错误语义映射正确状态码 ④错误映射写完后 grep 所有 raise/return error 逐条验证覆盖
> 从 bot_civ DEV-27 搬运

### DEV-107 E2E 测试未隔离被测 App 的自启动后端逻辑,导致 mock server 被杀死 `🟢`

❌ Electron App 内 `startBackend()` 无条件执行 `netstat` 查杀端口 12393 进程(mock server),再尝试启动 Python 后端(CI 无 runtime),WebSocket 连接失败。测试只验证了"mock server 能启动",没考虑 App 自身会攻击该端口
✅ E2E 测试涉及"App 自带 server 启动逻辑"时,必须在 App 内加 `NODE_ENV=test` 守卫跳过真实后端启动。检查点:App 是否有端口清理/进程杀死逻辑?测试 mock 和 App 是否争用同一端口?
> 归因 C:新场景。测试隔离思路只覆盖了"mock server 不崩溃",未推演"App 启动后会对 mock 做什么"。PR #68

### DEV-104 代码搬迁只验功能可用,未检查隐式依赖在新上下文是否成立 `🟢`

❌ 把托盘菜单的 IPC handler 搬到独立设置窗口,只验证"功能能用",没推演 `win` 的生命周期在新上下文是否仍然成立。主窗口销毁后设置窗口仍在,IPC 操作已销毁的 BrowserWindow 抛异常
Expand Down
5 changes: 4 additions & 1 deletion frontend/desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1203,7 +1203,10 @@ if (!gotLock) {
app.whenReady().then(() => {
historyFilePath = resolveHistoryFilePath();
loadHistoryFromDisk();
startBackend();
// NODE_ENV=test 时 mock-server.js 已在端口 12393 运行,跳过真实后端启动
if (process.env.NODE_ENV !== "test") {
startBackend();
}
createWindow();
});
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/desktop/renderer/socket-client.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* WebSocket 客户端 — 连接灰风后端
*/
const WS_URL = "ws://127.0.0.1:12393/ws";
const WS_URL = "ws://localhost:12393/ws";
let ws = null;
let reconnectTimer = null;
let pendingAudioMeta = null;
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies = [
"pyautogui>=0.9.54",
"pyperclip>=1.8.0",
"pygetwindow>=0.0.9",
"playwright>=1.58.0",
]

[project.optional-dependencies]
Expand Down
8 changes: 6 additions & 2 deletions src/greywind/context_runtime/prompt_assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,14 @@ def assemble(

# 最近对话
for turn in recent_dialogue:
messages.append({
msg: Dict[str, Any] = {
"role": turn["role"],
"content": turn["content"],
})
}
# 恢复 tool_calls(如果有)
if turn.get("tool_calls"):
msg["tool_calls"] = turn["tool_calls"]
messages.append(msg)

# 当前用户输入(可能附带截图)
if screen_image_b64:
Expand Down
9 changes: 6 additions & 3 deletions src/greywind/context_runtime/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ def state(self) -> str:
def state(self, value: str):
self._state = value

def add_turn(self, role: str, content: str) -> None:
def add_turn(self, role: str, content: str, tool_calls: List[Dict[str, Any]] | None = None) -> None:
"""添加一轮对话"""
self._recent_dialogue.append({
entry: Dict[str, Any] = {
"role": role,
"content": content,
"timestamp": time.time(),
})
}
if tool_calls:
entry["tool_calls"] = tool_calls
self._recent_dialogue.append(entry)
if len(self._recent_dialogue) > self._max_recent * 2:
self._recent_dialogue = self._recent_dialogue[-self._max_recent * 2:]

Expand Down
29 changes: 29 additions & 0 deletions src/greywind/execution/playwright_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import asyncio
import base64
import subprocess
import uuid
from typing import Optional, List, Any

Expand All @@ -11,6 +12,28 @@
from .base import BrowserProvider, ActionResult, TabInfo


async def _ensure_chromium_installed():
"""检查并自动安装 Chromium 浏览器二进制"""
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
p.chromium.launch()
return True
except Exception:
logger.info("PlaywrightProvider: 正在安装 Chromium...")
try:
subprocess.run(
["python", "-m", "playwright", "install", "chromium"],
capture_output=True,
check=True,
)
logger.info("PlaywrightProvider: Chromium 安装完成")
return True
except subprocess.CalledProcessError as e:
logger.error(f"PlaywrightProvider: Chromium 安装失败 — {e}")
return False


class PlaywrightProvider(BrowserProvider):
def __init__(self, screenshot_quality: int = 50, screenshot_width: int = 1280,
idle_timeout: int = 60, max_tabs: int = 10,
Expand All @@ -33,6 +56,12 @@ async def connect(self) -> bool:
try:
from playwright.async_api import async_playwright
import os

# 首次启动:自动检查并安装 Chromium
if not await _ensure_chromium_installed():
logger.error("PlaywrightProvider: Chromium 不可用")
return False

self._playwright = await async_playwright().start()

# 持久化 user data dir:保留登录态,下次启动不用重新登录
Expand Down
19 changes: 17 additions & 2 deletions src/greywind/persona/voice_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,19 @@ def _start_response(self, text, send_fn, send_audio_fn):
self._respond(text, send_fn, send_audio_fn)
)

async def interrupt(self):
async def interrupt(self) -> bool:
"""打断当前响应。返回 True 表示确实取消了正在运行的任务。"""
self._interrupted = True
was_running = False
if self._response_task and not self._response_task.done():
was_running = True
self._response_task.cancel()
try:
await self._response_task
except asyncio.CancelledError:
pass
self._interrupted = False
return was_running

async def _respond(self, user_text, send_fn, send_audio_fn):
self._interrupted = False
Expand Down Expand Up @@ -209,7 +213,18 @@ async def _respond(self, user_text, send_fn, send_audio_fn):
)

if clean_response and not self._interrupted:
self.session.add_turn("assistant", _sanitize_llm_text(clean_response))
# 如果有 tool_calls,也保存到 session
tool_calls_data = None
if tool_calls:
tool_calls_data = [
{
"id": tc.id,
"type": "function",
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
}
for tc in tool_calls
]
self.session.add_turn("assistant", _sanitize_llm_text(clean_response), tool_calls_data)
elif round_idx == max_rounds and not clean_response:
logger.warning("最后一轮(无 tools)LLM 返回空文本,可能模型不兼容 tool result 上下文")

Expand Down
4 changes: 3 additions & 1 deletion src/greywind/server/ws_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ async def send_audio(audio_bytes: bytes, payload: dict):
logger.info(f"桌面操控即时切换: enabled={enabled}")

elif msg_type == "interrupt":
await pipeline.interrupt()
was_running = await pipeline.interrupt()
if was_running:
await send_msg({"type": "status", "payload": {"state": "idle"}})

elif msg_type == "clear_history":
pipeline.session.clear()
Expand Down
13 changes: 11 additions & 2 deletions tests/e2e/electron.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,27 @@ test.describe("Electron 窗口基础渲染", () => {
}
});

test("status-bar 在连接成功后显示"已连接"", async () => {
test('status-bar 在连接成功后显示"已连接"', async () => {
const app = await electron.launch({
args: [ELECTRON_APP_DIR],
env: { ...process.env, NODE_ENV: "test" },
});

// 捕获浏览器控制台日志
app.on("window", (page) => {
page.on("console", (msg) => {
console.log(`[Browser Console] ${msg.type()}: ${msg.text()}`);
});
});

try {
const win = await app.firstWindow();
await win.waitForLoadState("domcontentloaded");
// 等待更长时间让 WebSocket 连接
await win.waitForTimeout(2000);
// WebSocket 连接 + health check 完成后 status-bar 变"已连接"
await expect(win.locator("#status-bar")).toContainText("已连接", {
timeout: 8000,
timeout: 15000,
});
} finally {
await app.close();
Expand Down
Loading
Loading