Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 11 additions & 6 deletions .claude/skills/verify-openminutes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ description: 在本机隔离启动 OpenMinutes,用无头 Chrome/Edge 验证导
- **默认复用桌面 Floatboat 的 Host,不另起 Host。** launch 检查 Floatboat 进程、本 appId 最新连接配置、监听端口的进程归属,以及只读 `capabilities.list`。任一步失败便停止,不自动降级;显式 `--no-host` 或 `VERIFY_HOST_MODE=none` 才禁用 Host。
- **Host 地址属于本机敏感信息。** 只从 `%APPDATA%/floatboat/workbench/connections/*.json` 读取连接配置,只复制 `hostConnection.url` 到本 run 的 `workbench-config.json`。不复制真实数据目录、端口或其它启动字段;不打印地址、不把配置放入证据、不提交它。cleanup 删除临时配置。
- **真实 ASR/LLM 消耗额度。** 使用 `speech.mjs` 的短语音,每条只点一次转录/归类;失败后先读证据,不盲目重跑配方。`evidence.mjs` 从隔离库统计 `asr.start` 和 `llm.*`,包含自动重试/回退。能力探测不发起计费调用,也不能证明上游模型一定可用。实时字幕/BLE 不在本配方覆盖范围。
- **BLE 设备不要碰。** 设备页的「连接并检测」「读取清单」「下载」会通过本机 Python + bleak 真正连无线电。launch 已关闭 `auto_connect` / `auto_pull`,驱动时也不要点这几个按钮。
- **真实录音笔不要碰。** 录音笔页的「连接」「同步录音」「断开连接」「清理已同步的录音」以及列表里的「同步」会通过本机 Python + bleak 真正连无线电;同步大录音还会**真实切换本机 Wi-Fi**(连上录音笔热点、断开外网),清理会删除录音笔上的文件。**禁止对真实录音笔执行同步**,e2e 只用 `--fake-device`。launch 已关闭 `auto_connect` / `auto_pull`;没有 `--fake-device` 时驱动也不要点这些按钮。真机验收交给用户按清单执行。
- **不要驱动不是本次 run 启动的实例。** 用户的 Floatboat 里可能开着同一个应用(数据在 `%APPDATA%\floatboat\workbench\data\workbench.3bb3830b-…`),那是真实数据,绝不读写、绝不 kill。
- 多个 run 可以并行:端口随机、数据目录 / 收件目录 / 归档目录 / 浏览器 profile 都按 runId 隔离。

Expand All @@ -24,8 +24,11 @@ description: 在本机隔离启动 OpenMinutes,用无头 Chrome/Edge 验证导
node .claude/skills/verify-openminutes/scripts/build.mjs # ~10s;严格类型检查 + 打包到 dist/,失败时打印诊断并 exit 1
node .claude/skills/verify-openminutes/scripts/launch.mjs <runId> # runId 省略则生成 UUID
node .claude/skills/verify-openminutes/scripts/launch.mjs <runId> --no-host # 显式验证无 Host 失败路径
MEMOTALK_TEST_PYTHON=<装了 memotalk-sdk 的解释器> node .claude/skills/verify-openminutes/scripts/launch.mjs <runId> --no-host --fake-device=wifi # 假录音笔:wifi | ble | absent
```

- `--fake-device=<wifi|ble|absent>` 设置 `OPENMINUTES_FAKE_DEVICE`,把 `python_path` 写成 `MEMOTALK_TEST_PYTHON`(缺少时报错退出),并用 ffmpeg 在 `$R/fake-device/` 生成 3 段真实 mp3(约 2.1 MiB、1.2 MiB、160 KiB)作为录音笔里的文件。`wifi` 走无线快传(内存 FTP),`ble` 模拟入网失败以验证「已改用蓝牙」,`absent` 模拟找不到录音笔。假设备 prelude 会让任何 `netsh` / `nmcli` / `networksetup` 调用直接抛错,保证不改动本机网络。模式写进 `run.json`,doctor 会输出当前模式。

- 要验收另一个 worktree 或分支的代码时,给每条命令都加上 `OPENMINUTES_ROOT=<那个 checkout 的绝对路径>`。build、run 目录和证据都会落在那个 checkout 里,不需要往它里面复制 skill。
- 每次使用全新 runId;即使 cleanup 已执行,旧证据目录仍然保留,launch 会拒绝复用该 id,防止读取旧日志的 ready 地址或混合不同运行的证据。
- `build.mjs` 调用 `C:/Program Files/Floatboat/resources/workbench-toolchain/compiler.mjs`(可用 `FLOATBOAT_TOOLCHAIN` 覆盖),产物与 Host 构建一致。改了 `src/` 之后必须重新 build 再 launch,否则验证的是旧代码(doctor 会报 `dist/ is older than src/`)。
Expand All @@ -42,7 +45,7 @@ node .claude/skills/verify-openminutes/scripts/launch.mjs <runId> --no-host #
node .claude/skills/verify-openminutes/scripts/doctor.mjs <runId>
```

只读检查,exit 0 才能继续驱动。它用 run 的 nonce 请求 `/__workbench/health`,确认端口上的进程就是本 run 启动的(pid、generation 一致);确认隔离设置仍然生效(导入/归档目录指向本 run,四个 auto 开关为 `0`);确认 `dist/` 不比 `src/` 旧。任何异常(页面空白、接口 404、数据对不上)都先跑它。如果报告设置被改,说明刚才的操作(例如设备页「保存路径」)覆盖了隔离,这个 run 应该 cleanup 后重来。
只读检查,exit 0 才能继续驱动。它用 run 的 nonce 请求 `/__workbench/health`,确认端口上的进程就是本 run 启动的(pid、generation 一致);确认隔离设置仍然生效(导入/归档目录指向本 run,四个 auto 开关为 `0`);确认 `dist/` 不比 `src/` 旧。任何异常(页面空白、接口 404、数据对不上)都先跑它。如果报告设置被改,说明刚才的操作(例如在「设置 › 通用」里保存了目录)覆盖了隔离,这个 run 应该 cleanup 后重来。

Host 模式另检查 Floatboat 监听归属,并发送一次 `capabilities.list`。输出 `host: {mode, available, probe}`,不输出连接地址或能力响应原文;显式无 Host 模式显示 `mode: "none", available: false`,不调用 Host。Floatboat 重启后旧地址可能失效,此时 cleanup 后重新 launch。

Expand All @@ -55,15 +58,17 @@ node .claude/skills/verify-openminutes/scripts/browse.mjs <runId> <label> '<step
node .claude/skills/verify-openminutes/scripts/browse.mjs <runId> <label> @steps.json
```

步骤与选择器的完整说明写在脚本头部注释里。选择器优先用 `aria=<aria-label>`(例如 `aria=搜索录音`),其次 `text=<完整可见文字>`(例如 `text=立即扫描`),最后才用 CSS(例如 `button.row__main`)。任何一步失败都会打印 `FAIL …` 并以 exit 1 结束,`steps.log` 里保留已完成的步骤。步骤里如果有 `eval`,并且 JS 中含有引号,请先把步骤写进 JSON 文件,再用 `@file` 传入;在 shell 单引号里多层转义很容易写坏,表现为 `SyntaxError: Invalid or unexpected token`。
步骤与选择器的完整说明写在脚本头部注释里。选择器优先用 `aria=<aria-label>`(例如 `aria=搜索录音`),其次 `text=<完整可见文字>`(例如 `text=扫描并导入`),最后才用 CSS(例如 `button.row__main`)。任何一步失败都会打印 `FAIL …` 并以 exit 1 结束,`steps.log` 里保留已完成的步骤。步骤里如果有 `eval`,并且 JS 中含有引号,请先把步骤写进 JSON 文件,再用 `@file` 传入;在 shell 单引号里多层转义很容易写坏,表现为 `SyntaxError: Invalid or unexpected token`。

示例:打开录音库并进入第一条录音的详情。

```bash
node .claude/skills/verify-openminutes/scripts/browse.mjs <runId> open-detail '[{"goto":"#/library"},{"waitFor":"button.row__main"},{"click":"button.row__main"},{"waitText":"返回录音库"},{"eval":"location.hash"},{"screenshot":"detail.png"},{"aria":"detail.aria.txt"}]'
node .claude/skills/verify-openminutes/scripts/browse.mjs <runId> open-detail '[{"goto":"#/library"},{"waitFor":"button.row__main"},{"click":"button.row__main"},{"waitText":"返回录音"},{"eval":"location.hash"},{"screenshot":"detail.png"},{"aria":"detail.aria.txt"}]'
```

页面路由:`#/overview`、`#/library`、`#/rec/<id>`、`#/live`、`#/device`、`#/categories`、`#/settings`。侧边导航是 `navigation "主导航"` 下的按钮(概览 / 录音库 / 实时字幕 / 设备接入 / 分类 / 设置)。
页面路由:`#/library`(默认,可带 `?stage=failed|ready|transcribed|pending`)、`#/rec/<id>`、`#/device`、`#/live`、`#/calendar`(可带 `?day=YYYY-MM-DD&rec=<id>`)、`#/categories`、`#/settings`、`#/settings/diagnostics`。`#/overview` 和未知哈希会被改写为 `#/library`。侧边导航是 `navigation "主导航"` 下的 6 个按钮:录音 / 录音笔 / 实时字幕 / 日历 / 分类 / 设置,每页 `<h1>` 与导航标签一致。详情页的返回按钮文字随来源变化(「返回录音」「返回日历」)。

入口速查(每个功能只有一个入口):导入本地文件夹在录音页头「导入」→「扫描并导入」;连接、同步录音、断开连接、清理已同步的录音、停止只在录音笔页;录音与日程的关联只在日历页;本机环境、Python/ffmpeg 路径、传输方式、最近设备操作、设备协议指令参考、手动登记清单只在「设置 › 诊断与高级」(`#/settings/diagnostics`,自动展开 `details#diagnostics`)。错误主行是中文结论,技术原文要点「查看详情」才出现。

**接口**:直接用 curl 打 `run.json` 里的 url,不需要鉴权。响应格式是 `{ ok, data | error }`。

Expand Down Expand Up @@ -115,7 +120,7 @@ powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='ch
| 脚本 | 作用 |
| --- | --- |
| `scripts/build.mjs` | 用 Floatboat 编译器构建 `dist/` |
| `scripts/launch.mjs [runId]` | 启动隔离实例,并写入 `run.json` |
| `scripts/launch.mjs [runId] [--no-host] [--fake-device=wifi\|ble\|absent]` | 启动隔离实例(可选假录音笔),并写入 `run.json` |
| `scripts/doctor.mjs <runId>` | 只读体检,exit 0 才可以继续驱动 |
| `scripts/browse.mjs <runId> <label> <steps>` | 无头浏览器驱动页面,并留存证据 |
| `scripts/cleanup.mjs <runId>` | 停掉本 run 的进程并删除 scratch,保留证据 |
Expand Down
13 changes: 11 additions & 2 deletions .claude/skills/verify-openminutes/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,20 @@

## 功能

- [目录导入](./directory-import.md):把 MP3 放进收件目录,扫描、复制入库并去重。
导航固定 6 项:录音 / 录音笔 / 实时字幕 / 日历 / 分类 / 设置,每个功能只有一个入口(入口速查见 SKILL.md「Drive」)。

- [目录导入](./directory-import.md):录音页头「导入」对话框,扫描收件目录、复制入库并去重。
- [录音库检索](./library-search.md):全文搜索、阶段/分类/标签筛选、排序和空结果。
- [录音详情](./recording-detail.md):Range 播放、改标题、改分类/重要度/标签、收藏、导出、删除。
- [录音详情](./recording-detail.md):面包屑与返回、单一主按钮、Range 播放、改标题、改分类/重要度/标签、收藏、导出、删除、只读日程区。
- [导航与单一入口](./navigation.md):6 项导航、`aria-current` 与 h1 一致、`#/overview` 改写、主界面无技术词。
- [录音笔](./device-connection.md)(只用 `--fake-device`):点一次「连接」即同步、过程中「暂时断开外网」、诊断里的「无线快传」记录、「清理已同步的录音」、「断开连接」后自动连接暂停;`ble` 模式「已改用蓝牙」;同步中实时字幕被禁用。
- [错误分两层](./error-detail.md):`absent` 模式「没有找到录音笔」,「查看详情」或 toast「详情」后才出现 `DeviceNotFound`。
- [录音与日程关联](./calendar-link.md):详情页跳到日历、EventLinker 展开、一键关联、每条录音一个关联控件、详情只读。
- [诊断与高级](./diagnostics.md):`#/settings/diagnostics` 自动展开,含本机环境、运行时路径、传输方式、最近设备操作、宿主能力、设备协议指令参考、开发者工具;修改 `device_transport` 后用 `/api/settings` 读回。
- [分类管理](./categories.md):新建、改名、改色、删除,内置分类不可删。
- [转录与归类](./transcribe-organize.md):复用 Floatboat Host 验证单条、批量、人工修正与全文检索;显式无 Host 验证失败。
- [转录与归类成功路径](./transcribe-success.md):可选语言的离线 TTS、能力完整性检查和单条/批量 JSON 配方。

2026-11-08 工作台整改的 e2e 结果:[报告](../reports/2026-11-08-workbench-unify.md)。

实测结果与未修应用问题:[2026-10-29 Host 模式报告](../reports/2026-10-29-host-mode.md)。
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
[
{"goto":"#/device"},
{"eval":"(async () => { const required = { asr: ['start', 'sendFrame', 'events', 'stop'], llm: ['generateObject', 'generateText'] }; const response = await fetch('/api/device/capabilities'); const body = await response.json(); const capabilities = body.data; const ready = (domain, operations) => operations.every(operation => capabilities?.[domain]?.some(item => item.operation === operation && item.declared === true && item.status === 'available')); if (!response.ok || !body.ok || !ready('asr', required.asr) || !ready('llm', required.llm)) throw Error('Host ASR/LLM capabilities incomplete; no paid task submitted'); return capabilities; })()"},
{"goto":"#/library"},
{"eval":"(async () => { const required = { asr: ['start', 'sendFrame', 'events', 'stop'], llm: ['generateObject', 'generateText'] }; const response = await fetch('/api/environment/capabilities'); const body = await response.json(); const capabilities = body.data; const ready = (domain, operations) => operations.every(operation => capabilities?.[domain]?.some(item => item.operation === operation && item.declared === true && item.status === 'available')); if (!response.ok || !body.ok || !ready('asr', required.asr) || !ready('llm', required.llm)) throw Error('Host ASR/LLM capabilities incomplete; no paid task submitted'); return capabilities; })()"},
{"goto":"#/library"},
{"waitFor":"aria=搜索录音"},
{"click":"text=导入"},
{"waitText":"3 个音频文件"},
{"click":"text=立即扫描"},
{"waitText":"导入 2 个文件,跳过 1 个,失败 0 个","timeout":20000},
{"click":"text=扫描并导入"},
{"waitText":"已导入 2 条,跳过 1 条","timeout":20000},
{"click":"text=关闭"},
{"goto":"#/library"},
{"waitText":"命中 3 条录音"},
{"waitText":"当前显示 3 条"},
{"eval":"(async () => { const b = await fetch('/api/recordings').then(r => r.json()); const rows = b.data.items.filter(r => ['REC20261029_150100.mp3', 'REC20261029_150200.mp3'].includes(r.filename)); if (!b.ok || rows.length !== 2 || rows.some(r => r.status !== 'ready')) throw Error('Requires two fresh batch fixtures'); window.__batchIds = rows.map(r => r.id); return b; })()"},
{"click":"aria=选择 REC20261029_150100.mp3"},
{"click":"aria=选择 REC20261029_150200.mp3"},
{"expectText":"已选 2 条"},
{"screenshot":"01-selected.png"},
{"aria":"01-selected.aria.txt"},
{"click":"text=批量转录"},
{"waitText":"已排队 2 条转录任务"},
{"waitText":"已开始转录 2"},
{"screenshot":"02-transcribe-accepted.png"},
{"aria":"02-transcribe-accepted.aria.txt"},
{"eval":"(async () => { const deadline = Date.now() + 120000; while (Date.now() < deadline) { const rows = [...document.querySelectorAll('.table__body .row')]; const target = rows.filter(r => /REC20261029_150[12]00.mp3/.test(r.innerText)); if (target.length === 2 && target.every(r => r.querySelector('.col-status')?.innerText === '待归类')) return target.map(r => r.innerText); if (target.some(r => r.querySelector('.col-status')?.innerText === '失败')) throw Error('Batch ASR failed'); await new Promise(r => setTimeout(r, 200)); } throw Error('List did not refresh to transcribed'); })()"},
Expand All @@ -24,10 +28,10 @@
{"click":"aria=选择 REC20261029_150200.mp3"},
{"expectText":"已选 2 条"},
{"click":"text=批量归类"},
{"waitText":"已排队 2 条归类任务"},
{"waitText":"已开始归类 2"},
{"screenshot":"04-organize-accepted.png"},
{"aria":"04-organize-accepted.aria.txt"},
{"eval":"(async () => { const deadline = Date.now() + 120000; while (Date.now() < deadline) { const rows = [...document.querySelectorAll('.table__body .row')]; if (rows.length === 3 && rows.every(r => r.querySelector('.col-status')?.innerText === '已归档')) return rows.map(r => r.innerText); await new Promise(r => setTimeout(r, 200)); } throw Error('List did not refresh to organized'); })()"},
{"eval":"(async () => { const deadline = Date.now() + 120000; while (Date.now() < deadline) { const rows = [...document.querySelectorAll('.table__body .row')]; if (rows.length === 3 && rows.every(r => r.querySelector('.col-status')?.innerText === '已整理')) return rows.map(r => r.innerText); await new Promise(r => setTimeout(r, 200)); } throw Error('List did not refresh to organized'); })()"},
{"eval":"(async () => { const list = await fetch('/api/recordings').then(r => r.json()); const details = await Promise.all(window.__batchIds.map(id => fetch('/api/recordings/' + id).then(r => r.json()))); if (!list.ok || list.data.hasActiveTasks || details.some(b => { const d = b.data, r = d?.recording, a = d?.analysis; return !b.ok || r.status !== 'organized' || d.lastError || !r.title || !r.summary || !r.categoryId || !r.categoryName || !r.tags.length || !(r.transcriptChars > 0) || !d.segments.length || !a?.keyPoints.length || !a.actionItems.length || !a.categorySlug || !Number.isInteger(r.importance) || r.importance < 1 || r.importance > 5 || !d.jobs.some(j => j.kind === 'organize' && j.status === 'success' && j.finishedAt); })) throw Error('Batch organization readback failed: ' + JSON.stringify(details)); return { list, details }; })()"},
{"screenshot":"05-organized.png"},
{"aria":"05-organized.aria.txt"}
Expand Down
26 changes: 26 additions & 0 deletions .claude/skills/verify-openminutes/features/calendar-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 录音与日程关联

关联只在日历页做,每条录音只有一个关联控件(EventLinker),写接口只有 `PATCH /api/recordings/:id {calendarEventId}`。详情页的日程区只读,提供「在日历中关联 / 修改」,跳到 `#/calendar?day=<录制日期>&rec=<id>`,对应的 EventLinker 高亮并展开。

## Sub-features

- `link-jump`:详情页点「在日历中关联」,hash 带 `day`、`rec`,对应 EventLinker 带 `eventlinker--highlight` 且 `aria-expanded=true`。
- `link-suggest`:点建议项「关联到:<事件>」,出现「已关联日程」,接口读回 `calendarEventId`。
- `link-single-control`:日历页同一条录音只有一个 `.eventlinker`。
- `link-readonly`:从日历点录音标题进入详情,显示事件标题和「在日历中修改」,没有可编辑的日程控件;「返回日历」回到日历页。

## How to get to it (user POV)

- 侧边导航「日历」,或详情页日程区的「在日历中关联」链接。

## Driving it with browse.mjs

Preconditions:普通启动;已按 `import-dialog.steps.json` 导入 1 条录音(`$R/inbox/REC20261029_101500.mp3`)。

- 运行 `browse.mjs <runId> calendar-link @.claude/skills/verify-openminutes/features/calendar-link.steps.json`。第一步用 `POST /api/calendar/events` 在录制时间前后各 30 分钟建一个「验证周会」事件。
- 预期 `eval` 依次返回日历 hash、`controls: 1` 与 `calendarEventId`、`"read-only"`;`console.log` 为空。
- 更完整的日历回归(导入 .ics、解除关联、纪要失败文案)见 `scripts/verify-calendar.mjs`。

## Gotchas

- 面包屑是三个行内元素,innerText 中间没有空格,用结构断言而不是 `expectText`。
Loading
Loading