diff --git a/CHANGELOG.md b/CHANGELOG.md index 2509572..bd3c5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 2.0.11 - 2026-09-10 + +Candidate small release for scanned PDF visual preview and background subagent wakeup merging. GitHub CI, tag and Release status will be recorded after publication. + +- Text-layer PDFs are answered from extracted text; scanned PDFs render the first few full pages for vision and cite those page numbers. +- Full visual PDF reading is explicit: set start and end pages on the paperclip and resend. PNG pastes without MIME are classified by extension. +- Joining a background subagent group can strengthen wakeup (`wakeParent` OR, `waitForGroup` all > any > none) but cannot disable an existing wakeup request. +- Windows executable distributions include `@napi-rs/canvas` and the Windows x64 native binary beside the exe. + +Validation, upgrade steps and limitations: [Chinese release note](docs/releases/2.0.11-pdf-scan-vision-wakeup_zh.md). + ## 2.0.10 - 2026-09-10 Published 2026-09-10 03:38:31 UTC (2026-09-10 11:38:31 UTC+08:00): [v2.0.10](https://github.com/wicm84266964/Ant-Code/releases/tag/v2.0.10). diff --git a/README.md b/README.md index 79981c8..e5ee59a 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ ant-code doctor ant-code ``` -`ant-code --version` should print `2.0.10`. If you previously linked a +`ant-code --version` should print `2.0.11`. If you previously linked a JavaScript install, run `npm link` again so the global command points at `src/cli/index.ts`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5620d9b..afc1d93 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -13,6 +13,7 @@ runtime assets from the following third-party packages: | `katex` | math rendering runtime dependency; Dashboard CSS and font assets | MIT | | `mermaid` | Dashboard diagram rendering bundle input | MIT | | `unpdf` | PDF text-layer extraction for document_intake and Dashboard paperclip | MIT | +| `@napi-rs/canvas` | PDF full-page rendering backend for scanned composer attachments | MIT | | `yaml` | Dashboard YAML parsing bundle input | ISC | | `esbuild` | development/build tool for Dashboard assets | MIT | | `postject` | development/build tool for optional executable packaging | MIT | diff --git a/config/skills/document-intake/SKILL.md b/config/skills/document-intake/SKILL.md index 5a9c271..0ad6a97 100644 --- a/config/skills/document-intake/SKILL.md +++ b/config/skills/document-intake/SKILL.md @@ -17,7 +17,9 @@ argument_hint: 提供文档路径、需要提取的内容类型、摘要粒度 - 支持轻量解析 docx/pptx/xlsx。 - PDF 抽取内嵌文本层,默认每次最多 20 页;需要后续页时传 `pageStart`。 - Dashboard 回形针也可直接附上这些文件;发送时会写入 `ant-code-uploads/` 并抽文本。 - - 扫描件没有 OCR。回形针附上的无文本层 PDF 会把前几页内嵌图送给视觉模型。 + - 有文字层的 PDF:用抽取的文字回答;预览不够时用 `document_intake` 按页补读,不要自动逐页走视觉。 + - 无文字层的扫描件:先完整渲染前几页做视觉识别,尽快参与对话,并标明已读页码;不能假定这些页包含摘要。 + - 还需要更多扫描页时:说明未覆盖范围,询问是继续后几页还是全量视觉,并提示额外耗时和费用。全量视觉必须由用户在回形针上填写起止页后重新发送。失败或取消不能视为已读完整文档。没有本地 OCR 引擎。 3. 只有文本层为空、且用户允许时,才考虑本机 MarkItDown 或其他 OCR 转换器。 4. 对大文件只提取目录、标题、表格预览和关键段落,不输出全文。 diff --git a/docs/releases/2.0.11-pdf-scan-vision-wakeup_zh.md b/docs/releases/2.0.11-pdf-scan-vision-wakeup_zh.md new file mode 100644 index 0000000..ff33fbe --- /dev/null +++ b/docs/releases/2.0.11-pdf-scan-vision-wakeup_zh.md @@ -0,0 +1,37 @@ +# Ant Code v2.0.11:扫描件 PDF 视觉预读与子任务唤醒合并 + +发布日期:2026-09-10 + +这是面向文档阅读和后台子任务编排的小版本更新。回形针附上的 PDF 不再默认逐页全量视觉识别:有文字层时直接抽文本,扫描件先完整渲染前几页参与对话;全量视觉改为明确选择。同时修复同组后台子任务唤醒参数互相覆盖、主控不再接续的问题。本说明为发布候选记录,线上 CI、标签和 Release 状态以实际发布记录为准。 + +## 对用户工作流的直接变化 + +- 有可用文字层的 PDF:抽取文本参与回答。预览不够时按需用 `document_intake` 补读,不自动把每一页送入视觉模型。 +- 无文字层的扫描件:用完整页面渲染(文字、矢量图和内嵌图)识别前 2 页,尽快开始对话,并标明已读页码。不得把这几页当作摘要或目录。 +- 还需要更多扫描页时:说明尚未覆盖的范围,询问是继续后几页还是全量视觉,并提示额外耗时和费用。全量视觉须在回形针上填写起始页和结束页后重新发送;只在对话里说「继续」不会再渲染更多页。 +- PDF 附件保留页码输入。空着表示默认(扫描件前几页,文字层按文本窗口抽取);填写起止页才覆盖指定范围。 +- 粘贴或上传 PNG 等图片时,即使浏览器没带 MIME,也会按已支持的扩展名补全,粘贴与回形针上传共用同一套分类。 +- 同组后台子任务若携带相反的 `wakeParent` / `waitForGroup`,加入任务时在组文件锁内合并:唤醒采用 OR,等待条件采用 `all > any > none`。后写入的关闭参数不能取消已经开启的唤醒。全部明确关闭的组仍保持关闭。工具结果返回实际生效设置,并在发生合并时提示。 +- TUI 和 Dashboard 显示 PDF 识别进度与失败/取消状态;失败、空报告或取消不会伪装成已经读完整份文档。视觉页面图只驻留内存,不写入会话 transcript。 + +## 配置、数据与安全边界 + +- 不要求手动迁移配置或历史任务记录。已有会话、网关和权限设置可继续使用。 +- 回形针文档仍保存到工作区 `ant-code-uploads/`,并沿用现有忽略规则。渲染出的 PDF 页面图不进入 transcript 存储。 +- 扫描件视觉识别需要支持图片的主模型,或同一网关下的视觉路由。没有本地 OCR 引擎。 +- 新增运行时依赖 `@napi-rs/canvas` 1.0.9(MIT)。Windows 可执行分发包会附带该 JS 包和 Windows x64 原生组件;不能只复制 `ant-code.exe` 代替完整包。 +- 本次不更新其他产品的内嵌运行时,不同步真实会话、凭据或研究数据。自动化测试不等于所有供应商、字体、复杂排版或私人研究 PDF 均已验证。 + +## 升级与产物位置 + +需要 Node.js 22.18+。源码安装更新后执行 `npm ci`、`npm run verify:install`;使用全局链接的用户可执行 `npm link`。`ant-code --version` 应显示 `2.0.11`。完整重启 TUI/Dashboard,浏览器刷新以加载当前资源。 + +已有配置和会话继续使用。审计产物仍由发布检查写入既有审计目录。 + +## 验证、限制与待完成门禁 + +- 开发阶段已覆盖文字层不自动视觉、扫描件默认前两页、显式页范围全量分批、整页渲染的矢量/文字/多图像素、取消、损坏文件、网关错误、空报告、视觉路由,以及同组唤醒相反顺序、并发合并、静默组和实际唤醒事件;用户已完成本地验收。 +- 开发仓完整 `verify:release` 通过:单元/集成 1,331 项、浏览器 38 项,均无失败或跳过;语法 342 个文件、类型检查 0 诊断、依赖、来源、安装、网关兼容、资源一致性和发布封印检查通过。额外进程测试 8 项通过,与完整套件重叠,不累计为独立覆盖。 +- 跨仓一致性检查和正式仓复验在脱敏同步后进行。候选提交后仍须 GitHub PR 和 main CI 通过,随后创建标签与 Release;实际完成状态记录在公开 CHANGELOG。 +- 报告累计超过 240,000 字符时会明确失败,需要缩小页范围后重试。网关传输重试沿用现有策略;重新发送任务会重跑指定页范围。 +- 未调用真实外部模型验收所有科研 PDF。复杂字体、扫描质量和表格结构仍可能影响视觉报告,不能替代人工核对关键数字。 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 11390ae..6863d8b 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,14 +1,15 @@ { "name": "@ant-code/cli", - "version": "2.0.10", + "version": "2.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.10", + "version": "2.0.11", "license": "AGPL-3.0-only", "dependencies": { + "@napi-rs/canvas": "1.0.9", "@vscode/ripgrep": "1.18.0", "ink": "^6.8.0", "katex": "^0.16.46", @@ -540,6 +541,255 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.9.tgz", + "integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.9", + "@napi-rs/canvas-darwin-arm64": "1.0.9", + "@napi-rs/canvas-darwin-x64": "1.0.9", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", + "@napi-rs/canvas-linux-arm64-musl": "1.0.9", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-musl": "1.0.9", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", + "@napi-rs/canvas-win32-x64-msvc": "1.0.9" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", + "integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", + "integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", + "integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", + "integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", + "integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", + "integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", + "integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", + "integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", + "integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", + "integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", + "integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", diff --git a/package-lock.json b/package-lock.json index 11390ae..6863d8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@ant-code/cli", - "version": "2.0.10", + "version": "2.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.10", + "version": "2.0.11", "license": "AGPL-3.0-only", "dependencies": { + "@napi-rs/canvas": "1.0.9", "@vscode/ripgrep": "1.18.0", "ink": "^6.8.0", "katex": "^0.16.46", @@ -540,6 +541,255 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.9.tgz", + "integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.9", + "@napi-rs/canvas-darwin-arm64": "1.0.9", + "@napi-rs/canvas-darwin-x64": "1.0.9", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", + "@napi-rs/canvas-linux-arm64-musl": "1.0.9", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-gnu": "1.0.9", + "@napi-rs/canvas-linux-x64-musl": "1.0.9", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", + "@napi-rs/canvas-win32-x64-msvc": "1.0.9" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", + "integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", + "integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", + "integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", + "integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", + "integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", + "integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", + "integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", + "integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", + "integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", + "integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", + "integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", diff --git a/package.json b/package.json index ae91f01..4a824fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-code/cli", - "version": "2.0.10", + "version": "2.0.11", "description": "AGPL-licensed local coding agent with a terminal UI, local dashboard, tool permissions, skills, MCP integration, and model gateway adapters.", "type": "module", "private": true, @@ -90,6 +90,7 @@ "node": ">=22.18" }, "dependencies": { + "@napi-rs/canvas": "1.0.9", "@vscode/ripgrep": "1.18.0", "ink": "^6.8.0", "katex": "^0.16.46", diff --git a/scripts/build-windows-exe.ts b/scripts/build-windows-exe.ts index a0f7fe8..cdd7242 100644 --- a/scripts/build-windows-exe.ts +++ b/scripts/build-windows-exe.ts @@ -111,6 +111,10 @@ run(process.execPath, [ for (const relativePath of RESOURCE_PATHS) { await copyResource(relativePath); } +// Native canvas cannot be embedded in the JS SEA asset. +await copyResource("node_modules/@napi-rs/canvas"); +await copyResource("node_modules/@napi-rs/canvas-win32-x64-msvc"); +await fs.access(path.join(RELEASE, "node_modules/@napi-rs/canvas-win32-x64-msvc/skia.win32-x64-msvc.node")); await writeLauncher("ant-code.cmd", "ant-code.exe"); await writeLauncher("lab-agent.cmd", "ant-code.exe"); @@ -127,7 +131,8 @@ const manifest = [ "This external package is the Windows executable distribution. It includes", "a bundled runtime executable plus configuration templates, deployment docs,", "skills, and audit evidence. It does not include the repository src/ tree,", - "tests, npm tarball, node_modules, handoff notes, or planning notes.", + "tests, npm tarball, handoff notes, or planning notes. The node_modules directory", + "contains only the native PDF canvas runtime and its Windows x64 binary.", "", "Run:", "", diff --git a/scripts/verify-release-seal.ts b/scripts/verify-release-seal.ts index 6c0331a..ed39ff0 100644 --- a/scripts/verify-release-seal.ts +++ b/scripts/verify-release-seal.ts @@ -5,6 +5,12 @@ import { ROOT, collectFiles, rel } from "./audit-common.ts"; const failures: string[] = []; const REVIEWED_RUNTIME_DEPENDENCIES = new Map([ + ["@napi-rs/canvas", { + versionSpec: "1.0.9", + installedVersion: "1.0.9", + license: "MIT", + provenanceMarker: "open_source: napi-rs canvas PDF page rendering backend, MIT licensed, public npm package" + }], ["@vscode/ripgrep", { versionSpec: "1.18.0", installedVersion: "1.18.0", diff --git a/src/agents/task-group-store.ts b/src/agents/task-group-store.ts index c062ea5..9e69f1a 100644 --- a/src/agents/task-group-store.ts +++ b/src/agents/task-group-store.ts @@ -78,9 +78,19 @@ export function createAgentTaskGroupStore(options: { cwd: string; onListScan?: ( const existing = await readGroupFile(path.join(directory, `${id}.json`)); if (existing.ok) { const taskIds = mergeUnique(existing.group.taskIds, group.taskIds); + const incoming = normalizeGroup({ ...existing.group, ...group }); + // Joining tasks may strengthen the group's wake contract, never disable it. + const waitFor = ["all", "any", "none"].find((value) => ( + value === existing.group.waitFor || value === incoming.waitFor + )); const record = normalizeGroup({ ...existing.group, ...group, + waitFor, + wakeParent: existing.group.wakeParent || incoming.wakeParent, + wakeReason: existing.group.wakeParent && existing.group.waitFor !== "none" + ? existing.group.wakeReason || incoming.wakeReason + : incoming.wakeReason || existing.group.wakeReason, id, taskIds, status: existing.group.status === "queued" ? "running" : existing.group.status, @@ -89,7 +99,7 @@ export function createAgentTaskGroupStore(options: { cwd: string; onListScan?: ( }); await writeGroup(directory, record); invalidateGroupListCache(directory); - return { ok: true, group: record }; + return { ok: true as const, group: record }; } if (existing.error.code !== "AGENT_TASK_GROUP_NOT_FOUND") { return existing; @@ -105,7 +115,7 @@ export function createAgentTaskGroupStore(options: { cwd: string; onListScan?: ( }); await writeGroup(directory, record); invalidateGroupListCache(directory); - return { ok: true, group: record }; + return { ok: true as const, group: record }; }); }, async updateGroup(groupId: unknown, patch: Record) { diff --git a/src/cli/tui/app-actions.ts b/src/cli/tui/app-actions.ts index 38774f2..4ed53b2 100644 --- a/src/cli/tui/app-actions.ts +++ b/src/cli/tui/app-actions.ts @@ -1051,6 +1051,8 @@ export function useTuiAppActions(s: ReturnType) { void loadTaskRecords(); setSideView("tasks"); } + } else if (event.type === "pdf_vision_progress") { + setActivity((current) => ({ ...current, status: `PDF ${event.pageEnd ?? ""}/${event.totalPages ?? ""} ${event.stage === "completed" ? "已识别" : "识别中"}`, lastTool: "PDF vision" })); } else if (event.type === "subagent_group_started") { const body = [ `group=${event.groupId}`, diff --git a/src/cli/tui/types.ts b/src/cli/tui/types.ts index 46d92cb..65931e5 100644 --- a/src/cli/tui/types.ts +++ b/src/cli/tui/types.ts @@ -97,6 +97,8 @@ export type TuiRuntimeEvent = { truncated?: boolean; error?: TuiGatewayError; stage?: string; + pageEnd?: number; + totalPages?: number; attempt?: number; maxAttempts?: number; delayMs?: number; diff --git a/src/context/builder.ts b/src/context/builder.ts index 819d6cf..44cbc34 100644 --- a/src/context/builder.ts +++ b/src/context/builder.ts @@ -70,6 +70,7 @@ export async function buildInitialContext(options: { cwd: string; config: import `- For long tasks, dispatch independent readonly agent_run calls in the same tool batch only when useful and within the current readonly parallel budget (${parallelBudgetText}): explorer for code shape, planner for execution graph, web-researcher for external evidence, reviewer for risk. Add more children only when the scopes are clearly independent and worth the extra model round. Use modelTier=cheap for routine read/search slices so the configured low-cost model absorbs bulk context.`, "- For broad or slow child work, prefer agent_run with background=true, a shared groupId, waitForGroup=all, wakeParent=true, and a clear wakeReason. After starting a background group, do not continue a long answer unless you have independent local work; give a brief status and wait for the group completion prompt to wake you.", "- Background subagents are not interrupted by ending the current parent turn. Their completion will return as an Ant Code subagent group completed prompt; treat that prompt as the next coordinator step.", + "- waitForGroup and wakeParent apply to the entire group. Set the same values on every task in a shared group; do not disable wakeup on the first task and enable it only on the last. Never end your turn expecting automatic continuation from a group with wakeParent=false or waitForGroup=none.", "- For long-running terminal commands that should continue while the user keeps chatting, use background_shell instead of powershell/bash or hand-written Start-Process/nohup. It registers PID, logs, status, and Dashboard cancellation; report the task id and log paths, then end the turn promptly. Before restarting a server/viewer/discover/download/render job, call background_terminal_list to check whether an existing task is still running; reuse it when suitable, or call background_terminal_cancel on the old taskId before starting a replacement.", "- Direct parent execution is reserved for simple answers, one-file edits with obvious scope, final synthesis, high-level tradeoff decisions, approval-sensitive actions, and user-facing reporting.", "- Direct parent reads are allowed for a user-specified single file/path, a single exact URL, or small follow-up snippets after child agents identify evidence. Do not turn those exceptions into broad parent exploration.", @@ -111,7 +112,7 @@ export async function buildInitialContext(options: { cwd: string; config: import "- /compact is a context-window operation that prefers a separate model summarization request through the configured lab gateway; if the gateway is unavailable or summarization fails, it falls back to local deterministic compaction.", "- Compaction keeps recent messages exactly and stores a bounded redacted summary of older messages for future turns.", "- web_fetch is the stable public tool name, but URL fetching is configured MCP-first by default: prefer the fetch MCP when available, then fall back to the built-in bounded fetcher. web_search uses built-in DuckDuckGo HTML only; do not expect Bing/Brave scrapers or search MCP. A self-hosted SearXNG is the only stable no-key search backend. Use network tools only when the task needs current or external information, and cite URLs in user-facing conclusions.", - "- document_intake extracts bounded local text from common text/HTML/Office files and the text layer of PDFs inside the workspace. PDFs default to a 20-page window; pass pageStart/maxPages to continue. Dashboard paperclip can attach those files directly: images stay vision attachments, documents are saved under ant-code-uploads/ and ingested. Scanned PDFs are not OCR'd; composer-attached PDFs with no text layer send a few embedded page images to the configured vision model.", + "- document_intake extracts bounded local text from common text/HTML/Office files and the text layer of PDFs inside the workspace. PDFs default to a 20-page window; pass pageStart/maxPages to continue. Dashboard paperclip can attach those files directly: images stay vision attachments, documents are saved under ant-code-uploads/ and ingested. Text-layer PDFs are answered from extracted text; do not auto-run page vision. Scanned PDFs render the first few full pages for vision, cite those page numbers, and must not assume they contain the abstract. If more visual pages are needed, state the unread range, ask before continuing or doing a full visual read, and warn about extra time and cost. Full visual read requires explicit start and end pages on the attachment.", "- MCP is optional. Use mcp_list to inspect configured servers/tools; mcp_call works only when a local or lab-approved MCP server is configured, and missing MCP servers do not disable built-in local tools.", "- Recommended no-key MCP servers are enabled by default when self-contained; service-bound entries such as SearXNG/SQLite may remain disabled until configured. Use /mcp doctor before relying on MCP, and /mcp doctor --live when you need tool discovery.", "- Skills are local instruction resources discovered from project/configured skill directories. Use skill_list, skill_read, or skill_run before applying a specialized workflow.", diff --git a/src/core/session-messages.ts b/src/core/session-messages.ts index e4d8cf8..9ecca9d 100644 --- a/src/core/session-messages.ts +++ b/src/core/session-messages.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { renderPdfBatches, type PdfVisionDocument } from "../tools/pdf-render.ts"; import { buildInitialContext } from "../context/builder.ts"; import { loadConfig, type LabAgentConfig } from "../config/load-config.ts"; import { @@ -84,6 +85,8 @@ export function buildTurnMessages(session: AgentSession, userMessage: SessionMes export async function prepareVisionAttachmentsForTurn(options: { session: AgentSession; attachments?: unknown; + pdfDocuments?: PdfVisionDocument[]; + forceVisionAnalysis?: boolean; prompt?: string; gateway?: unknown; signal?: AbortSignal; @@ -93,16 +96,52 @@ export async function prepareVisionAttachmentsForTurn(options: { antEventNormalizer?: ReturnType; }; metadata?: SessionTurnMetadata; -}) { +}): Promise<{ ok: boolean; attachments?: InputImageAttachment[]; analysisText?: string; status?: string; output?: string }> { const attachments = normalizeInputAttachments(options.attachments); + if (options.pdfDocuments?.length) { + const reports: string[] = []; + let reportChars = 0; + try { + // Check routing before rendering potentially large documents. + if (!modelSupportsImages(options.session.config, options.session.model) && !resolveVisionAgentModel(options.session.config)) { + return { ok: false, status: "vision_unavailable", output: "PDF 按页识别需要视觉模型。请切换到支持图片的主模型,或启用同网关的视觉模型后重试。" }; + } + for (const document of options.pdfDocuments) { + await emitEvent(options.eventOptions, { type: "pdf_vision_progress", name: document.name, stage: "rendering" }); + for await (const batch of renderPdfBatches(document, { signal: options.signal })) { + await emitEvent(options.eventOptions, { type: "pdf_vision_progress", name: document.name, pageStart: batch.pageStart, pageEnd: batch.pageEnd, totalPages: batch.totalPages, stage: "analyzing" }); + const prepared = await prepareVisionAttachmentsForTurn({ + ...options, pdfDocuments: undefined, forceVisionAnalysis: true, attachments: batch.images, + prompt: `${options.prompt ?? ""}\nPDF: ${document.name}; pages ${batch.pageStart}-${batch.pageEnd} of ${batch.totalPages}. Analyze only these supplied pages, cite page numbers, preserve table/figure values relevant to the request, and mark unreadable details. These may be the first pages of a scan and are not necessarily the abstract. Do not claim to have read other pages.` + }); + if (!prepared.ok) return prepared; + const report = `PDF ${document.name}, pages ${batch.pageStart}-${batch.pageEnd} of ${batch.totalPages}:\n${prepared.analysisText}`; + reportChars += report.length; + if (reportChars > 240_000) throw new Error("PDF visual reports exceed the context budget; select a smaller page range and retry."); + reports.push(report); + await emitEvent(options.eventOptions, { type: "pdf_vision_progress", name: document.name, pageStart: batch.pageStart, pageEnd: batch.pageEnd, totalPages: batch.totalPages, stage: "completed" }); + } + } + const images = await prepareVisionAttachmentsForTurn({ ...options, pdfDocuments: undefined }); + if (!images.ok) return images; + return { ...images, analysisText: [...reports, images.analysisText].filter(Boolean).join("\n\n") }; + } catch (error) { + const interrupted = options.signal?.aborted; + const output = interrupted ? "PDF 视觉识别已取消,未完成的页面没有被当作已识别。" : `PDF 视觉识别失败,未生成完整报告:${error instanceof Error ? error.message : String(error)}`; + await emitEvent(options.eventOptions, { type: "pdf_vision_error", interrupted, output }); + return { ok: false, status: interrupted ? "interrupted" : "vision_error", output }; + } + } if (attachments.length === 0) { return { ok: true, attachments, analysisText: "" }; } - if (modelSupportsImages(options.session.config, options.session.model)) { + if (!options.forceVisionAnalysis && modelSupportsImages(options.session.config, options.session.model)) { return { ok: true, attachments, analysisText: "" }; } - const visionModel = resolveVisionAgentModel(options.session.config); + const visionModel = options.forceVisionAnalysis && modelSupportsImages(options.session.config, options.session.model) + ? { id: options.session.model } + : resolveVisionAgentModel(options.session.config); if (!visionModel) { const output = [ "当前主模型不支持图片输入,且当前网关配置里没有可用的视觉模型。", @@ -158,7 +197,10 @@ export async function prepareVisionAttachmentsForTurn(options: { return { ok: false, status: "vision_error", output }; } - const analysisText = formatAssistantOutput(response.data).trim(); + const analysisText = response.data.text.trim(); + if (!analysisText) { + return { ok: false, status: "vision_error", output: "视觉模型未返回有效识别内容,请重试或检查视觉模型配置。" }; + } await emitEvent(options.eventOptions, { type: "vision_analysis_complete", model: response.data.model ?? visionModel.id, @@ -179,7 +221,7 @@ export function buildVisionAnalysisMessage(prompt: string, attachments: InputIma { type: "text", text: [ - "你是 Ant Code visual-verifier 视觉复核子智能体。当前主模型不支持图片输入,请你先处理用户上传的图片,输出可供另一个文本模型继续工作的中文视觉证据报告。", + "你是 Ant Code visual-verifier 视觉复核子智能体。请先处理用户上传的图片或 PDF 渲染页面,输出可供主智能体继续工作的中文视觉证据报告。", "职责:把截图/图片当作证据,识别任务类型(UI/前端截图、代码或错误截图、表格/图表、文档、前后对比等),提取可见事实、OCR 文字、界面元素、布局状态、异常现象和不确定点。", "前端/UI 任务需重点复核:布局完整性、响应式视口、重叠/遮挡/裁切、对齐/间距、可读性/对比度、加载/错误/空状态、交互线索与用户验收目标是否一致。", "输出结构:target、visualEvidence、findings、result、residualRisks、recommendedFollowup。发现问题时 findings 优先;没有问题时明确 pass/uncertain。", diff --git a/src/core/session-turn.ts b/src/core/session-turn.ts index 36b0d3f..da6948a 100644 --- a/src/core/session-turn.ts +++ b/src/core/session-turn.ts @@ -268,6 +268,7 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT prompt: options.prompt, attachments, gateway, + pdfDocuments: ingested.pdfDocuments, signal: options.signal, eventOptions, metadata @@ -296,8 +297,8 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT const userMessage = buildUserTurnMessage(modelPrompt, session.workflow, visionPreparation.attachments, visionPreparation.analysisText); let messages: SessionMessage[] = buildTurnMessages(session, userMessage); let toolResults: SessionToolResult[] = []; - const turnMessages: SessionMessage[] = [persistableUserTurnMessage(options.prompt, attachments)]; - const transcriptTurnMessages: SessionMessage[] = [persistableUserTurnMessage(displayPrompt, attachments)]; + const turnMessages: SessionMessage[] = [persistableUserTurnMessage(options.prompt, options.attachments ?? attachments)]; + const transcriptTurnMessages: SessionMessage[] = [persistableUserTurnMessage(displayPrompt, options.attachments ?? attachments)]; const maxToolRounds = resolveMainToolRounds(session.config); const turnChangeTracker = createTurnChangeTracker(); diff --git a/src/dashboard/events.ts b/src/dashboard/events.ts index 804838f..748232f 100644 --- a/src/dashboard/events.ts +++ b/src/dashboard/events.ts @@ -54,6 +54,13 @@ export function mapSessionEventToDashboard(event: Record) { if (type === "turn_start") { return [activity("turn-start", "开始任务", "正在准备本轮请求", "running", "session", event, { coalesceKey: "turn" })]; } + if (type === "pdf_vision_progress") { + const detail = `${String(event.name ?? "PDF")}${event.pageEnd ? ` · ${event.pageStart}-${event.pageEnd} / ${event.totalPages}` : ""}`; + return [activity("pdf-vision", event.stage === "completed" ? "PDF 页面识别完成" : event.stage === "rendering" ? "正在渲染 PDF" : "正在识别 PDF 页面", detail, "running", "gateway", event, { coalesceKey: "pdf-vision" })]; + } + if (type === "pdf_vision_error") { + return [activity("pdf-vision", event.interrupted ? "PDF 识别已取消" : "PDF 识别失败", String(event.output ?? ""), event.interrupted ? "interrupted" : "failed", "gateway", event, { coalesceKey: "pdf-vision" })]; + } if (type === "gateway_request_start") { return [activity("gateway-request", "正在请求模型", roundDetail(event), "running", "gateway", event, { coalesceKey: "gateway" })]; } diff --git a/src/dashboard/public/app-core.ts b/src/dashboard/public/app-core.ts index 8c83140..3c8eaf2 100644 --- a/src/dashboard/public/app-core.ts +++ b/src/dashboard/public/app-core.ts @@ -616,6 +616,8 @@ export type DashboardUiState = { type?: string; data?: string; previewUrl?: string; + pageStart?: number; + pageEnd?: number; }>; workflow: { todos?: Array<{ status?: string; content?: string; title?: string }>; @@ -980,4 +982,3 @@ export const PREVIEW_WIDTH_MIN = 300; export const PREVIEW_WIDTH_MAX = 640; export const PREVIEW_WORKSPACE_MIN = 520; - diff --git a/src/dashboard/public/app-ui1.ts b/src/dashboard/public/app-ui1.ts index 8aeff72..843705b 100644 --- a/src/dashboard/public/app-ui1.ts +++ b/src/dashboard/public/app-ui1.ts @@ -1,4 +1,5 @@ import { renderMarkdown } from "./markdown.ts"; +import { classifyComposerFile } from "./app-ui2.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; @@ -120,7 +121,7 @@ export function bindEvents() { els.attachmentInput.value = ""; }); els.promptInput.addEventListener("paste", async (event: ClipboardEvent) => { - const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/")); + const files = Array.from(event.clipboardData?.files ?? []).filter((file) => classifyComposerFile(file) === "image"); if (files.length === 0) { return; } @@ -944,4 +945,3 @@ export function modalFocusableElements(modal: HTMLElement | null | undefined): H node.getAttribute("aria-hidden") !== "true" && !node.closest("[inert]") )); } - diff --git a/src/dashboard/public/app-ui2.ts b/src/dashboard/public/app-ui2.ts index 8c707d9..40ba90f 100644 --- a/src/dashboard/public/app-ui2.ts +++ b/src/dashboard/public/app-ui2.ts @@ -704,7 +704,7 @@ export function readImageAttachment(file: File) { id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, type: "image", name: file.name || "image", - mimeType: match[1] || file.type || "image/png", + mimeType: /^(image\/(png|jpeg|gif|webp))$/i.test(match[1]) ? match[1].toLowerCase() : imageMimeFromName(file.name), size: file.size, data: match[2], previewUrl: dataUrl @@ -771,6 +771,25 @@ export function renderAttachmentStrip() { renderAttachmentStrip(); updateSendButton(); }); + if (isDocument && /\.pdf$/i.test(label)) { + const range = document.createElement("div"); + range.className = "attachment-page-range"; + for (const [key, labelText, placeholder] of [["pageStart", "起始页", "默认"], ["pageEnd", "结束页", "默认"]] as const) { + const input = document.createElement("input"); + input.type = "number"; + input.min = "1"; + input.step = "1"; + input.placeholder = placeholder; + input.title = key === "pageEnd" + ? "扫描件不填则只视觉识别前几页;全量视觉请填写结束页" + : "不填则从第 1 页开始"; + input.setAttribute("aria-label", `${label} ${labelText}`); + input.value = attachment[key] === undefined ? "" : String(attachment[key]); + input.addEventListener("input", () => { attachment[key] = input.value ? Number(input.value) : undefined; }); + range.append(input as unknown as Node); + } + item.append(range); + } els.attachmentStrip.append(item); } } @@ -780,13 +799,20 @@ export function documentChipLabel(name: unknown) { return ext.slice(0, 4); } -export function attachmentPayload(attachment: { type?: string; name?: string; mimeType?: string; size?: number; data?: string }) { +export function imageMimeFromName(name: string) { + const extension = name.split(".").pop()?.toLowerCase(); + return ({ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" } as Record)[extension ?? ""] || "application/octet-stream"; +} + +export function attachmentPayload(attachment: { type?: string; name?: string; mimeType?: string; size?: number; data?: string; pageStart?: number; pageEnd?: number }) { return { type: attachment.type === "document" ? "document" : "image", name: attachment.name, mimeType: attachment.mimeType, size: attachment.size, - data: attachment.data + data: attachment.data, + pageStart: attachment.pageStart, + pageEnd: attachment.pageEnd }; } @@ -879,12 +905,12 @@ export async function sendPrompt() { await loadSessions(); } -export function stableTurnRequest(prompt: string, attachments: Array<{ id?: string; name?: string; mimeType?: string; size?: number }>) { +export function stableTurnRequest(prompt: string, attachments: Array<{ id?: string; name?: string; mimeType?: string; size?: number; pageStart?: number; pageEnd?: number }>) { const signature = JSON.stringify({ prompt, sessionId: state.currentSessionId, permissionMode: state.permissionMode, - attachments: attachments.map((item) => [item.id, item.name, item.mimeType, item.size]) + attachments: attachments.map((item) => [item.id, item.name, item.mimeType, item.size, item.pageStart, item.pageEnd]) }); if (state.turnRequest?.signature === signature) { return state.turnRequest; @@ -1021,4 +1047,3 @@ export async function cancelQueuedTurn(queueItemId: unknown) { } syncGuideButton(); } - diff --git a/src/dashboard/public/app.js b/src/dashboard/public/app.js index 489e258..08e3066 100644 --- a/src/dashboard/public/app.js +++ b/src/dashboard/public/app.js @@ -384,1792 +384,1817 @@ var PREVIEW_WIDTH_MIN = 300; var PREVIEW_WIDTH_MAX = 640; var PREVIEW_WORKSPACE_MIN = 520; -// src/dashboard/public/app-ui1.ts -async function init() { - restorePreviewWidth(); - bindEvents(); - observeRunStatus(); - await bootstrapDashboard(); +// src/dashboard/public/app-ui2.ts +function activateModal(modal, options = {}) { + if (!modal) return; + if (state.modalContext?.modal === modal) { + focusModalInitialTarget(modal, options.initialFocus); + return; + } + if (state.modalContext) { + deactivateModal(state.modalContext.modal, { restoreFocus: false }); + } + const inertEntries = collectModalBackground(modal); + const returnFocus = options.returnFocus ?? document.activeElement; + state.modalContext = { + modal, + returnFocus, + inertEntries, + previousRole: modal.getAttribute("role"), + previousAriaModal: modal.getAttribute("aria-modal"), + previousTabIndex: modal.getAttribute("tabindex") + }; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + if (!modal.hasAttribute("tabindex")) modal.setAttribute("tabindex", "-1"); + if (modal !== els.modelConfigPanel && modal !== els.imageLightbox) { + modal.classList.add("modal-interaction"); + } + for (const entry of inertEntries) entry.node.inert = true; + focusModalInitialTarget(modal, options.initialFocus); } -async function bootstrapDashboard() { - const request = beginScopedRequest("bootstrap"); - renderBootstrapLoading(); - try { - const status = await getJson(statusUrl(), { signal: request.signal }); - if (!isCurrentScopedRequest(request)) return; - if (!status.ok) { - throw dashboardPayloadError(status, "Dashboard 初始化失败"); - } - if (status.version !== DASHBOARD_API_VERSION) { - throw new Error("Dashboard 前后端版本不一致,请重启 Dashboard 服务后刷新页面"); - } - state.cwd = String(status.cwd ?? ""); - state.models = normalizeModels(status.models); - state.gatewayConfig = normalizeGatewayConfig(status.gatewayConfig); - state.gatewayProfiles = normalizeGatewayProfiles(status.gatewayProfiles); - state.agentModelTiers = normalizeAgentModelTiers(status.agentModelTiers); - state.visionAgent = normalizeVisionAgent(status.visionAgent); - state.settings = normalizeDashboardSettings(status.settings); - updateConfigRevisions(status); - state.applyAgentDefaultsOnSwitch = Boolean(state.settings?.agents?.syncModelTiersOnSwitch); - updateSessionStatus({ - ...status.sessionStatus, - providerId: status.sessionStatus?.providerId ?? state.gatewayConfig?.activeProfileId ?? "" - }); - rememberNewTaskModelState(); - if (els.projectPath) { - els.projectPath.textContent = String(status.cwd ?? ""); - } - const trust = await loadTrust({ signal: request.signal, silent: true }); - if (!trust?.ok) { - throw dashboardPayloadError(trust, "无法读取工作区信任状态"); - } - if (!isCurrentScopedRequest(request)) return; - await loadSessions(); - if (!isCurrentScopedRequest(request)) return; - await restoreInitialSession(); - if (!isCurrentScopedRequest(request)) return; - updateSendButton(); - renderComposerStatus(); - clearBootstrapStatus(); - } catch (error) { - if (!isAbortError(error) && isCurrentScopedRequest(request)) { - renderBootstrapFailure(error); +function collectModalBackground(modal) { + const entries = []; + const seen = /* @__PURE__ */ new Set(); + let branch = modal; + while (branch?.parentElement) { + const parent = branch.parentElement; + for (const node of Array.from(parent.children ?? [])) { + if (node === branch || seen.has(node)) continue; + seen.add(node); + entries.push({ node, inert: Boolean(node.inert) }); } - } finally { - finishScopedRequest(request); + branch = parent; + if (parent === document.body) break; } + return entries; } -function observeRunStatus() { - updateRunStatusTone(); - new MutationObserver(updateRunStatusTone).observe(els.runStatus, { childList: true, characterData: true, subtree: true }); +function focusModalInitialTarget(modal, selector) { + requestAnimationFrame(() => { + if (!modal || state.modalContext?.modal !== modal) return; + const target = typeof selector === "string" ? modal.querySelector(selector) : modalFocusableElements(modal)[0]; + (target ?? modal).focus({ preventScroll: true }); + }); } -function updateRunStatusTone() { - const status = els.runStatus.textContent.trim(); - let tone = "idle"; - if (/失败|拒绝/.test(status)) { - tone = "error"; - } else if (/等待|待/.test(status)) { - tone = "waiting"; - } else if (/运行|启动|引导|中断|停止|收尾|排队|压缩/.test(status)) { - tone = "running"; - } else if (/完成/.test(status)) { - tone = "done"; - } - els.runStatus.dataset.tone = tone; +function deactivateModal(modal, options = {}) { + const context = state.modalContext; + if (!context || context.modal !== modal) return; + state.modalContext = null; + for (const entry of context.inertEntries) entry.node.inert = entry.inert; + modal.classList.remove("modal-interaction"); + restoreModalAttribute(modal, "role", context.previousRole); + restoreModalAttribute(modal, "aria-modal", context.previousAriaModal); + restoreModalAttribute(modal, "tabindex", context.previousTabIndex); + syncResponsiveNavigation(); + if (options.restoreFocus === false) return; + const fallback = typeof options.fallbackFocus === "string" ? document.querySelector(options.fallbackFocus) : options.fallbackFocus; + const target = context.returnFocus?.isConnected === false ? fallback : context.returnFocus ?? fallback; + requestAnimationFrame(() => target?.focus?.({ preventScroll: true })); } -function bindEvents() { - els.refreshSessions.addEventListener("click", () => loadSessions({ feedback: true })); - els.collapseSidebar.addEventListener("click", () => { - if (responsiveLayoutMode() === "desktop") { - toggleSidebar(); - } else { - setResponsiveView("conversation"); - } - }); - els.newTask.addEventListener("click", () => { - newTask(); - setResponsiveView("conversation"); - }); - els.sendButton.addEventListener("click", () => { - if (state.turnSubmitting) { +function restoreModalAttribute(element, name, value) { + if (!element) return; + if (value === null || typeof value === "undefined") element.removeAttribute(name); + else element.setAttribute(name, String(value)); +} +function handleGlobalKeydown(event) { + const activeModal = state.modalContext?.modal; + if (activeModal) { + if (event.key === "Tab") { + const focusables = modalFocusableElements(activeModal); + const target = focusTrapTarget(focusables, document.activeElement, event.shiftKey); + event.preventDefault(); + (target ?? activeModal).focus?.({ preventScroll: true }); return; } - if (state.running) { - interruptTurn(); + if (event.key === "Escape") { + event.preventDefault(); + closeActiveModal(activeModal); return; } - sendPrompt(); - }); - els.promptInput.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + if (activeModal === els.imageLightbox && event.key === "ArrowLeft") { event.preventDefault(); - sendPrompt(); - } - }); - els.promptInput.addEventListener("input", () => { - syncGuideButton(); - resizePromptInput(); - }); - els.attachButton.addEventListener("click", () => els.attachmentInput.click()); - els.attachmentInput.addEventListener("change", async () => { - await addAttachmentFiles(Array.from(els.attachmentInput.files ?? [])); - els.attachmentInput.value = ""; - }); - els.promptInput.addEventListener("paste", async (event) => { - const files = Array.from(event.clipboardData?.files ?? []).filter((file) => file.type.startsWith("image/")); - if (files.length === 0) { - return; + moveLightbox(-1); + } else if (activeModal === els.imageLightbox && event.key === "ArrowRight") { + event.preventDefault(); + moveLightbox(1); } + return; + } + if (state.questionReviewMode && event.key === "Escape") { event.preventDefault(); - await addAttachmentFiles(files); - }); - document.addEventListener("keydown", handleGlobalKeydown); - els.permissionMode.addEventListener("click", (event) => { - const button = eventElement(event)?.closest("button[data-mode]"); - if (!(button instanceof HTMLButtonElement)) return; - requestPermissionMode(button.dataset.mode, button); - }); - els.permissionMode.addEventListener("keydown", handlePermissionModeKeydown); - els.goalMode?.addEventListener("click", () => requestGoalMode()); - els.preview.addEventListener("click", handleLocalFileOpenClick); - els.collapsePreview.addEventListener("click", () => { - if (responsiveLayoutMode() === "desktop") { - document.body.classList.toggle("preview-collapsed"); - syncPreviewResizeHandle(); - } else { - setResponsiveView("conversation"); + returnToQuestion(); + return; + } + if (event.key === "Escape") { + if (state.modelPanelOpen) hideModelPanel(); + else if (state.settingsOpen) hideSettingsWorkspace(); + else if (state.responsiveView !== "conversation") setResponsiveView("conversation"); + } +} +function closeActiveModal(modal) { + if (modal === els.modelConfigPanel) hideModelConfigPanel(); + else if (modal === els.imageLightbox) hideLightbox(); + else if (modal === els.shutdownPanel) hideShutdownPanel(); + else if (modal === els.contextPanel) hideContextConfirm(); + else if (modal === els.permissionConfirmPanel) hidePermissionConfirm(); + else if (modal === els.goalConfirmPanel) hideGoalConfirm(); + else if (modal === els.goalTextPanel) hideGoalTextPanel(); + else if (modal === els.approvalPanel) resolveApproval("cancel"); + else if (modal === els.questionPanel) cancelQuestion(); +} +async function loadTrust(options = {}) { + const result = await getJson("/api/trust", { signal: options.signal }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), aborted: isAbortError(error) })); + if (!result.ok) { + if (!options.silent && !result.aborted) { + showError(result.error ?? "无法读取工作区信任状态"); } - }); - els.previewResizeHandle?.addEventListener("pointerdown", (event) => beginPreviewResize(event)); - els.previewResizeHandle?.addEventListener("pointermove", (event) => updatePreviewResize(event)); - els.previewResizeHandle?.addEventListener("pointerup", (event) => finishPreviewResize(event)); - els.previewResizeHandle?.addEventListener("pointercancel", (event) => finishPreviewResize(event)); - els.previewResizeHandle?.addEventListener("keydown", (event) => handlePreviewResizeKeydown(event)); - els.previewResizeHandle?.addEventListener("dblclick", () => { - setPreviewWidth(PREVIEW_WIDTH_DEFAULT, { persist: true, announce: true }); - }); - els.shutdownButton.addEventListener("click", showShutdownPanel); - els.headerShutdownButton.addEventListener("click", showShutdownPanel); - els.shutdownCancel.addEventListener("click", hideShutdownPanel); - els.shutdownConfirm.addEventListener("click", shutdownDashboard); - els.lightboxBackdrop.addEventListener("click", hideLightbox); - els.lightboxClose.addEventListener("click", hideLightbox); - els.lightboxPrevious.addEventListener("click", () => moveLightbox(-1)); - els.lightboxNext.addEventListener("click", () => moveLightbox(1)); - els.contextClear.addEventListener("click", () => showContextConfirm("clear")); - els.contextCompact.addEventListener("click", () => showContextConfirm("compact")); - els.modelStatus.addEventListener("click", handleModelStatusActivate); - els.modelStatus.addEventListener("keydown", handleModelStatusKeydown); - els.modelStatus.addEventListener("change", handleReasoningEffortChange); - els.modelPanel.addEventListener("click", handleModelPanelClick); - els.modelPanel.addEventListener("change", handleModelPanelChange); - els.modelConfigPanel.addEventListener("click", handleModelConfigPanelClick); - els.modelConfigPanel.addEventListener("input", handleModelConfigInput); - els.modelConfigPanel.addEventListener("change", handleModelConfigChange); - els.modelConfigPanel.addEventListener("submit", saveModelConfig); - els.settingsButton?.addEventListener("click", () => { - if (state.settingsOpen) hideSettingsWorkspace(); - else showSettingsWorkspace(); - }); - els.settingsBack?.addEventListener("click", () => hideSettingsWorkspace()); - els.settingsRail?.addEventListener("click", handleSettingsRailClick); - els.settingsContent?.addEventListener("click", handleSettingsClick); - els.settingsContent?.addEventListener("input", handleSettingsFormChange); - els.settingsContent?.addEventListener("change", handleSettingsFormChange); - els.settingsContent?.addEventListener("submit", saveSettingsConfig); - els.liveSubtasks.addEventListener("click", (event) => { - const button = eventTargetOf(event).closest("button[data-background-cancel]"); - if (!button) { - return; + return result; + } + state.trust = result.trust ?? null; + renderTrustPanel(); + return result; +} +async function loadSessions(options = {}) { + const feedback = options.feedback === true; + if (!feedback && state.sessionsLoading) { + return null; + } + const request = beginScopedRequest("sessions"); + if (feedback) { + setSessionsRefreshState("loading", "刷新中"); + } + try { + const result = await getJson("/api/sessions", { signal: request.signal }); + if (!isCurrentScopedRequest(request)) return result; + if (!result.ok) { + throw new Error(result.error ?? "刷新会话失败"); } - event.preventDefault(); - event.stopPropagation(); - cancelBackgroundSubagent(button.dataset.groupId, button.dataset.taskId); - }); - document.addEventListener("click", (event) => { - if (!state.modelPanelOpen) { - return; + state.sessions = result.sessions ?? []; + renderSessions(); + if (sessionsNeedRefresh()) { + scheduleSessionsRefresh(4e3); } - if (eventTargetOf(event).closest("#model-panel") || eventTargetOf(event).closest("#model-config-panel") || eventTargetOf(event).closest("#model-status-toggle")) { - return; + if (feedback) { + setSessionsRefreshState("success", `已刷新 ${state.sessions.length} 个会话`); } - hideModelPanel(); - }); - els.transcript.addEventListener("scroll", handleTranscriptScroll); - els.workflowStrip.addEventListener("click", (event) => { - if (!eventTargetOf(event).closest("button[data-action='toggle-workflow']")) { - return; + return result; + } catch (error) { + if (isAbortError(error) || !isCurrentScopedRequest(request)) return null; + if (feedback) { + setSessionsRefreshState("error", "刷新失败"); } - state.workflowExpanded = !state.workflowExpanded; - renderWorkflowStrip(); - }); - els.activityToggle.addEventListener("click", toggleLiveStatusDetails); - els.transcriptJump.addEventListener("click", followTranscript); - els.responsiveNavigation.querySelectorAll("button[data-dashboard-view]").forEach((button) => { - button.addEventListener("click", () => { - if (button.dataset.dashboardView === "settings") { - showSettingsWorkspace(); - return; - } - if (state.settingsOpen) hideSettingsWorkspace({ restoreFocus: false }); - setResponsiveView(button.dataset.dashboardView); - }); - }); - els.responsiveScrim.addEventListener("click", () => setResponsiveView("conversation")); - els.threadList.addEventListener("click", (event) => { - if (eventTargetOf(event).closest(".thread-open")) { - setResponsiveView("conversation"); - } - }); - document.addEventListener("click", handleResponsiveFileNavigation); - els.connectionStatus?.addEventListener("click", reconnectEventsManually); - window.addEventListener?.("online", () => { - if (state.currentSessionId && state.connectionState !== "connected") { - reconnectEventsManually(); - } - }); - window.addEventListener?.("offline", () => { - clearEventReconnectTimer(); - closeEventSource(); - setConnectionState("offline"); - }); - window.addEventListener?.("resize", () => { - syncResponsiveNavigation(); - setPreviewWidth(state.previewPreferredWidth, { updatePreference: false }); - }); - window.visualViewport?.addEventListener?.("resize", syncVisualViewport); - window.visualViewport?.addEventListener?.("scroll", syncVisualViewport); - resizePromptInput(); - syncVisualViewport(); - syncResponsiveNavigation(); -} -function normalizedResponsiveView(width, requestedView) { - if (requestedView === "settings") return "settings"; - if (Number(width) >= 1200) return "conversation"; - return typeof requestedView === "string" && ["sessions", "conversation", "files"].includes(requestedView) ? requestedView : "conversation"; -} -function composerHeightFor(scrollHeight, minimum = 52, maximum = 160) { - const measured = Number(scrollHeight); - const safeMinimum = Math.max(1, Number(minimum) || 52); - const safeMaximum = Math.max(safeMinimum, Number(maximum) || 160); - return Math.min(safeMaximum, Math.max(safeMinimum, Number.isFinite(measured) ? measured : safeMinimum)); -} -function previewWidthBounds(viewportWidth, sidebarCollapsed = false) { - const viewport = Math.max(0, Number(viewportWidth) || 0); - const sidebarWidth = sidebarCollapsed ? 56 : 280; - const available = viewport - 20 - 20 - sidebarWidth - PREVIEW_WORKSPACE_MIN; - return { - min: PREVIEW_WIDTH_MIN, - max: Math.max(PREVIEW_WIDTH_MIN, Math.min(PREVIEW_WIDTH_MAX, available)) - }; -} -function clampedPreviewWidth(width, bounds) { - const minimum = Math.max(0, Number(bounds?.min) || PREVIEW_WIDTH_MIN); - const maximum = Math.max(minimum, Number(bounds?.max) || PREVIEW_WIDTH_MAX); - const value = Number(width); - return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? value : PREVIEW_WIDTH_DEFAULT)); -} -function permissionIndexForKey(currentIndex, key, length) { - const count = Math.max(0, Number(length) || 0); - if (count === 0) return -1; - if (key === "Home") return 0; - if (key === "End") return count - 1; - if (key === "ArrowRight" || key === "ArrowDown") return (Math.max(0, currentIndex) + 1) % count; - if (key === "ArrowLeft" || key === "ArrowUp") return (Math.max(0, currentIndex) - 1 + count) % count; - return currentIndex; -} -function focusTrapTarget(focusables, activeElement, shiftKey = false) { - const items = Array.from(focusables ?? []); - if (items.length === 0) return null; - const current = activeElement ? items.indexOf(activeElement) : -1; - if (current < 0) return shiftKey ? items.at(-1) : items[0]; - return items[(current + (shiftKey ? -1 : 1) + items.length) % items.length]; -} -function shouldFollowTranscript({ force = false, following = true, onlyIfNearBottom = false, wasAtBottom = true } = {}) { - if (force) return true; - if (!following) return false; - return !onlyIfNearBottom || wasAtBottom !== false; -} -function scheduleAnimationFrameOnce(holder, key, callback, scheduler = requestAnimationFrame) { - if (holder[key] != null) return false; - const frame = scheduler(() => { - holder[key] = null; - callback(); - }); - holder[key] = frame ?? true; - return true; -} -function cancelScheduledAnimationFrame(holder, key, cancel = cancelAnimationFrame) { - const frame = holder[key]; - if (frame == null) return false; - holder[key] = null; - if (frame !== true) cancel(Number(frame)); - return true; + showError(errorMessageOf(error) || "刷新会话失败"); + return { ok: false, error: errorMessageOf(error) }; + } finally { + finishScopedRequest(request); + } } -function appendPlainDraftDelta(body, text, renderedLength = 0, createTextNode = (value) => document.createTextNode(value)) { - const value = String(text ?? ""); - const start = Math.min(value.length, Math.max(0, Number(renderedLength) || 0)); - const pending = value.slice(start); - if (pending) { - if (typeof body?.append === "function") body.append(createTextNode(pending)); - else if (body) body.textContent = `${body.textContent ?? ""}${pending}`; +async function restoreInitialSession() { + if (state.currentSessionId) { + return; } - return value.length; + const sessionId = initialSessionId() || latestBackgroundSessionId(); + if (!sessionId || !state.sessions.some((session) => session.id === sessionId)) { + return; + } + await openSession(sessionId); } -function renderFinalAssistantBody(body, text, renderer = renderMessageText) { - renderer(body, text ?? "", { markdown: true }); +function latestBackgroundSessionId() { + return state.sessions.find((session) => session.backgroundVisible === true)?.id ?? ""; } -function selectTranscriptNodesToRemove(nodes, limit, direction = "append", isProtected = () => false) { - const values = Array.from(nodes ?? []); - let overflow = Math.max(0, values.length - Math.max(0, Number(limit) || 0)); - if (overflow === 0) return []; - const ordered = direction === "prepend" ? values.slice().reverse() : values; - const selected = []; - for (const node of ordered) { - if (overflow === 0) break; - if (isProtected(node)) continue; - selected.push(node); - overflow -= 1; +function initialSessionId() { + try { + const params = new URLSearchParams(window.location?.search ?? ""); + return params.get("sessionId") || window.localStorage?.getItem(CURRENT_SESSION_STORAGE_KEY) || ""; + } catch { + return ""; } - return selected; -} -function responsiveLayoutMode() { - const width = Number(window.innerWidth) || Number(document.documentElement?.clientWidth) || 1200; - if (width >= 1200) return "desktop"; - return width >= 768 ? "tablet" : "mobile"; } -function restorePreviewWidth() { - let saved = PREVIEW_WIDTH_DEFAULT; +function rememberCurrentSession(sessionId) { try { - saved = Number(window.localStorage?.getItem(PREVIEW_WIDTH_STORAGE_KEY)) || PREVIEW_WIDTH_DEFAULT; + const id = String(sessionId ?? "").trim(); + if (id) { + window.localStorage?.setItem(CURRENT_SESSION_STORAGE_KEY, id); + } else { + window.localStorage?.removeItem(CURRENT_SESSION_STORAGE_KEY); + } } catch { } - setPreviewWidth(saved); } -function setPreviewWidth(width, options = {}) { - const bounds = previewWidthBounds(Number(window.innerWidth) || 1200, state.sidebarCollapsed); - if (options.updatePreference !== false) { - state.previewPreferredWidth = clampedPreviewWidth(width, { min: PREVIEW_WIDTH_MIN, max: PREVIEW_WIDTH_MAX }); +function renderSessions() { + const threadList = els.threadList; + if (!threadList) return; + threadList.innerHTML = ""; + if (state.sessions.length === 0) { + const empty = document.createElement("div"); + empty.className = "thread-meta"; + empty.textContent = "暂无历史任务"; + threadList.append(empty); + return; } - state.previewWidth = clampedPreviewWidth(state.previewPreferredWidth, bounds); - document.documentElement?.style?.setProperty("--preview-width", `${state.previewWidth}px`); - syncPreviewResizeHandle(bounds); - if (options.persist) { - try { - window.localStorage?.setItem(PREVIEW_WIDTH_STORAGE_KEY, String(state.previewPreferredWidth)); - } catch { - } + for (const session of state.sessions) { + const status = sessionStatusView(session); + const title = session.title || "未命名任务"; + const meta = sessionMeta(session, status); + const item = document.createElement("div"); + item.className = `thread-item${session.id === state.currentSessionId ? " active" : ""}`; + item.dataset.tone = status.tone; + item.innerHTML = ` + + ${state.deleteConfirmSessionId === session.id ? ` +
+
确认删除这个会话?
+
历史记录和 transcript 分片会被删除,操作不可撤销。
+
+ + +
+
+ ` : ` +
+ + +
+ `} + `; + item.querySelector(".thread-open")?.addEventListener("click", () => openSession(session.id)); + item.querySelectorAll("button[data-action]").forEach((button) => { + button.addEventListener("click", (event) => { + event.stopPropagation(); + handleSessionAction(String(button.dataset.action ?? ""), String(button.dataset.sessionId ?? "")); + }); + }); + threadList.append(item); } - if (options.announce) announceStatus(`文件栏宽度 ${state.previewWidth} 像素`); - return state.previewWidth; } -function syncPreviewResizeHandle(bounds = previewWidthBounds(Number(window.innerWidth) || 1200, state.sidebarCollapsed)) { - const handle = els.previewResizeHandle; - if (!handle) return; - handle.setAttribute("aria-valuemin", String(bounds.min)); - handle.setAttribute("aria-valuemax", String(bounds.max)); - handle.setAttribute("aria-valuenow", String(state.previewWidth)); - handle.setAttribute("aria-valuetext", `${state.previewWidth} 像素`); - handle.setAttribute("aria-disabled", responsiveLayoutMode() !== "desktop" || document.body.classList.contains("preview-collapsed") ? "true" : "false"); +function sessionMeta(session, status = sessionStatusView(session)) { + const parts = [ + Number(session.queueLength ?? 0) > 0 ? `${session.queueLength} 排队` : null, + status.detail, + session.model || null, + formatTime(session.modifiedAt) + ].filter(Boolean); + return parts.join(" · "); } -function beginPreviewResize(event) { - const handle = els.previewResizeHandle; - if (!handle) return; - if (responsiveLayoutMode() !== "desktop" || document.body.classList.contains("preview-collapsed")) return; - event.preventDefault(); - state.previewResizeStartX = Number(event.clientX); - state.previewResizeStartWidth = state.previewWidth; - handle.setPointerCapture?.(event.pointerId); - document.body.classList.add("preview-resizing"); +function sessionStatusView(session) { + const raw = String(session.status ?? "").toLowerCase(); + if (session.running || raw === "running") { + return { label: "运行中", tone: "running", detail: Number(session.queueLength ?? 0) > 0 ? "有排队" : "" }; + } + if (session.backgroundVisible) { + const kinds = Array.isArray(session.backgroundKinds) ? session.backgroundKinds : []; + if (kinds.includes("terminal") && !kinds.includes("subagent")) { + return { label: "终端后台", tone: "background", detail: Number(session.backgroundCount ?? 0) > 1 ? `${session.backgroundCount} 个任务` : "" }; + } + if (kinds.includes("terminal")) { + return { label: "后台运行", tone: "background", detail: Number(session.backgroundCount ?? 0) > 1 ? `${session.backgroundCount} 个任务` : "" }; + } + return { label: "子智能体后台", tone: "background", detail: Number(session.backgroundCount ?? 0) > 1 ? `${session.backgroundCount} 个任务` : "" }; + } + if (raw.includes("引导")) { + return { label: "引导中", tone: "running", detail: "" }; + } + if (Number(session.queueLength ?? 0) > 0) { + return { label: "排队中", tone: "waiting", detail: "" }; + } + if (["failed", "error"].includes(raw) || raw.endsWith("_error") || raw.includes("失败")) { + return { label: "失败", tone: "error", detail: "" }; + } + if (["interrupted", "cancelled"].includes(raw) || raw.includes("中断")) { + return { label: "已中断", tone: "warning", detail: "" }; + } + if (["completed", "done"].includes(raw) || raw.includes("完成")) { + return { label: "完成", tone: "done", detail: "" }; + } + if (session.active) { + return { label: "已打开", tone: "active", detail: "" }; + } + return { label: "历史", tone: "idle", detail: raw && raw !== "unknown" ? session.status : "" }; } -function updatePreviewResize(event) { - if (state.previewResizeStartX === null || state.previewResizeStartWidth === null) return; - const delta = Number(event.clientX) - state.previewResizeStartX; - setPreviewWidth(state.previewResizeStartWidth - delta); +function toggleSidebar() { + setSidebarCollapsed(!state.sidebarCollapsed); } -function finishPreviewResize(event) { - const handle = els.previewResizeHandle; - if (!handle) return; - if (state.previewResizeStartX === null) return; - handle.releasePointerCapture?.(event.pointerId); - state.previewResizeStartX = null; - state.previewResizeStartWidth = null; - document.body.classList.remove("preview-resizing"); - setPreviewWidth(state.previewWidth, { persist: true, announce: true }); +function setSidebarCollapsed(collapsed) { + state.sidebarCollapsed = Boolean(collapsed); + document.body.classList.toggle("sidebar-collapsed", state.sidebarCollapsed); + els.collapseSidebar.textContent = state.sidebarCollapsed ? "›" : "‹"; + els.collapseSidebar.title = state.sidebarCollapsed ? "展开会话栏" : "收起会话栏"; + els.collapseSidebar.setAttribute("aria-label", els.collapseSidebar.title); + setPreviewWidth(state.previewPreferredWidth, { updatePreference: false }); } -function handlePreviewResizeKeydown(event) { - if (responsiveLayoutMode() !== "desktop" || document.body.classList.contains("preview-collapsed")) return; - let next = state.previewWidth; - const step = event.shiftKey ? 48 : 16; - if (event.key === "ArrowLeft") next += step; - else if (event.key === "ArrowRight") next -= step; - else if (event.key === "Home") next = previewWidthBounds(Number(window.innerWidth) || 1200, state.sidebarCollapsed).min; - else if (event.key === "End") next = previewWidthBounds(Number(window.innerWidth) || 1200, state.sidebarCollapsed).max; - else return; - event.preventDefault(); - setPreviewWidth(next, { persist: true, announce: true }); +function sessionsNeedRefresh() { + return state.sessions.some( + (session) => session.running || session.backgroundVisible || Number(session.queueLength) > 0 || String(session.status ?? "").toLowerCase() === "running" + ); } -function setResponsiveView(view) { - if (view !== "settings" && state.settingsOpen) { - hideSettingsWorkspace({ restoreFocus: false }); +function scheduleSessionsRefresh(delayMs = 800) { + const normalizedDelay = Math.max(0, Number(delayMs) || 0); + const dueAt = Date.now() + normalizedDelay; + if (state.sessionsRefreshTimer && state.sessionsRefreshDueAt <= dueAt) { + return; } - state.responsiveView = normalizedResponsiveView(Number(window.innerWidth) || 1200, view); - syncResponsiveNavigation(); -} -function syncResponsiveNavigation() { - const width = Number(window.innerWidth) || Number(document.documentElement?.clientWidth) || 1200; - const view = normalizedResponsiveView(width, state.responsiveView); - state.responsiveView = view; - if (width < 1200) { - state.sidebarCollapsed = false; - document.body.classList.remove("sidebar-collapsed", "preview-collapsed"); + if (state.sessionsRefreshTimer) { + clearTimeout(state.sessionsRefreshTimer); } - document.body.dataset.dashboardView = view; - els.responsiveNavigation?.querySelectorAll("button[data-dashboard-view]").forEach((button) => { - const active = button.dataset.dashboardView === (state.settingsOpen ? "settings" : view); - button.classList.toggle("active", active); - if (active) button.setAttribute("aria-current", "page"); - else button.removeAttribute("aria-current"); - }); - if (state.modalContext) return; - const desktop = width >= 1200; - setResponsiveSurfaceInert(els.sidebar, !desktop && view !== "sessions"); - setResponsiveSurfaceInert(els.workspace, !desktop && !["conversation", "settings"].includes(view)); - setResponsiveSurfaceInert(els.preview, !desktop && view !== "files"); -} -function setResponsiveSurfaceInert(element, inert) { - if (!element) return; - element.inert = Boolean(inert); + state.sessionsRefreshDueAt = dueAt; + state.sessionsRefreshTimer = setTimeout(() => { + state.sessionsRefreshTimer = null; + state.sessionsRefreshDueAt = 0; + loadSessions().catch(() => null); + }, normalizedDelay); } -function handleResponsiveFileNavigation(event) { - if (responsiveLayoutMode() === "desktop") return; - if (eventTargetOf(event).closest(".file-item, .file-link, [data-file]")) { - setResponsiveView("files"); +function handleSessionAction(action, sessionId) { + if (!sessionId) { + return; } -} -function syncVisualViewport() { - const viewportHeight = Number(window.visualViewport?.height) || Number(window.innerHeight) || 0; - if (viewportHeight > 0) { - document.documentElement?.style?.setProperty("--dashboard-viewport-height", `${Math.round(viewportHeight)}px`); + if (action === "delete") { + state.deleteConfirmSessionId = sessionId; + renderSessions(); + return; } - const keyboardVisible = Boolean(window.visualViewport) && Number(window.innerHeight) - viewportHeight > 120; - document.body.classList.toggle("keyboard-visible", keyboardVisible); -} -function resizePromptInput() { - if (!els.promptInput) return; - els.promptInput.style.height = "auto"; - const height = composerHeightFor(els.promptInput.scrollHeight); - els.promptInput.style.height = `${height}px`; - els.promptInput.style.overflowY = Number(els.promptInput.scrollHeight) > height ? "auto" : "hidden"; -} -function handlePermissionModeKeydown(event) { - const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]; - if (!keys.includes(event.key)) return; - const buttons = Array.from(els.permissionMode.querySelectorAll("button[data-mode]")); - const current = Math.max(0, buttons.indexOf(eventTargetOf(event).closest("button[data-mode]"))); - const next = permissionIndexForKey(current, event.key, buttons.length); - const button = buttons[next]; - if (!button) return; - event.preventDefault(); - event.stopPropagation(); - button.focus(); - requestPermissionMode(button.dataset.mode, button); -} -function requestPermissionMode(mode, trigger = document.activeElement) { - if (state.goal.enabled && mode !== "fullAccess") { - showError("Goal 开启时不能降低权限。请先退出 Goal。"); + if (action === "cancel-delete") { + state.deleteConfirmSessionId = ""; + renderSessions(); return; } - if (mode === "fullAccess" && state.permissionMode !== "fullAccess") { - showPermissionConfirm(trigger); + if (action === "confirm-delete") { + deleteSession(sessionId); return; } - hidePermissionConfirm({ restoreFocus: false }); - setPermissionMode(mode); -} -function defaultGoalMaxAutoContinues() { - const value = Number(state.settings?.agents?.goalMaxAutoContinues); - return Number.isInteger(value) && value >= 1 && value <= 100 ? value : 12; -} -function emptyGoalSnapshot() { - return { - enabled: false, - status: "off", - text: "", - previousPermissionMode: "plan", - roundCount: 0, - continueCount: 0, - maxAutoContinues: defaultGoalMaxAutoContinues(), - lastContinueReason: "", - lastBlockReason: "", - lastEvidence: null, - hasWrites: false, - recap: null - }; -} -function applyGoalSnapshot(goal, options = {}) { - const snapshot = isPlainObject(goal) ? goal : {}; - const enabled = snapshot.enabled === true; - state.goal = { - ...emptyGoalSnapshot(), - enabled, - text: String(snapshot.text ?? ""), - status: enabled ? String(snapshot.status || "active") : "off", - previousPermissionMode: String(snapshot.previousPermissionMode ?? state.goal.previousPermissionMode ?? "plan"), - roundCount: Number(snapshot.roundCount) || 0, - continueCount: Number(snapshot.continueCount) || 0, - maxAutoContinues: Number(snapshot.maxAutoContinues) || defaultGoalMaxAutoContinues(), - lastContinueReason: String(snapshot.lastContinueReason ?? ""), - lastBlockReason: String(snapshot.lastBlockReason ?? ""), - lastEvidence: snapshot.lastEvidence ?? null, - hasWrites: snapshot.hasWrites === true, - recap: isPlainObject(snapshot.recap) ? snapshot.recap : null - }; - const permissionSource = options.permissionMode || (state.goal.enabled ? "fullAccess" : state.permissionMode); - if (state.goal.enabled) { - setPermissionMode("fullAccess"); - } else if (options.permissionMode) { - setPermissionMode(options.permissionMode); - } else { - setPermissionMode(permissionSource); + if (action === "copy-id") { + copySessionId(sessionId); } - renderGoalControls(); - syncPromptPlaceholder(); } -var DEFAULT_PROMPT_PLACEHOLDER = "输入任务需求,例如:整理这批实验数据并生成一份简洁报告"; -function syncPromptPlaceholder() { - if (!els.promptInput) return; - els.promptInput.placeholder = DEFAULT_PROMPT_PLACEHOLDER; -} -function renderGoalControls() { - const enabled = state.goal.enabled === true; - const readonlyLocked = state.sessionStatus?.readonlyLocked === true || state.trust?.readonlyLocked === true; - if (els.goalMode) { - els.goalMode.setAttribute("aria-pressed", String(enabled)); - els.goalMode.classList.toggle("active", enabled); - els.goalMode.disabled = readonlyLocked && !enabled; - els.goalMode.title = readonlyLocked && !enabled ? "只读锁定会话不能启用 Goal" : "Goal 模式"; +async function openSession(id) { + state.turnRequest = null; + const request = beginScopedRequest("session", id); + cancelScopedRequest("transcript"); + cancelScopedRequest("file"); + els.runStatus.textContent = "加载会话"; + let result; + try { + result = await getJson(`/api/sessions/${encodeURIComponent(id)}`, { signal: request.signal }); + } catch (error) { + if (!isAbortError(error) && isCurrentScopedRequest(request)) { + showError(errorMessageOf(error) || "无法读取会话"); + } + finishScopedRequest(request); + return; } - document.body.classList.toggle("goal-mode-active", enabled); - els.permissionMode?.querySelectorAll("button[data-mode]").forEach((button) => { - button.disabled = enabled && button.dataset.mode !== "fullAccess"; - }); - if (enabled) { - els.modeDescription.textContent = MODE_DESCRIPTIONS.goal; + if (!isCurrentScopedRequest(request)) return; + finishScopedRequest(request); + if (!result.ok) { + showError(typeof result.error === "string" ? result.error : result.error ?? "无法读取会话"); + return; } - renderGoalStatusBar(); -} -function renderGoalStatusBar() { - const bar = els.goalStatusBar; - if (!bar) return; - if (!state.goal.enabled) { - bar.classList.add("hidden"); - bar.replaceChildren(); + const loadedSession = result.session; + if (!loadedSession) { + showError("无法读取会话"); return; } - bar.classList.remove("hidden"); - const statusLabel = { - active: "进行中", - running: "进行中", - paused: "已暂停", - verifying: "核验中", - complete: "已完成", - failed: "失败", - off: "关闭" - }[state.goal.status] ?? state.goal.status; - const recapLine = String(state.goal.recap?.line ?? "").trim(); - const showRecap = recapLine.length > 0; - const canResume = state.goal.status === "paused" || state.goal.status === "failed"; - const showPause = !canResume && state.goal.status !== "complete"; - const objectiveClass = ["goal-objective", showRecap ? "goal-objective-ellipsis" : ""].filter(Boolean).join(" "); - const continueReason = String(state.goal.lastContinueReason ?? "").trim(); - bar.innerHTML = ` -
-
Goal · ${escapeHtml(statusLabel)}
-
${escapeHtml(state.goal.text || "")}
- ${showRecap ? `
${escapeHtml(recapLine)}
` : `
${Number(state.goal.continueCount) || 0} / ${Number(state.goal.maxAutoContinues) || defaultGoalMaxAutoContinues()} 次续跑${continueReason ? ` · ${escapeHtml(continueReason)}` : ""}
`} -
-
- ${canResume ? `` : ""} - ${showPause ? `` : ""} - -
- `; - bar.querySelectorAll("[data-goal-action]").forEach((button) => { - button.addEventListener("click", () => submitGoalAction(String(button.dataset.goalAction ?? ""))); + state.currentSessionId = id; + applyGoalSnapshot(loadedSession.goal, { permissionMode: loadedSession.permission?.mode ?? "plan" }); + state.running = loadedSession.active === true && loadedSession.running === true; + rememberCurrentSession(id); + disconnectEvents(); + hideApproval(); + hideQuestion(); + hideContextConfirm(); + clearTranscript(); + resetLiveStatus(); + state.queue = []; + state.queueCancelling.clear(); + state.backgroundCancelling.clear(); + clearPendingGuide(); + state.activeTurnId = ""; + resetEventReplayState(); + state.deleteConfirmSessionId = ""; + renderQueuePanel(); + state.models = markCurrentModel(state.models, loadedSession.sessionStatus?.model ?? loadedSession.model); + state.sessionStatus = null; + updateSessionStatus(loadedSession.sessionStatus ?? { + model: loadedSession.model, + context: isPlainObject(loadedSession.context) ? loadedSession.context : null }); + updateSendButton(); + resetTurnChangeStats(); + state.files = Array.isArray(loadedSession.files) ? loadedSession.files : []; + renderFiles(); + resetPreview(); + els.runStatus.textContent = loadedSession.status || "历史"; + setTranscriptPaging(loadedSession.transcriptPage); + renderTranscriptMessages(loadedSession.transcript ?? []); + renderSessionFailure(loadedSession.failure); + scrollTranscript({ force: true }); + const hasBackground = restoreBackgroundSnapshot(loadedSession.backgroundSnapshot); + if (loadedSession.active && loadedSession.running) { + rememberEventCursor(loadedSession.eventCursor); + ensureEventsConnected(id); + els.runStatus.textContent = loadedSession.status === "引导中" ? "引导中" : "运行中"; + setLiveTitle(loadedSession.status === "引导中" ? "正在按引导继续" : "正在恢复运行中的任务"); + state.running = true; + updateSendButton(); + } else if (loadedSession.active && hasBackground) { + rememberEventCursor(loadedSession.eventCursor); + ensureEventsConnected(id); + applyIdleRunStatus("完成"); + updateSendButton(); + } + renderSessions(); } -function requestGoalMode() { - if (state.goal.enabled) { - submitGoalAction("disable"); +function restoreBackgroundSnapshot(snapshot) { + if (!isPlainObject(snapshot) || !Array.isArray(snapshot.groups)) { + return false; + } + reconcileBackgroundSubagentSnapshot(snapshot.groups); + return state.backgroundSubagents.size > 0; +} +async function deleteSession(sessionId) { + if (!sessionId || state.deletingSessions.has(sessionId)) { return; } - if (!state.trust?.trusted) { - showTrustPanel(); + state.deletingSessions.add(sessionId); + renderSessions(); + const result = await deleteJson(`/api/sessions/${encodeURIComponent(sessionId)}`).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); + state.deletingSessions.delete(sessionId); + state.deleteConfirmSessionId = ""; + if (!result.ok) { + showError(result.error ?? "删除会话失败"); + renderSessions(); return; } - showGoalConfirm(els.goalMode); -} -function showGoalConfirm(trigger) { - const panel = els.goalConfirmPanel; - if (!panel) return; - panel.classList.remove("hidden"); - panel.innerHTML = ` -
-
启用 Goal 模式?
-
Goal 模式会在本会话朝着你给出的目标自动连续执行,直到完成、暂停、失败或预算用尽。

开启后当前会话将使用「完全访问」:本机工具、工作区外文件和网络操作将自动获准,并且不会在每个工具调用时停下等你批准。

这是无人值守自动执行,不是普通聊天。请确认本机工作区可被连续修改。你随时可以暂停、中断或退出 Goal。
-
-
- - -
- `; - panel.setAttribute("role", "dialog"); - panel.setAttribute("aria-modal", "true"); - panel.setAttribute("aria-labelledby", "goal-confirm-title"); - panel.setAttribute("tabindex", "-1"); - panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hideGoalConfirm()); - panel.querySelector("button[data-action='confirm']")?.addEventListener("click", () => { - hideGoalConfirm({ restoreFocus: false }); - showGoalTextPanel(trigger); - }); - activateModal(panel, { initialFocus: "button[data-action='cancel']", returnFocus: trigger }); + if (state.currentSessionId === sessionId) { + newTask(); + rememberCurrentSession(null); + } + await loadSessions(); } -function hideGoalConfirm(options = {}) { - const panel = els.goalConfirmPanel; - if (!panel || panel.classList.contains("hidden")) return; - deactivateModal(panel, options); - panel.classList.add("hidden"); - panel.replaceChildren(); +function setSessionsRefreshState(tone, message = "") { + if (state.sessionsStatusTimer) { + clearTimeout(state.sessionsStatusTimer); + state.sessionsStatusTimer = null; + } + state.sessionsLoading = tone === "loading"; + els.refreshSessions.disabled = state.sessionsLoading; + els.refreshSessions.dataset.state = tone; + els.refreshSessions.textContent = tone === "success" ? "✓" : tone === "error" ? "!" : "↻"; + els.refreshSessions.title = message || "刷新"; + if (els.sessionsStatus) { + els.sessionsStatus.textContent = message; + els.sessionsStatus.dataset.tone = tone; + } + if (tone === "success" || tone === "error") { + state.sessionsStatusTimer = setTimeout(() => { + els.refreshSessions.dataset.state = "idle"; + els.refreshSessions.textContent = "↻"; + els.refreshSessions.title = "刷新"; + if (els.sessionsStatus) { + els.sessionsStatus.textContent = ""; + els.sessionsStatus.dataset.tone = "idle"; + } + state.sessionsStatusTimer = null; + }, 1800); + } } -function showGoalTextPanel(trigger) { - const panel = els.goalTextPanel; - if (!panel) return; - panel.classList.remove("hidden"); - panel.innerHTML = ` -
-
输入 Goal 目标
-
用一句话写清要完成的目标。提交后才会进入无人值守执行。
- - - -
-
- - -
- `; - panel.setAttribute("role", "dialog"); - panel.setAttribute("aria-modal", "true"); - panel.setAttribute("aria-labelledby", "goal-text-title"); - const input = panel.querySelector("#goal-objective-input"); - panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hideGoalTextPanel()); - panel.querySelector("button[data-action='confirm']")?.addEventListener("click", () => { - const text = String(input?.value ?? "").trim(); - if (!text) { - const error = panel.querySelector("#goal-text-error"); - if (error) error.hidden = false; - input?.focus(); - return; - } - hideGoalTextPanel({ restoreFocus: false }); - enableGoalWithObjective(text); - }); - activateModal(panel, { initialFocus: "#goal-objective-input", returnFocus: trigger }); +async function copySessionId(sessionId) { + try { + await navigator.clipboard.writeText(sessionId); + appendActivity({ + title: "会话 ID 已复制", + detail: sessionId, + severity: "success", + collapsed: true + }); + } catch { + showError("复制会话 ID 失败"); + } } -function hideGoalTextPanel(options = {}) { - const panel = els.goalTextPanel; - if (!panel || panel.classList.contains("hidden")) return; - deactivateModal(panel, options); - panel.classList.add("hidden"); - panel.replaceChildren(); +function newTask() { + cancelScopedRequest("session"); + cancelScopedRequest("transcript"); + cancelScopedRequest("file"); + state.turnRequest = null; + state.currentSessionId = null; + applyGoalSnapshot(null, { permissionMode: "plan" }); + state.running = false; + rememberCurrentSession(null); + disconnectEvents(); + hideApproval(); + hideQuestion(); + hideContextConfirm(); + clearTranscript(); + state.files = []; + state.queue = []; + state.queueCancelling.clear(); + state.backgroundCancelling.clear(); + state.completedActivities = []; + clearPendingGuide(); + state.activeTurnId = ""; + resetEventReplayState(); + state.deleteConfirmSessionId = ""; + resetLiveStatus(); + resetTurnChangeStats(); + restoreNewTaskModelState(); + clearAttachments(); + renderQueuePanel(); + renderFiles(); + resetPreview(); + els.runStatus.textContent = "空闲"; + updateSendButton(); + resizePromptInput(); + els.promptInput.focus(); + refreshNewTaskModelState().catch(() => null); } -async function enableGoalWithObjective(text) { - const previous = state.permissionMode; - applyGoalSnapshot({ - enabled: true, - status: "active", - text, - previousPermissionMode: previous - }); - if (!state.currentSessionId) { - if (currentSessionNeedsModelSelection()) { - applyGoalSnapshot(null, { permissionMode: previous }); - showError("请先重新选择模型来源和模型"); - return; - } - state.turnSubmitting = true; - updateSendButton(); - els.runStatus.textContent = "启动中"; - let result; - try { - result = await postJson("/api/turns", { - requestId: dashboardRequestId(), - prompt: text, - permissionMode: "fullAccess", - goalMode: true, - goalText: text, - clientPreviousPermissionMode: previous, - clientId: dashboardClientId() - }); - } catch (error) { - result = { ok: false, error: errorMessageOf(error) }; - } finally { - state.turnSubmitting = false; - updateSendButton(); - } - if (!result.ok) { - applyGoalSnapshot(null, { permissionMode: previous }); - if (result.trust) { - state.trust = result.trust; - renderTrustPanel(); - } - showError(result.error ?? "Goal 启动失败"); - els.runStatus.textContent = result.status === 403 ? "待信任" : "失败"; - updateSendButton(); - return; +function rememberNewTaskModelState() { + state.newTaskModelState = { + models: normalizeModels(state.models), + gatewayConfig: normalizeGatewayConfig(state.gatewayConfig), + gatewayProfiles: normalizeGatewayProfiles(state.gatewayProfiles), + agentModelTiers: normalizeAgentModelTiers(state.agentModelTiers), + visionAgent: normalizeVisionAgent(state.visionAgent), + sessionStatus: { + ...state.sessionStatus ?? emptySessionStatus, + context: state.sessionStatus?.context ? { ...state.sessionStatus.context } : null } - adoptGoalRunResult(result); - await loadSessions(); + }; +} +function restoreNewTaskModelState() { + const snapshot = state.newTaskModelState; + if (!snapshot) { return; } - await submitGoalAction("enable", { objective: text }); + state.models = normalizeModels(snapshot.models); + state.gatewayConfig = normalizeGatewayConfig(snapshot.gatewayConfig); + state.gatewayProfiles = normalizeGatewayProfiles(snapshot.gatewayProfiles); + state.agentModelTiers = normalizeAgentModelTiers(snapshot.agentModelTiers); + state.visionAgent = normalizeVisionAgent(snapshot.visionAgent); + state.sessionStatus = null; + updateSessionStatus(snapshot.sessionStatus); } -async function submitGoalAction(action, extra = {}) { - if (state.goalSubmitting) return; - if (!state.currentSessionId) { - if (action === "disable" || action === "clear") { - applyGoalSnapshot(null, { permissionMode: state.goal.previousPermissionMode || "plan" }); - } +async function refreshNewTaskModelState() { + const result = await getJson(statusUrl()); + if (!result.ok || state.currentSessionId) { return; } - state.goalSubmitting = true; - const result = await postJson("/api/goal", { - sessionId: state.currentSessionId, - action, - objective: extra.objective, - clientPreviousPermissionMode: state.goal.enabled ? state.goal.previousPermissionMode : state.permissionMode, - permissionMode: state.permissionMode - }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); - state.goalSubmitting = false; - if (!result.ok) { - showError(result.error ?? "Goal 操作失败"); + state.models = normalizeModels(result.models); + state.gatewayConfig = normalizeGatewayConfig(result.gatewayConfig); + state.gatewayProfiles = normalizeGatewayProfiles(result.gatewayProfiles); + state.agentModelTiers = normalizeAgentModelTiers(result.agentModelTiers); + state.visionAgent = normalizeVisionAgent(result.visionAgent); + updateConfigRevisions(result); + state.sessionStatus = null; + updateSessionStatus({ + ...result.sessionStatus, + providerId: result.sessionStatus?.providerId ?? result.gatewayConfig?.activeProfileId ?? "" + }); + rememberNewTaskModelState(); +} +async function addAttachmentFiles(files) { + const list = Array.from(files ?? []).filter((file) => file instanceof File); + if (list.length === 0) { return; } - adoptGoalRunResult(result); - if (result.running === true) { - await loadSessions(); + let ignoredUnknown = 0; + for (const file of list) { + const kind = classifyComposerFile(file); + if (kind === "image") { + const imageCount = state.attachments.filter((item) => item.type !== "document").length; + if (imageCount >= MAX_IMAGE_ATTACHMENTS) { + showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片`); + continue; + } + if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { + showError(`${file.name || "图片"} 超过 8MB,暂不发送`); + continue; + } + try { + state.attachments.push(await readImageAttachment(file)); + } catch (error) { + showError(errorMessageOf(error) || "读取图片失败"); + } + continue; + } + if (kind === "document") { + const documentCount = state.attachments.filter((item) => item.type === "document").length; + if (documentCount >= MAX_DOCUMENT_ATTACHMENTS) { + showError(`最多可附加 ${MAX_DOCUMENT_ATTACHMENTS} 个文档`); + continue; + } + if (file.size > MAX_DOCUMENT_ATTACHMENT_BYTES) { + showError(`${file.name || "文档"} 超过 40MB,暂不发送`); + continue; + } + try { + state.attachments.push(await readDocumentAttachment(file)); + } catch (error) { + showError(errorMessageOf(error) || "读取文档失败"); + } + continue; + } + ignoredUnknown += 1; } + if (ignoredUnknown > 0) { + showError("回形针只接收图片和 PDF / Word / Excel / PPT / 文本,不支持旧版 .doc .xls .ppt"); + } + renderAttachmentStrip(); + updateSendButton(); } -function adoptGoalRunResult(result) { - applyGoalSnapshot(result.goal ?? state.goal, { permissionMode: result.permission?.mode ?? state.permissionMode }); - if (result.sessionStatus) { - updateSessionStatus(result.sessionStatus); +function classifyComposerFile(file) { + const name = String(file.name ?? ""); + const ext = name.includes(".") ? `.${name.split(".").pop()?.toLowerCase() ?? ""}` : ""; + const mime = String(file.type ?? "").toLowerCase(); + if (IMAGE_EXTENSIONS.has(ext) || mime === "image/png" || mime === "image/jpeg" || mime === "image/gif" || mime === "image/webp") { + return "image"; } - state.queue = result.queue ?? state.queue; - renderQueuePanel(); - const previousSessionId = state.currentSessionId; - if (result.sessionId) { - state.currentSessionId = result.sessionId; - rememberCurrentSession(result.sessionId); - if (previousSessionId !== result.sessionId) { - resetEventReplayState(); - } - rememberEventCursor(result.eventCursor); - ensureEventsConnected(result.sessionId); - } - if (result.running === true) { - state.running = true; - els.runStatus.textContent = "运行中"; - setLiveTitle("正在推进 Goal"); + if (DOCUMENT_EXTENSIONS.has(ext) || mime === "application/pdf") { + return "document"; } - updateSendButton(); + return "unsupported"; } -function showPermissionConfirm(trigger = document.activeElement) { - const panel = els.permissionConfirmPanel; - if (!panel) return; - panel.classList.remove("hidden"); - panel.innerHTML = ` -
-
启用完全访问?
-
当前会话的本机工具、工作区外文件和网络操作将自动获准,直到你切换权限或离开该会话。
-
-
- - -
- `; - panel.setAttribute("role", "dialog"); - panel.setAttribute("aria-modal", "true"); - panel.setAttribute("aria-labelledby", "permission-confirm-title"); - panel.setAttribute("tabindex", "-1"); - panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hidePermissionConfirm()); - panel.querySelector("button[data-action='confirm']")?.addEventListener("click", () => { - hidePermissionConfirm({ restoreFocus: false }); - setPermissionMode("fullAccess"); - els.permissionMode.querySelector("button[data-mode='fullAccess']")?.focus(); +function readImageAttachment(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("error", () => reject(new Error("读取图片失败"))); + reader.addEventListener("load", () => { + const dataUrl = String(reader.result ?? ""); + const match = dataUrl.match(/^data:([^;,]+);base64,([\s\S]+)$/); + if (!match) { + reject(new Error("图片格式无法作为附件发送")); + return; + } + resolve({ + id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, + type: "image", + name: file.name || "image", + mimeType: /^(image\/(png|jpeg|gif|webp))$/i.test(match[1]) ? match[1].toLowerCase() : imageMimeFromName(file.name), + size: file.size, + data: match[2], + previewUrl: dataUrl + }); + }); + reader.readAsDataURL(file); }); - activateModal(panel, { initialFocus: "button[data-action='cancel']", returnFocus: trigger }); -} -function hidePermissionConfirm(options = {}) { - const panel = els.permissionConfirmPanel; - if (!panel || panel.classList.contains("hidden")) return; - deactivateModal(panel, options); - panel.classList.add("hidden"); - panel.replaceChildren(); -} -function updateContextActions() { - const noSession = !state.currentSessionId; - const busy = state.running || state.turnSubmitting; - const disabled = noSession || busy; - const hint = noSession ? "请先打开一个空闲会话" : busy ? "任务运行期间不能清空或压缩上下文" : "可管理当前会话上下文"; - for (const button of [els.contextClear, els.contextCompact]) { - if (!button) continue; - button.disabled = disabled; - button.title = hint; - } - if (els.contextActionHint) els.contextActionHint.textContent = hint; } -function announceStatus(message) { - if (!els.dashboardLiveRegion || !message) return; - els.dashboardLiveRegion.textContent = ""; - requestAnimationFrame(() => { - els.dashboardLiveRegion.textContent = String(message); +function readDocumentAttachment(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("error", () => reject(new Error("读取文档失败"))); + reader.addEventListener("load", () => { + const dataUrl = String(reader.result ?? ""); + const match = dataUrl.match(/^data:([^;,]+);base64,([\s\S]+)$/); + if (!match) { + reject(new Error("文档格式无法作为附件发送")); + return; + } + resolve({ + id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, + type: "document", + name: file.name || "document", + mimeType: match[1] || file.type || "application/octet-stream", + size: file.size, + data: match[2], + previewUrl: "" + }); + }); + reader.readAsDataURL(file); }); } -var FOCUSABLE_SELECTOR = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "[tabindex]:not([tabindex='-1'])" -].join(","); -function modalFocusableElements(modal) { - if (!modal) return []; - return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter((node) => node.getAttribute("aria-hidden") !== "true" && !node.closest("[inert]")); -} - -// src/dashboard/public/app-ui2.ts -function activateModal(modal, options = {}) { - if (!modal) return; - if (state.modalContext?.modal === modal) { - focusModalInitialTarget2(modal, options.initialFocus); +function renderAttachmentStrip() { + if (!els.attachmentStrip) { return; } - if (state.modalContext) { - deactivateModal(state.modalContext.modal, { restoreFocus: false }); - } - const inertEntries = collectModalBackground2(modal); - const returnFocus = options.returnFocus ?? document.activeElement; - state.modalContext = { - modal, - returnFocus, - inertEntries, - previousRole: modal.getAttribute("role"), - previousAriaModal: modal.getAttribute("aria-modal"), - previousTabIndex: modal.getAttribute("tabindex") - }; - modal.setAttribute("role", "dialog"); - modal.setAttribute("aria-modal", "true"); - if (!modal.hasAttribute("tabindex")) modal.setAttribute("tabindex", "-1"); - if (modal !== els.modelConfigPanel && modal !== els.imageLightbox) { - modal.classList.add("modal-interaction"); - } - for (const entry of inertEntries) entry.node.inert = true; - focusModalInitialTarget2(modal, options.initialFocus); -} -function collectModalBackground2(modal) { - const entries = []; - const seen = /* @__PURE__ */ new Set(); - let branch = modal; - while (branch?.parentElement) { - const parent = branch.parentElement; - for (const node of Array.from(parent.children ?? [])) { - if (node === branch || seen.has(node)) continue; - seen.add(node); - entries.push({ node, inert: Boolean(node.inert) }); + els.attachmentStrip.innerHTML = ""; + els.attachmentStrip.classList.toggle("hidden", state.attachments.length === 0); + for (const attachment of state.attachments) { + const item = document.createElement("div"); + item.className = "attachment-chip"; + const isDocument = attachment.type === "document"; + const label = attachment.name || (isDocument ? "文档" : "图片"); + const preview = isDocument || !attachment.previewUrl ? `${escapeHtml(documentChipLabel(attachment.name))}` : ``; + item.innerHTML = ` + ${preview} + ${escapeHtml(label)} + + `; + item.querySelector("button").addEventListener("click", () => { + state.attachments = state.attachments.filter((candidate) => candidate.id !== attachment.id); + renderAttachmentStrip(); + updateSendButton(); + }); + if (isDocument && /\.pdf$/i.test(label)) { + const range = document.createElement("div"); + range.className = "attachment-page-range"; + for (const [key, labelText, placeholder] of [["pageStart", "起始页", "默认"], ["pageEnd", "结束页", "默认"]]) { + const input = document.createElement("input"); + input.type = "number"; + input.min = "1"; + input.step = "1"; + input.placeholder = placeholder; + input.title = key === "pageEnd" ? "扫描件不填则只视觉识别前几页;全量视觉请填写结束页" : "不填则从第 1 页开始"; + input.setAttribute("aria-label", `${label} ${labelText}`); + input.value = attachment[key] === void 0 ? "" : String(attachment[key]); + input.addEventListener("input", () => { + attachment[key] = input.value ? Number(input.value) : void 0; + }); + range.append(input); + } + item.append(range); } - branch = parent; - if (parent === document.body) break; + els.attachmentStrip.append(item); } - return entries; } -function focusModalInitialTarget2(modal, selector) { - requestAnimationFrame(() => { - if (!modal || state.modalContext?.modal !== modal) return; - const target = typeof selector === "string" ? modal.querySelector(selector) : modalFocusableElements(modal)[0]; - (target ?? modal).focus({ preventScroll: true }); - }); +function documentChipLabel(name) { + const ext = String(name ?? "").split(".").pop()?.toUpperCase() ?? "FILE"; + return ext.slice(0, 4); } -function deactivateModal(modal, options = {}) { - const context = state.modalContext; - if (!context || context.modal !== modal) return; - state.modalContext = null; - for (const entry of context.inertEntries) entry.node.inert = entry.inert; - modal.classList.remove("modal-interaction"); - restoreModalAttribute2(modal, "role", context.previousRole); - restoreModalAttribute2(modal, "aria-modal", context.previousAriaModal); - restoreModalAttribute2(modal, "tabindex", context.previousTabIndex); - syncResponsiveNavigation(); - if (options.restoreFocus === false) return; - const fallback = typeof options.fallbackFocus === "string" ? document.querySelector(options.fallbackFocus) : options.fallbackFocus; - const target = context.returnFocus?.isConnected === false ? fallback : context.returnFocus ?? fallback; - requestAnimationFrame(() => target?.focus?.({ preventScroll: true })); +function imageMimeFromName(name) { + const extension = name.split(".").pop()?.toLowerCase(); + return { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }[extension ?? ""] || "application/octet-stream"; } -function restoreModalAttribute2(element, name, value) { - if (!element) return; - if (value === null || typeof value === "undefined") element.removeAttribute(name); - else element.setAttribute(name, String(value)); +function attachmentPayload(attachment) { + return { + type: attachment.type === "document" ? "document" : "image", + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size, + data: attachment.data, + pageStart: attachment.pageStart, + pageEnd: attachment.pageEnd + }; } -function handleGlobalKeydown(event) { - const activeModal = state.modalContext?.modal; - if (activeModal) { - if (event.key === "Tab") { - const focusables = modalFocusableElements(activeModal); - const target = focusTrapTarget(focusables, document.activeElement, event.shiftKey); - event.preventDefault(); - (target ?? activeModal).focus?.({ preventScroll: true }); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - closeActiveModal2(activeModal); - return; - } - if (activeModal === els.imageLightbox && event.key === "ArrowLeft") { - event.preventDefault(); - moveLightbox(-1); - } else if (activeModal === els.imageLightbox && event.key === "ArrowRight") { - event.preventDefault(); - moveLightbox(1); - } +function clearAttachments() { + state.attachments = []; + if (els.attachmentInput) { + els.attachmentInput.value = ""; + } + renderAttachmentStrip(); +} +async function sendPrompt() { + if (state.turnSubmitting) { return; } - if (state.questionReviewMode && event.key === "Escape") { - event.preventDefault(); - returnToQuestion2(); + if (currentSessionNeedsModelSelection()) { + showError("请先重新选择模型来源和模型"); return; } - if (event.key === "Escape") { - if (state.modelPanelOpen) hideModelPanel(); - else if (state.settingsOpen) hideSettingsWorkspace(); - else if (state.responsiveView !== "conversation") setResponsiveView("conversation"); + const prompt = els.promptInput.value.trim(); + const attachments = state.attachments.slice(); + if (!prompt && attachments.length === 0) { + return; } -} -function closeActiveModal2(modal) { - if (modal === els.modelConfigPanel) hideModelConfigPanel2(); - else if (modal === els.imageLightbox) hideLightbox(); - else if (modal === els.shutdownPanel) hideShutdownPanel(); - else if (modal === els.contextPanel) hideContextConfirm2(); - else if (modal === els.permissionConfirmPanel) hidePermissionConfirm(); - else if (modal === els.goalConfirmPanel) hideGoalConfirm(); - else if (modal === els.goalTextPanel) hideGoalTextPanel(); - else if (modal === els.approvalPanel) resolveApproval2("cancel"); - else if (modal === els.questionPanel) cancelQuestion2(); -} -async function loadTrust(options = {}) { - const result = await getJson("/api/trust", { signal: options.signal }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error), aborted: isAbortError(error) })); - if (!result.ok) { - if (!options.silent && !result.aborted) { - showError(result.error ?? "无法读取工作区信任状态"); - } - return result; - } - state.trust = result.trust ?? null; - renderTrustPanel(); - return result; -} -async function loadSessions(options = {}) { - const feedback = options.feedback === true; - if (!feedback && state.sessionsLoading) { - return null; - } - const request = beginScopedRequest("sessions"); - if (feedback) { - setSessionsRefreshState2("loading", "刷新中"); + if (!state.trust?.trusted) { + showTrustPanel(); + return; } + state.turnSubmitting = true; + updateSendButton(); + els.runStatus.textContent = state.running ? "已排队" : "启动中"; + const turnRequest = stableTurnRequest(prompt, attachments); + let result; try { - const result = await getJson("/api/sessions", { signal: request.signal }); - if (!isCurrentScopedRequest(request)) return result; - if (!result.ok) { - throw new Error(result.error ?? "刷新会话失败"); - } - state.sessions = result.sessions ?? []; - renderSessions2(); - if (sessionsNeedRefresh2()) { - scheduleSessionsRefresh2(4e3); - } - if (feedback) { - setSessionsRefreshState2("success", `已刷新 ${state.sessions.length} 个会话`); - } - return result; + result = await postJson("/api/turns", { + requestId: turnRequest.id, + prompt, + attachments: attachments.map(attachmentPayload), + sessionId: state.currentSessionId, + clientId: state.currentSessionId ? void 0 : dashboardClientId(), + permissionMode: state.permissionMode, + goalMode: state.goal.enabled === true, + goalText: state.goal.enabled ? state.goal.text : void 0, + clientPreviousPermissionMode: state.goal.enabled ? state.goal.previousPermissionMode : void 0 + }); } catch (error) { - if (isAbortError(error) || !isCurrentScopedRequest(request)) return null; - if (feedback) { - setSessionsRefreshState2("error", "刷新失败"); - } - showError(errorMessageOf(error) || "刷新会话失败"); - return { ok: false, error: errorMessageOf(error) }; + result = { ok: false, error: errorMessageOf(error) }; } finally { - finishScopedRequest(request); + state.turnSubmitting = false; + updateSendButton(); } -} -async function restoreInitialSession() { - if (state.currentSessionId) { + if (!result.ok) { + if (Number.isFinite(Number(result.status))) { + state.turnRequest = null; + } + if (result.trust) { + state.trust = result.trust; + renderTrustPanel(); + } + showError(result.error ?? "任务启动失败"); + els.runStatus.textContent = result.status === 403 ? "待信任" : "失败"; + updateSendButton(); return; } - const sessionId = initialSessionId2() || latestBackgroundSessionId2(); - if (!sessionId || !state.sessions.some((session) => session.id === sessionId)) { - return; + state.turnRequest = null; + els.promptInput.value = ""; + resizePromptInput(); + clearAttachments(); + state.queue = result.queue ?? state.queue; + state.running = result.running === true || state.running; + applyGoalSnapshot(result.goal ?? state.goal, { permissionMode: result.permission?.mode ?? state.permissionMode }); + updateSessionStatus(result.sessionStatus); + updateTurnChangeStats(result.changeStats, { replace: true }); + renderQueuePanel(); + if (result.running === true) { + els.runStatus.textContent = "运行中"; + setLiveTitle("正在处理你的任务"); } - await openSession2(sessionId); -} -function latestBackgroundSessionId2() { - return state.sessions.find((session) => session.backgroundVisible === true)?.id ?? ""; -} -function initialSessionId2() { - try { - const params = new URLSearchParams(window.location?.search ?? ""); - return params.get("sessionId") || window.localStorage?.getItem(CURRENT_SESSION_STORAGE_KEY) || ""; - } catch { - return ""; + updateSendButton(); + const previousSessionId = state.currentSessionId; + state.currentSessionId = result.sessionId ?? null; + rememberCurrentSession(result.sessionId ?? null); + if (previousSessionId !== result.sessionId) { + resetEventReplayState(); + } + rememberEventCursor(result.eventCursor); + if (result.sessionId) { + ensureEventsConnected(result.sessionId); } + await loadSessions(); } -function rememberCurrentSession(sessionId) { - try { - const id = String(sessionId ?? "").trim(); - if (id) { - window.localStorage?.setItem(CURRENT_SESSION_STORAGE_KEY, id); - } else { - window.localStorage?.removeItem(CURRENT_SESSION_STORAGE_KEY); - } - } catch { +function stableTurnRequest(prompt, attachments) { + const signature = JSON.stringify({ + prompt, + sessionId: state.currentSessionId, + permissionMode: state.permissionMode, + attachments: attachments.map((item) => [item.id, item.name, item.mimeType, item.size, item.pageStart, item.pageEnd]) + }); + if (state.turnRequest?.signature === signature) { + return state.turnRequest; } + const request = { id: dashboardRequestId(), signature }; + state.turnRequest = request; + return request; } -function renderSessions2() { - const threadList = els.threadList; - if (!threadList) return; - threadList.innerHTML = ""; - if (state.sessions.length === 0) { - const empty = document.createElement("div"); - empty.className = "thread-meta"; - empty.textContent = "暂无历史任务"; - threadList.append(empty); - return; +function dashboardRequestId() { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); } - for (const session of state.sessions) { - const status = sessionStatusView2(session); - const title = session.title || "未命名任务"; - const meta = sessionMeta2(session, status); - const item = document.createElement("div"); - item.className = `thread-item${session.id === state.currentSessionId ? " active" : ""}`; - item.dataset.tone = status.tone; - item.innerHTML = ` - - ${state.deleteConfirmSessionId === session.id ? ` -
-
确认删除这个会话?
-
历史记录和 transcript 分片会被删除,操作不可撤销。
-
- - -
-
- ` : ` -
- - -
- `} - `; - item.querySelector(".thread-open")?.addEventListener("click", () => openSession2(session.id)); - item.querySelectorAll("button[data-action]").forEach((button) => { - button.addEventListener("click", (event) => { - event.stopPropagation(); - handleSessionAction2(String(button.dataset.action ?? ""), String(button.dataset.sessionId ?? "")); - }); - }); - threadList.append(item); + if (typeof globalThis.crypto?.getRandomValues === "function") { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); } + return `turn-${Date.now()}-${Math.random().toString(36).slice(2)}`; } -function sessionMeta2(session, status = sessionStatusView2(session)) { - const parts = [ - Number(session.queueLength ?? 0) > 0 ? `${session.queueLength} 排队` : null, - status.detail, - session.model || null, - formatTime2(session.modifiedAt) - ].filter(Boolean); - return parts.join(" · "); -} -function sessionStatusView2(session) { - const raw = String(session.status ?? "").toLowerCase(); - if (session.running || raw === "running") { - return { label: "运行中", tone: "running", detail: Number(session.queueLength ?? 0) > 0 ? "有排队" : "" }; - } - if (session.backgroundVisible) { - const kinds = Array.isArray(session.backgroundKinds) ? session.backgroundKinds : []; - if (kinds.includes("terminal") && !kinds.includes("subagent")) { - return { label: "终端后台", tone: "background", detail: Number(session.backgroundCount ?? 0) > 1 ? `${session.backgroundCount} 个任务` : "" }; - } - if (kinds.includes("terminal")) { - return { label: "后台运行", tone: "background", detail: Number(session.backgroundCount ?? 0) > 1 ? `${session.backgroundCount} 个任务` : "" }; +function dashboardClientId() { + if (state.dashboardClientId) return state.dashboardClientId; + try { + const stored = sessionStorage.getItem(DASHBOARD_CLIENT_STORAGE_KEY); + if (stored) { + state.dashboardClientId = stored; + return stored; } - return { label: "子智能体后台", tone: "background", detail: Number(session.backgroundCount ?? 0) > 1 ? `${session.backgroundCount} 个任务` : "" }; - } - if (raw.includes("引导")) { - return { label: "引导中", tone: "running", detail: "" }; - } - if (Number(session.queueLength ?? 0) > 0) { - return { label: "排队中", tone: "waiting", detail: "" }; - } - if (["failed", "error"].includes(raw) || raw.endsWith("_error") || raw.includes("失败")) { - return { label: "失败", tone: "error", detail: "" }; - } - if (["interrupted", "cancelled"].includes(raw) || raw.includes("中断")) { - return { label: "已中断", tone: "warning", detail: "" }; - } - if (["completed", "done"].includes(raw) || raw.includes("完成")) { - return { label: "完成", tone: "done", detail: "" }; + } catch { } - if (session.active) { - return { label: "已打开", tone: "active", detail: "" }; + const clientId = `dashboard-${dashboardRequestId()}`; + state.dashboardClientId = clientId; + try { + sessionStorage.setItem(DASHBOARD_CLIENT_STORAGE_KEY, clientId); + } catch { } - return { label: "历史", tone: "idle", detail: raw && raw !== "unknown" ? session.status : "" }; -} -function toggleSidebar() { - setSidebarCollapsed2(!state.sidebarCollapsed); -} -function setSidebarCollapsed2(collapsed) { - state.sidebarCollapsed = Boolean(collapsed); - document.body.classList.toggle("sidebar-collapsed", state.sidebarCollapsed); - els.collapseSidebar.textContent = state.sidebarCollapsed ? "›" : "‹"; - els.collapseSidebar.title = state.sidebarCollapsed ? "展开会话栏" : "收起会话栏"; - els.collapseSidebar.setAttribute("aria-label", els.collapseSidebar.title); - setPreviewWidth(state.previewPreferredWidth, { updatePreference: false }); + return clientId; } -function sessionsNeedRefresh2() { - return state.sessions.some( - (session) => session.running || session.backgroundVisible || Number(session.queueLength) > 0 || String(session.status ?? "").toLowerCase() === "running" - ); +function statusUrl() { + return `/api/status?clientId=${encodeURIComponent(dashboardClientId())}`; } -function scheduleSessionsRefresh2(delayMs = 800) { - const normalizedDelay = Math.max(0, Number(delayMs) || 0); - const dueAt = Date.now() + normalizedDelay; - if (state.sessionsRefreshTimer && state.sessionsRefreshDueAt <= dueAt) { +async function interruptTurn() { + if (!state.currentSessionId) { return; } - if (state.sessionsRefreshTimer) { - clearTimeout(state.sessionsRefreshTimer); + els.sendButton.disabled = true; + els.sendButton.textContent = "中断中"; + const result = await postJson("/api/turns/interrupt", { + sessionId: state.currentSessionId, + reason: "user" + }, { timeoutMs: DASHBOARD_INTERRUPT_TIMEOUT_MS }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); + els.sendButton.disabled = false; + if (!result.ok) { + showError(result.error ?? "中断失败"); } - state.sessionsRefreshDueAt = dueAt; - state.sessionsRefreshTimer = setTimeout(() => { - state.sessionsRefreshTimer = null; - state.sessionsRefreshDueAt = 0; - loadSessions().catch(() => null); - }, normalizedDelay); + updateSessionStatus(result.sessionStatus); + updateSendButton(); } -function handleSessionAction2(action, sessionId) { - if (!sessionId) { - return; - } - if (action === "delete") { - state.deleteConfirmSessionId = sessionId; - renderSessions2(); +async function guideTurn(queueItemId = "") { + const source = guideSource(queueItemId); + if (!source || !state.currentSessionId || !state.running || state.guideSubmitting) { return; } - if (action === "cancel-delete") { - state.deleteConfirmSessionId = ""; - renderSessions2(); - return; - } - if (action === "confirm-delete") { - deleteSession2(sessionId); - return; - } - if (action === "copy-id") { - copySessionId2(sessionId); - } -} -async function openSession2(id) { - state.turnRequest = null; - const request = beginScopedRequest("session", id); - cancelScopedRequest2("transcript"); - cancelScopedRequest2("file"); - els.runStatus.textContent = "加载会话"; - let result; - try { - result = await getJson(`/api/sessions/${encodeURIComponent(id)}`, { signal: request.signal }); - } catch (error) { - if (!isAbortError(error) && isCurrentScopedRequest(request)) { - showError(errorMessageOf(error) || "无法读取会话"); - } - finishScopedRequest(request); - return; - } - if (!isCurrentScopedRequest(request)) return; - finishScopedRequest(request); + const clientId = `guide-${Date.now()}-${Math.random().toString(16).slice(2)}`; + state.guideSubmitting = true; + setPendingGuide({ + clientId, + sessionId: state.currentSessionId, + phase: "registering", + preview: source.preview + }); + hideApproval(); + hideQuestion(); + els.runStatus.textContent = "引导中"; + setLiveTitle("正在登记引导"); + const result = await postJson("/api/turns/guide", { + sessionId: state.currentSessionId, + guidance: source.guidance, + queueItemId: source.queueItemId, + permissionMode: state.permissionMode + }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); + state.guideSubmitting = false; if (!result.ok) { - showError(typeof result.error === "string" ? result.error : result.error ?? "无法读取会话"); - return; - } - const loadedSession = result.session; - if (!loadedSession) { - showError("无法读取会话"); + if (state.pendingGuide?.clientId === clientId) { + clearPendingGuide(); + } else { + renderQueuePanel(); + } + showError(result.error ?? "引导失败"); return; } - state.currentSessionId = id; - applyGoalSnapshot(loadedSession.goal, { permissionMode: loadedSession.permission?.mode ?? "plan" }); - state.running = loadedSession.active === true && loadedSession.running === true; - rememberCurrentSession(id); - disconnectEvents2(); - hideApproval2(); - hideQuestion2(); - hideContextConfirm2(); - clearTranscript2(); - resetLiveStatus2(); - state.queue = []; - state.queueCancelling.clear(); - state.backgroundCancelling.clear(); - clearPendingGuide2(); - state.activeTurnId = ""; - resetEventReplayState(); - state.deleteConfirmSessionId = ""; - renderQueuePanel(); - state.models = markCurrentModel2(state.models, loadedSession.sessionStatus?.model ?? loadedSession.model); - state.sessionStatus = null; - updateSessionStatus(loadedSession.sessionStatus ?? { - model: loadedSession.model, - context: isPlainObject(loadedSession.context) ? loadedSession.context : null + els.promptInput.value = ""; + state.queue = result.queue ?? state.queue; + updateSessionStatus(result.sessionStatus); + hideApproval(); + hideQuestion(); + setPendingGuide({ + clientId, + sessionId: state.currentSessionId, + phase: result.stopped ? "stopped" : "registered", + preview: source.preview }); - updateSendButton(); - resetTurnChangeStats2(); - state.files = Array.isArray(loadedSession.files) ? loadedSession.files : []; - renderFiles2(); - resetPreview2(); - els.runStatus.textContent = loadedSession.status || "历史"; - setTranscriptPaging2(loadedSession.transcriptPage); - renderTranscriptMessages2(loadedSession.transcript ?? []); - renderSessionFailure2(loadedSession.failure); - scrollTranscript2({ force: true }); - const hasBackground = restoreBackgroundSnapshot2(loadedSession.backgroundSnapshot); - if (loadedSession.active && loadedSession.running) { - rememberEventCursor(loadedSession.eventCursor); - ensureEventsConnected(id); - els.runStatus.textContent = loadedSession.status === "引导中" ? "引导中" : "运行中"; - setLiveTitle(loadedSession.status === "引导中" ? "正在按引导继续" : "正在恢复运行中的任务"); - state.running = true; - updateSendButton(); - } else if (loadedSession.active && hasBackground) { - rememberEventCursor(loadedSession.eventCursor); - ensureEventsConnected(id); - applyIdleRunStatus2("完成"); - updateSendButton(); - } - renderSessions2(); -} -function restoreBackgroundSnapshot2(snapshot) { - if (!isPlainObject(snapshot) || !Array.isArray(snapshot.groups)) { - return false; - } - reconcileBackgroundSubagentSnapshot2(snapshot.groups); - return state.backgroundSubagents.size > 0; + syncGuideButton(); } -async function deleteSession2(sessionId) { - if (!sessionId || state.deletingSessions.has(sessionId)) { +async function cancelQueuedTurn(queueItemId) { + if (!queueItemId || !state.currentSessionId || state.queueCancelling.has(queueItemId)) { return; } - state.deletingSessions.add(sessionId); - renderSessions2(); - const result = await deleteJson2(`/api/sessions/${encodeURIComponent(sessionId)}`).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); - state.deletingSessions.delete(sessionId); - state.deleteConfirmSessionId = ""; + state.queueCancelling.add(queueItemId); + renderQueuePanel(); + const result = await postJson("/api/turns/queue/cancel", { + sessionId: state.currentSessionId, + queueItemId + }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); + state.queueCancelling.delete(queueItemId); if (!result.ok) { - showError(result.error ?? "删除会话失败"); - renderSessions2(); + showError(result.error ?? "取消排队失败"); + renderQueuePanel(); return; } - if (state.currentSessionId === sessionId) { - newTask(); - rememberCurrentSession(null); + state.queue = result.queue ?? state.queue.filter((item) => item.id !== queueItemId); + updateSessionStatus(result.sessionStatus); + if (result.item?.kind === "guide" && state.pendingGuide?.phase !== "continuing") { + clearPendingGuide(); + } else { + renderQueuePanel(); } - await loadSessions(); + syncGuideButton(); } -function setSessionsRefreshState2(tone, message = "") { - if (state.sessionsStatusTimer) { - clearTimeout(state.sessionsStatusTimer); - state.sessionsStatusTimer = null; - } - state.sessionsLoading = tone === "loading"; - els.refreshSessions.disabled = state.sessionsLoading; - els.refreshSessions.dataset.state = tone; - els.refreshSessions.textContent = tone === "success" ? "✓" : tone === "error" ? "!" : "↻"; - els.refreshSessions.title = message || "刷新"; - if (els.sessionsStatus) { - els.sessionsStatus.textContent = message; - els.sessionsStatus.dataset.tone = tone; - } - if (tone === "success" || tone === "error") { - state.sessionsStatusTimer = setTimeout(() => { - els.refreshSessions.dataset.state = "idle"; - els.refreshSessions.textContent = "↻"; - els.refreshSessions.title = "刷新"; - if (els.sessionsStatus) { - els.sessionsStatus.textContent = ""; - els.sessionsStatus.dataset.tone = "idle"; - } - state.sessionsStatusTimer = null; - }, 1800); - } + +// src/dashboard/public/app-ui1.ts +async function init2() { + restorePreviewWidth2(); + bindEvents2(); + observeRunStatus2(); + await bootstrapDashboard2(); } -async function copySessionId2(sessionId) { +async function bootstrapDashboard2() { + const request = beginScopedRequest("bootstrap"); + renderBootstrapLoading2(); try { - await navigator.clipboard.writeText(sessionId); - appendActivity2({ - title: "会话 ID 已复制", - detail: sessionId, - severity: "success", - collapsed: true + const status = await getJson(statusUrl(), { signal: request.signal }); + if (!isCurrentScopedRequest(request)) return; + if (!status.ok) { + throw dashboardPayloadError2(status, "Dashboard 初始化失败"); + } + if (status.version !== DASHBOARD_API_VERSION) { + throw new Error("Dashboard 前后端版本不一致,请重启 Dashboard 服务后刷新页面"); + } + state.cwd = String(status.cwd ?? ""); + state.models = normalizeModels(status.models); + state.gatewayConfig = normalizeGatewayConfig(status.gatewayConfig); + state.gatewayProfiles = normalizeGatewayProfiles(status.gatewayProfiles); + state.agentModelTiers = normalizeAgentModelTiers(status.agentModelTiers); + state.visionAgent = normalizeVisionAgent(status.visionAgent); + state.settings = normalizeDashboardSettings2(status.settings); + updateConfigRevisions(status); + state.applyAgentDefaultsOnSwitch = Boolean(state.settings?.agents?.syncModelTiersOnSwitch); + updateSessionStatus({ + ...status.sessionStatus, + providerId: status.sessionStatus?.providerId ?? state.gatewayConfig?.activeProfileId ?? "" }); - } catch { - showError("复制会话 ID 失败"); + rememberNewTaskModelState(); + if (els.projectPath) { + els.projectPath.textContent = String(status.cwd ?? ""); + } + const trust = await loadTrust({ signal: request.signal, silent: true }); + if (!trust?.ok) { + throw dashboardPayloadError2(trust, "无法读取工作区信任状态"); + } + if (!isCurrentScopedRequest(request)) return; + await loadSessions(); + if (!isCurrentScopedRequest(request)) return; + await restoreInitialSession(); + if (!isCurrentScopedRequest(request)) return; + updateSendButton(); + renderComposerStatus2(); + clearBootstrapStatus2(); + } catch (error) { + if (!isAbortError(error) && isCurrentScopedRequest(request)) { + renderBootstrapFailure2(error); + } + } finally { + finishScopedRequest(request); } } -function newTask() { - cancelScopedRequest2("session"); - cancelScopedRequest2("transcript"); - cancelScopedRequest2("file"); - state.turnRequest = null; - state.currentSessionId = null; - applyGoalSnapshot(null, { permissionMode: "plan" }); - state.running = false; - rememberCurrentSession(null); - disconnectEvents2(); - hideApproval2(); - hideQuestion2(); - hideContextConfirm2(); - clearTranscript2(); - state.files = []; - state.queue = []; - state.queueCancelling.clear(); - state.backgroundCancelling.clear(); - state.completedActivities = []; - clearPendingGuide2(); - state.activeTurnId = ""; - resetEventReplayState(); - state.deleteConfirmSessionId = ""; - resetLiveStatus2(); - resetTurnChangeStats2(); - restoreNewTaskModelState2(); - clearAttachments2(); - renderQueuePanel(); - renderFiles2(); - resetPreview2(); - els.runStatus.textContent = "空闲"; - updateSendButton(); - resizePromptInput(); - els.promptInput.focus(); - refreshNewTaskModelState2().catch(() => null); -} -function rememberNewTaskModelState() { - state.newTaskModelState = { - models: normalizeModels(state.models), - gatewayConfig: normalizeGatewayConfig(state.gatewayConfig), - gatewayProfiles: normalizeGatewayProfiles(state.gatewayProfiles), - agentModelTiers: normalizeAgentModelTiers(state.agentModelTiers), - visionAgent: normalizeVisionAgent(state.visionAgent), - sessionStatus: { - ...state.sessionStatus ?? emptySessionStatus, - context: state.sessionStatus?.context ? { ...state.sessionStatus.context } : null - } - }; +function observeRunStatus2() { + updateRunStatusTone2(); + new MutationObserver(updateRunStatusTone2).observe(els.runStatus, { childList: true, characterData: true, subtree: true }); } -function restoreNewTaskModelState2() { - const snapshot = state.newTaskModelState; - if (!snapshot) { - return; +function updateRunStatusTone2() { + const status = els.runStatus.textContent.trim(); + let tone = "idle"; + if (/失败|拒绝/.test(status)) { + tone = "error"; + } else if (/等待|待/.test(status)) { + tone = "waiting"; + } else if (/运行|启动|引导|中断|停止|收尾|排队|压缩/.test(status)) { + tone = "running"; + } else if (/完成/.test(status)) { + tone = "done"; } - state.models = normalizeModels(snapshot.models); - state.gatewayConfig = normalizeGatewayConfig(snapshot.gatewayConfig); - state.gatewayProfiles = normalizeGatewayProfiles(snapshot.gatewayProfiles); - state.agentModelTiers = normalizeAgentModelTiers(snapshot.agentModelTiers); - state.visionAgent = normalizeVisionAgent(snapshot.visionAgent); - state.sessionStatus = null; - updateSessionStatus(snapshot.sessionStatus); + els.runStatus.dataset.tone = tone; } -async function refreshNewTaskModelState2() { - const result = await getJson(statusUrl()); - if (!result.ok || state.currentSessionId) { - return; - } - state.models = normalizeModels(result.models); - state.gatewayConfig = normalizeGatewayConfig(result.gatewayConfig); - state.gatewayProfiles = normalizeGatewayProfiles(result.gatewayProfiles); - state.agentModelTiers = normalizeAgentModelTiers(result.agentModelTiers); - state.visionAgent = normalizeVisionAgent(result.visionAgent); - updateConfigRevisions(result); - state.sessionStatus = null; - updateSessionStatus({ - ...result.sessionStatus, - providerId: result.sessionStatus?.providerId ?? result.gatewayConfig?.activeProfileId ?? "" - }); - rememberNewTaskModelState(); -} -async function addAttachmentFiles(files) { - const list = Array.from(files ?? []).filter((file) => file instanceof File); - if (list.length === 0) { - return; - } - let ignoredUnknown = 0; - for (const file of list) { - const kind = classifyComposerFile(file); - if (kind === "image") { - const imageCount = state.attachments.filter((item) => item.type !== "document").length; - if (imageCount >= MAX_IMAGE_ATTACHMENTS) { - showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片`); - continue; - } - if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { - showError(`${file.name || "图片"} 超过 8MB,暂不发送`); - continue; - } - try { - state.attachments.push(await readImageAttachment2(file)); - } catch (error) { - showError(errorMessageOf(error) || "读取图片失败"); - } - continue; +function bindEvents2() { + els.refreshSessions.addEventListener("click", () => loadSessions({ feedback: true })); + els.collapseSidebar.addEventListener("click", () => { + if (responsiveLayoutMode2() === "desktop") { + toggleSidebar(); + } else { + setResponsiveView("conversation"); } - if (kind === "document") { - const documentCount = state.attachments.filter((item) => item.type === "document").length; - if (documentCount >= MAX_DOCUMENT_ATTACHMENTS) { - showError(`最多可附加 ${MAX_DOCUMENT_ATTACHMENTS} 个文档`); - continue; - } - if (file.size > MAX_DOCUMENT_ATTACHMENT_BYTES) { - showError(`${file.name || "文档"} 超过 40MB,暂不发送`); - continue; - } - try { - state.attachments.push(await readDocumentAttachment(file)); - } catch (error) { - showError(errorMessageOf(error) || "读取文档失败"); + }); + els.newTask.addEventListener("click", () => { + newTask(); + setResponsiveView("conversation"); + }); + els.sendButton.addEventListener("click", () => { + if (state.turnSubmitting) { + return; + } + if (state.running) { + interruptTurn(); + return; + } + sendPrompt(); + }); + els.promptInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault(); + sendPrompt(); + } + }); + els.promptInput.addEventListener("input", () => { + syncGuideButton(); + resizePromptInput(); + }); + els.attachButton.addEventListener("click", () => els.attachmentInput.click()); + els.attachmentInput.addEventListener("change", async () => { + await addAttachmentFiles(Array.from(els.attachmentInput.files ?? [])); + els.attachmentInput.value = ""; + }); + els.promptInput.addEventListener("paste", async (event) => { + const files = Array.from(event.clipboardData?.files ?? []).filter((file) => classifyComposerFile(file) === "image"); + if (files.length === 0) { + return; + } + event.preventDefault(); + await addAttachmentFiles(files); + }); + document.addEventListener("keydown", handleGlobalKeydown); + els.permissionMode.addEventListener("click", (event) => { + const button = eventElement(event)?.closest("button[data-mode]"); + if (!(button instanceof HTMLButtonElement)) return; + requestPermissionMode2(button.dataset.mode, button); + }); + els.permissionMode.addEventListener("keydown", handlePermissionModeKeydown2); + els.goalMode?.addEventListener("click", () => requestGoalMode2()); + els.preview.addEventListener("click", handleLocalFileOpenClick); + els.collapsePreview.addEventListener("click", () => { + if (responsiveLayoutMode2() === "desktop") { + document.body.classList.toggle("preview-collapsed"); + syncPreviewResizeHandle2(); + } else { + setResponsiveView("conversation"); + } + }); + els.previewResizeHandle?.addEventListener("pointerdown", (event) => beginPreviewResize2(event)); + els.previewResizeHandle?.addEventListener("pointermove", (event) => updatePreviewResize2(event)); + els.previewResizeHandle?.addEventListener("pointerup", (event) => finishPreviewResize2(event)); + els.previewResizeHandle?.addEventListener("pointercancel", (event) => finishPreviewResize2(event)); + els.previewResizeHandle?.addEventListener("keydown", (event) => handlePreviewResizeKeydown2(event)); + els.previewResizeHandle?.addEventListener("dblclick", () => { + setPreviewWidth(PREVIEW_WIDTH_DEFAULT, { persist: true, announce: true }); + }); + els.shutdownButton.addEventListener("click", showShutdownPanel2); + els.headerShutdownButton.addEventListener("click", showShutdownPanel2); + els.shutdownCancel.addEventListener("click", hideShutdownPanel); + els.shutdownConfirm.addEventListener("click", shutdownDashboard2); + els.lightboxBackdrop.addEventListener("click", hideLightbox); + els.lightboxClose.addEventListener("click", hideLightbox); + els.lightboxPrevious.addEventListener("click", () => moveLightbox(-1)); + els.lightboxNext.addEventListener("click", () => moveLightbox(1)); + els.contextClear.addEventListener("click", () => showContextConfirm2("clear")); + els.contextCompact.addEventListener("click", () => showContextConfirm2("compact")); + els.modelStatus.addEventListener("click", handleModelStatusActivate2); + els.modelStatus.addEventListener("keydown", handleModelStatusKeydown2); + els.modelStatus.addEventListener("change", handleReasoningEffortChange2); + els.modelPanel.addEventListener("click", handleModelPanelClick2); + els.modelPanel.addEventListener("change", handleModelPanelChange2); + els.modelConfigPanel.addEventListener("click", handleModelConfigPanelClick2); + els.modelConfigPanel.addEventListener("input", handleModelConfigInput2); + els.modelConfigPanel.addEventListener("change", handleModelConfigChange2); + els.modelConfigPanel.addEventListener("submit", saveModelConfig2); + els.settingsButton?.addEventListener("click", () => { + if (state.settingsOpen) hideSettingsWorkspace(); + else showSettingsWorkspace2(); + }); + els.settingsBack?.addEventListener("click", () => hideSettingsWorkspace()); + els.settingsRail?.addEventListener("click", handleSettingsRailClick2); + els.settingsContent?.addEventListener("click", handleSettingsClick2); + els.settingsContent?.addEventListener("input", handleSettingsFormChange2); + els.settingsContent?.addEventListener("change", handleSettingsFormChange2); + els.settingsContent?.addEventListener("submit", saveSettingsConfig2); + els.liveSubtasks.addEventListener("click", (event) => { + const button = eventTargetOf(event).closest("button[data-background-cancel]"); + if (!button) { + return; + } + event.preventDefault(); + event.stopPropagation(); + cancelBackgroundSubagent2(button.dataset.groupId, button.dataset.taskId); + }); + document.addEventListener("click", (event) => { + if (!state.modelPanelOpen) { + return; + } + if (eventTargetOf(event).closest("#model-panel") || eventTargetOf(event).closest("#model-config-panel") || eventTargetOf(event).closest("#model-status-toggle")) { + return; + } + hideModelPanel(); + }); + els.transcript.addEventListener("scroll", handleTranscriptScroll2); + els.workflowStrip.addEventListener("click", (event) => { + if (!eventTargetOf(event).closest("button[data-action='toggle-workflow']")) { + return; + } + state.workflowExpanded = !state.workflowExpanded; + renderWorkflowStrip2(); + }); + els.activityToggle.addEventListener("click", toggleLiveStatusDetails2); + els.transcriptJump.addEventListener("click", followTranscript2); + els.responsiveNavigation.querySelectorAll("button[data-dashboard-view]").forEach((button) => { + button.addEventListener("click", () => { + if (button.dataset.dashboardView === "settings") { + showSettingsWorkspace2(); + return; } - continue; + if (state.settingsOpen) hideSettingsWorkspace({ restoreFocus: false }); + setResponsiveView(button.dataset.dashboardView); + }); + }); + els.responsiveScrim.addEventListener("click", () => setResponsiveView("conversation")); + els.threadList.addEventListener("click", (event) => { + if (eventTargetOf(event).closest(".thread-open")) { + setResponsiveView("conversation"); } - ignoredUnknown += 1; + }); + document.addEventListener("click", handleResponsiveFileNavigation2); + els.connectionStatus?.addEventListener("click", reconnectEventsManually2); + window.addEventListener?.("online", () => { + if (state.currentSessionId && state.connectionState !== "connected") { + reconnectEventsManually2(); + } + }); + window.addEventListener?.("offline", () => { + clearEventReconnectTimer2(); + closeEventSource2(); + setConnectionState2("offline"); + }); + window.addEventListener?.("resize", () => { + syncResponsiveNavigation(); + setPreviewWidth(state.previewPreferredWidth, { updatePreference: false }); + }); + window.visualViewport?.addEventListener?.("resize", syncVisualViewport2); + window.visualViewport?.addEventListener?.("scroll", syncVisualViewport2); + resizePromptInput(); + syncVisualViewport2(); + syncResponsiveNavigation(); +} +function normalizedResponsiveView2(width, requestedView) { + if (requestedView === "settings") return "settings"; + if (Number(width) >= 1200) return "conversation"; + return typeof requestedView === "string" && ["sessions", "conversation", "files"].includes(requestedView) ? requestedView : "conversation"; +} +function composerHeightFor2(scrollHeight, minimum = 52, maximum = 160) { + const measured = Number(scrollHeight); + const safeMinimum = Math.max(1, Number(minimum) || 52); + const safeMaximum = Math.max(safeMinimum, Number(maximum) || 160); + return Math.min(safeMaximum, Math.max(safeMinimum, Number.isFinite(measured) ? measured : safeMinimum)); +} +function previewWidthBounds2(viewportWidth, sidebarCollapsed = false) { + const viewport = Math.max(0, Number(viewportWidth) || 0); + const sidebarWidth = sidebarCollapsed ? 56 : 280; + const available = viewport - 20 - 20 - sidebarWidth - PREVIEW_WORKSPACE_MIN; + return { + min: PREVIEW_WIDTH_MIN, + max: Math.max(PREVIEW_WIDTH_MIN, Math.min(PREVIEW_WIDTH_MAX, available)) + }; +} +function clampedPreviewWidth2(width, bounds) { + const minimum = Math.max(0, Number(bounds?.min) || PREVIEW_WIDTH_MIN); + const maximum = Math.max(minimum, Number(bounds?.max) || PREVIEW_WIDTH_MAX); + const value = Number(width); + return Math.min(maximum, Math.max(minimum, Number.isFinite(value) ? value : PREVIEW_WIDTH_DEFAULT)); +} +function permissionIndexForKey2(currentIndex, key, length) { + const count = Math.max(0, Number(length) || 0); + if (count === 0) return -1; + if (key === "Home") return 0; + if (key === "End") return count - 1; + if (key === "ArrowRight" || key === "ArrowDown") return (Math.max(0, currentIndex) + 1) % count; + if (key === "ArrowLeft" || key === "ArrowUp") return (Math.max(0, currentIndex) - 1 + count) % count; + return currentIndex; +} +function focusTrapTarget(focusables, activeElement, shiftKey = false) { + const items = Array.from(focusables ?? []); + if (items.length === 0) return null; + const current = activeElement ? items.indexOf(activeElement) : -1; + if (current < 0) return shiftKey ? items.at(-1) : items[0]; + return items[(current + (shiftKey ? -1 : 1) + items.length) % items.length]; +} +function shouldFollowTranscript2({ force = false, following = true, onlyIfNearBottom = false, wasAtBottom = true } = {}) { + if (force) return true; + if (!following) return false; + return !onlyIfNearBottom || wasAtBottom !== false; +} +function scheduleAnimationFrameOnce2(holder, key, callback, scheduler = requestAnimationFrame) { + if (holder[key] != null) return false; + const frame = scheduler(() => { + holder[key] = null; + callback(); + }); + holder[key] = frame ?? true; + return true; +} +function cancelScheduledAnimationFrame2(holder, key, cancel = cancelAnimationFrame) { + const frame = holder[key]; + if (frame == null) return false; + holder[key] = null; + if (frame !== true) cancel(Number(frame)); + return true; +} +function appendPlainDraftDelta2(body, text, renderedLength = 0, createTextNode = (value) => document.createTextNode(value)) { + const value = String(text ?? ""); + const start = Math.min(value.length, Math.max(0, Number(renderedLength) || 0)); + const pending = value.slice(start); + if (pending) { + if (typeof body?.append === "function") body.append(createTextNode(pending)); + else if (body) body.textContent = `${body.textContent ?? ""}${pending}`; } - if (ignoredUnknown > 0) { - showError("回形针只接收图片和 PDF / Word / Excel / PPT / 文本,不支持旧版 .doc .xls .ppt"); + return value.length; +} +function renderFinalAssistantBody2(body, text, renderer = renderMessageText2) { + renderer(body, text ?? "", { markdown: true }); +} +function selectTranscriptNodesToRemove2(nodes, limit, direction = "append", isProtected = () => false) { + const values = Array.from(nodes ?? []); + let overflow = Math.max(0, values.length - Math.max(0, Number(limit) || 0)); + if (overflow === 0) return []; + const ordered = direction === "prepend" ? values.slice().reverse() : values; + const selected = []; + for (const node of ordered) { + if (overflow === 0) break; + if (isProtected(node)) continue; + selected.push(node); + overflow -= 1; } - renderAttachmentStrip2(); - updateSendButton(); + return selected; } -function classifyComposerFile(file) { - const name = String(file.name ?? ""); - const ext = name.includes(".") ? `.${name.split(".").pop()?.toLowerCase() ?? ""}` : ""; - const mime = String(file.type ?? "").toLowerCase(); - if (IMAGE_EXTENSIONS.has(ext) || mime === "image/png" || mime === "image/jpeg" || mime === "image/gif" || mime === "image/webp") { - return "image"; +function responsiveLayoutMode2() { + const width = Number(window.innerWidth) || Number(document.documentElement?.clientWidth) || 1200; + if (width >= 1200) return "desktop"; + return width >= 768 ? "tablet" : "mobile"; +} +function restorePreviewWidth2() { + let saved = PREVIEW_WIDTH_DEFAULT; + try { + saved = Number(window.localStorage?.getItem(PREVIEW_WIDTH_STORAGE_KEY)) || PREVIEW_WIDTH_DEFAULT; + } catch { } - if (DOCUMENT_EXTENSIONS.has(ext) || mime === "application/pdf") { - return "document"; + setPreviewWidth(saved); +} +function setPreviewWidth(width, options = {}) { + const bounds = previewWidthBounds2(Number(window.innerWidth) || 1200, state.sidebarCollapsed); + if (options.updatePreference !== false) { + state.previewPreferredWidth = clampedPreviewWidth2(width, { min: PREVIEW_WIDTH_MIN, max: PREVIEW_WIDTH_MAX }); } - return "unsupported"; + state.previewWidth = clampedPreviewWidth2(state.previewPreferredWidth, bounds); + document.documentElement?.style?.setProperty("--preview-width", `${state.previewWidth}px`); + syncPreviewResizeHandle2(bounds); + if (options.persist) { + try { + window.localStorage?.setItem(PREVIEW_WIDTH_STORAGE_KEY, String(state.previewPreferredWidth)); + } catch { + } + } + if (options.announce) announceStatus2(`文件栏宽度 ${state.previewWidth} 像素`); + return state.previewWidth; } -function readImageAttachment2(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("error", () => reject(new Error("读取图片失败"))); - reader.addEventListener("load", () => { - const dataUrl = String(reader.result ?? ""); - const match = dataUrl.match(/^data:([^;,]+);base64,([\s\S]+)$/); - if (!match) { - reject(new Error("图片格式无法作为附件发送")); - return; - } - resolve({ - id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, - type: "image", - name: file.name || "image", - mimeType: match[1] || file.type || "image/png", - size: file.size, - data: match[2], - previewUrl: dataUrl - }); - }); - reader.readAsDataURL(file); - }); +function syncPreviewResizeHandle2(bounds = previewWidthBounds2(Number(window.innerWidth) || 1200, state.sidebarCollapsed)) { + const handle = els.previewResizeHandle; + if (!handle) return; + handle.setAttribute("aria-valuemin", String(bounds.min)); + handle.setAttribute("aria-valuemax", String(bounds.max)); + handle.setAttribute("aria-valuenow", String(state.previewWidth)); + handle.setAttribute("aria-valuetext", `${state.previewWidth} 像素`); + handle.setAttribute("aria-disabled", responsiveLayoutMode2() !== "desktop" || document.body.classList.contains("preview-collapsed") ? "true" : "false"); } -function readDocumentAttachment(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("error", () => reject(new Error("读取文档失败"))); - reader.addEventListener("load", () => { - const dataUrl = String(reader.result ?? ""); - const match = dataUrl.match(/^data:([^;,]+);base64,([\s\S]+)$/); - if (!match) { - reject(new Error("文档格式无法作为附件发送")); - return; - } - resolve({ - id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, - type: "document", - name: file.name || "document", - mimeType: match[1] || file.type || "application/octet-stream", - size: file.size, - data: match[2], - previewUrl: "" - }); - }); - reader.readAsDataURL(file); +function beginPreviewResize2(event) { + const handle = els.previewResizeHandle; + if (!handle) return; + if (responsiveLayoutMode2() !== "desktop" || document.body.classList.contains("preview-collapsed")) return; + event.preventDefault(); + state.previewResizeStartX = Number(event.clientX); + state.previewResizeStartWidth = state.previewWidth; + handle.setPointerCapture?.(event.pointerId); + document.body.classList.add("preview-resizing"); +} +function updatePreviewResize2(event) { + if (state.previewResizeStartX === null || state.previewResizeStartWidth === null) return; + const delta = Number(event.clientX) - state.previewResizeStartX; + setPreviewWidth(state.previewResizeStartWidth - delta); +} +function finishPreviewResize2(event) { + const handle = els.previewResizeHandle; + if (!handle) return; + if (state.previewResizeStartX === null) return; + handle.releasePointerCapture?.(event.pointerId); + state.previewResizeStartX = null; + state.previewResizeStartWidth = null; + document.body.classList.remove("preview-resizing"); + setPreviewWidth(state.previewWidth, { persist: true, announce: true }); +} +function handlePreviewResizeKeydown2(event) { + if (responsiveLayoutMode2() !== "desktop" || document.body.classList.contains("preview-collapsed")) return; + let next = state.previewWidth; + const step = event.shiftKey ? 48 : 16; + if (event.key === "ArrowLeft") next += step; + else if (event.key === "ArrowRight") next -= step; + else if (event.key === "Home") next = previewWidthBounds2(Number(window.innerWidth) || 1200, state.sidebarCollapsed).min; + else if (event.key === "End") next = previewWidthBounds2(Number(window.innerWidth) || 1200, state.sidebarCollapsed).max; + else return; + event.preventDefault(); + setPreviewWidth(next, { persist: true, announce: true }); +} +function setResponsiveView(view) { + if (view !== "settings" && state.settingsOpen) { + hideSettingsWorkspace({ restoreFocus: false }); + } + state.responsiveView = normalizedResponsiveView2(Number(window.innerWidth) || 1200, view); + syncResponsiveNavigation(); +} +function syncResponsiveNavigation() { + const width = Number(window.innerWidth) || Number(document.documentElement?.clientWidth) || 1200; + const view = normalizedResponsiveView2(width, state.responsiveView); + state.responsiveView = view; + if (width < 1200) { + state.sidebarCollapsed = false; + document.body.classList.remove("sidebar-collapsed", "preview-collapsed"); + } + document.body.dataset.dashboardView = view; + els.responsiveNavigation?.querySelectorAll("button[data-dashboard-view]").forEach((button) => { + const active = button.dataset.dashboardView === (state.settingsOpen ? "settings" : view); + button.classList.toggle("active", active); + if (active) button.setAttribute("aria-current", "page"); + else button.removeAttribute("aria-current"); }); + if (state.modalContext) return; + const desktop = width >= 1200; + setResponsiveSurfaceInert2(els.sidebar, !desktop && view !== "sessions"); + setResponsiveSurfaceInert2(els.workspace, !desktop && !["conversation", "settings"].includes(view)); + setResponsiveSurfaceInert2(els.preview, !desktop && view !== "files"); +} +function setResponsiveSurfaceInert2(element, inert) { + if (!element) return; + element.inert = Boolean(inert); +} +function handleResponsiveFileNavigation2(event) { + if (responsiveLayoutMode2() === "desktop") return; + if (eventTargetOf(event).closest(".file-item, .file-link, [data-file]")) { + setResponsiveView("files"); + } +} +function syncVisualViewport2() { + const viewportHeight = Number(window.visualViewport?.height) || Number(window.innerHeight) || 0; + if (viewportHeight > 0) { + document.documentElement?.style?.setProperty("--dashboard-viewport-height", `${Math.round(viewportHeight)}px`); + } + const keyboardVisible = Boolean(window.visualViewport) && Number(window.innerHeight) - viewportHeight > 120; + document.body.classList.toggle("keyboard-visible", keyboardVisible); +} +function resizePromptInput() { + if (!els.promptInput) return; + els.promptInput.style.height = "auto"; + const height = composerHeightFor2(els.promptInput.scrollHeight); + els.promptInput.style.height = `${height}px`; + els.promptInput.style.overflowY = Number(els.promptInput.scrollHeight) > height ? "auto" : "hidden"; +} +function handlePermissionModeKeydown2(event) { + const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]; + if (!keys.includes(event.key)) return; + const buttons = Array.from(els.permissionMode.querySelectorAll("button[data-mode]")); + const current = Math.max(0, buttons.indexOf(eventTargetOf(event).closest("button[data-mode]"))); + const next = permissionIndexForKey2(current, event.key, buttons.length); + const button = buttons[next]; + if (!button) return; + event.preventDefault(); + event.stopPropagation(); + button.focus(); + requestPermissionMode2(button.dataset.mode, button); } -function renderAttachmentStrip2() { - if (!els.attachmentStrip) { +function requestPermissionMode2(mode, trigger = document.activeElement) { + if (state.goal.enabled && mode !== "fullAccess") { + showError("Goal 开启时不能降低权限。请先退出 Goal。"); return; } - els.attachmentStrip.innerHTML = ""; - els.attachmentStrip.classList.toggle("hidden", state.attachments.length === 0); - for (const attachment of state.attachments) { - const item = document.createElement("div"); - item.className = "attachment-chip"; - const isDocument = attachment.type === "document"; - const label = attachment.name || (isDocument ? "文档" : "图片"); - const preview = isDocument || !attachment.previewUrl ? `${escapeHtml(documentChipLabel(attachment.name))}` : ``; - item.innerHTML = ` - ${preview} - ${escapeHtml(label)} - - `; - item.querySelector("button").addEventListener("click", () => { - state.attachments = state.attachments.filter((candidate) => candidate.id !== attachment.id); - renderAttachmentStrip2(); - updateSendButton(); - }); - els.attachmentStrip.append(item); + if (mode === "fullAccess" && state.permissionMode !== "fullAccess") { + showPermissionConfirm2(trigger); + return; } + hidePermissionConfirm({ restoreFocus: false }); + setPermissionMode2(mode); } -function documentChipLabel(name) { - const ext = String(name ?? "").split(".").pop()?.toUpperCase() ?? "FILE"; - return ext.slice(0, 4); +function defaultGoalMaxAutoContinues2() { + const value = Number(state.settings?.agents?.goalMaxAutoContinues); + return Number.isInteger(value) && value >= 1 && value <= 100 ? value : 12; } -function attachmentPayload2(attachment) { +function emptyGoalSnapshot2() { return { - type: attachment.type === "document" ? "document" : "image", - name: attachment.name, - mimeType: attachment.mimeType, - size: attachment.size, - data: attachment.data + enabled: false, + status: "off", + text: "", + previousPermissionMode: "plan", + roundCount: 0, + continueCount: 0, + maxAutoContinues: defaultGoalMaxAutoContinues2(), + lastContinueReason: "", + lastBlockReason: "", + lastEvidence: null, + hasWrites: false, + recap: null }; } -function clearAttachments2() { - state.attachments = []; - if (els.attachmentInput) { - els.attachmentInput.value = ""; +function applyGoalSnapshot(goal, options = {}) { + const snapshot = isPlainObject(goal) ? goal : {}; + const enabled = snapshot.enabled === true; + state.goal = { + ...emptyGoalSnapshot2(), + enabled, + text: String(snapshot.text ?? ""), + status: enabled ? String(snapshot.status || "active") : "off", + previousPermissionMode: String(snapshot.previousPermissionMode ?? state.goal.previousPermissionMode ?? "plan"), + roundCount: Number(snapshot.roundCount) || 0, + continueCount: Number(snapshot.continueCount) || 0, + maxAutoContinues: Number(snapshot.maxAutoContinues) || defaultGoalMaxAutoContinues2(), + lastContinueReason: String(snapshot.lastContinueReason ?? ""), + lastBlockReason: String(snapshot.lastBlockReason ?? ""), + lastEvidence: snapshot.lastEvidence ?? null, + hasWrites: snapshot.hasWrites === true, + recap: isPlainObject(snapshot.recap) ? snapshot.recap : null + }; + const permissionSource = options.permissionMode || (state.goal.enabled ? "fullAccess" : state.permissionMode); + if (state.goal.enabled) { + setPermissionMode2("fullAccess"); + } else if (options.permissionMode) { + setPermissionMode2(options.permissionMode); + } else { + setPermissionMode2(permissionSource); } - renderAttachmentStrip2(); + renderGoalControls2(); + syncPromptPlaceholder(); } -async function sendPrompt() { - if (state.turnSubmitting) { +var DEFAULT_PROMPT_PLACEHOLDER = "输入任务需求,例如:整理这批实验数据并生成一份简洁报告"; +function syncPromptPlaceholder() { + if (!els.promptInput) return; + els.promptInput.placeholder = DEFAULT_PROMPT_PLACEHOLDER; +} +function renderGoalControls2() { + const enabled = state.goal.enabled === true; + const readonlyLocked = state.sessionStatus?.readonlyLocked === true || state.trust?.readonlyLocked === true; + if (els.goalMode) { + els.goalMode.setAttribute("aria-pressed", String(enabled)); + els.goalMode.classList.toggle("active", enabled); + els.goalMode.disabled = readonlyLocked && !enabled; + els.goalMode.title = readonlyLocked && !enabled ? "只读锁定会话不能启用 Goal" : "Goal 模式"; + } + document.body.classList.toggle("goal-mode-active", enabled); + els.permissionMode?.querySelectorAll("button[data-mode]").forEach((button) => { + button.disabled = enabled && button.dataset.mode !== "fullAccess"; + }); + if (enabled) { + els.modeDescription.textContent = MODE_DESCRIPTIONS.goal; + } + renderGoalStatusBar2(); +} +function renderGoalStatusBar2() { + const bar = els.goalStatusBar; + if (!bar) return; + if (!state.goal.enabled) { + bar.classList.add("hidden"); + bar.replaceChildren(); return; } - if (currentSessionNeedsModelSelection()) { - showError("请先重新选择模型来源和模型"); + bar.classList.remove("hidden"); + const statusLabel = { + active: "进行中", + running: "进行中", + paused: "已暂停", + verifying: "核验中", + complete: "已完成", + failed: "失败", + off: "关闭" + }[state.goal.status] ?? state.goal.status; + const recapLine = String(state.goal.recap?.line ?? "").trim(); + const showRecap = recapLine.length > 0; + const canResume = state.goal.status === "paused" || state.goal.status === "failed"; + const showPause = !canResume && state.goal.status !== "complete"; + const objectiveClass = ["goal-objective", showRecap ? "goal-objective-ellipsis" : ""].filter(Boolean).join(" "); + const continueReason = String(state.goal.lastContinueReason ?? "").trim(); + bar.innerHTML = ` +
+
Goal · ${escapeHtml(statusLabel)}
+
${escapeHtml(state.goal.text || "")}
+ ${showRecap ? `
${escapeHtml(recapLine)}
` : `
${Number(state.goal.continueCount) || 0} / ${Number(state.goal.maxAutoContinues) || defaultGoalMaxAutoContinues2()} 次续跑${continueReason ? ` · ${escapeHtml(continueReason)}` : ""}
`} +
+
+ ${canResume ? `` : ""} + ${showPause ? `` : ""} + +
+ `; + bar.querySelectorAll("[data-goal-action]").forEach((button) => { + button.addEventListener("click", () => submitGoalAction2(String(button.dataset.goalAction ?? ""))); + }); +} +function requestGoalMode2() { + if (state.goal.enabled) { + submitGoalAction2("disable"); + return; + } + if (!state.trust?.trusted) { + showTrustPanel(); + return; + } + showGoalConfirm2(els.goalMode); +} +function showGoalConfirm2(trigger) { + const panel = els.goalConfirmPanel; + if (!panel) return; + panel.classList.remove("hidden"); + panel.innerHTML = ` +
+
启用 Goal 模式?
+
Goal 模式会在本会话朝着你给出的目标自动连续执行,直到完成、暂停、失败或预算用尽。

开启后当前会话将使用「完全访问」:本机工具、工作区外文件和网络操作将自动获准,并且不会在每个工具调用时停下等你批准。

这是无人值守自动执行,不是普通聊天。请确认本机工作区可被连续修改。你随时可以暂停、中断或退出 Goal。
+
+
+ + +
+ `; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + panel.setAttribute("aria-labelledby", "goal-confirm-title"); + panel.setAttribute("tabindex", "-1"); + panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hideGoalConfirm()); + panel.querySelector("button[data-action='confirm']")?.addEventListener("click", () => { + hideGoalConfirm({ restoreFocus: false }); + showGoalTextPanel2(trigger); + }); + activateModal(panel, { initialFocus: "button[data-action='cancel']", returnFocus: trigger }); +} +function hideGoalConfirm(options = {}) { + const panel = els.goalConfirmPanel; + if (!panel || panel.classList.contains("hidden")) return; + deactivateModal(panel, options); + panel.classList.add("hidden"); + panel.replaceChildren(); +} +function showGoalTextPanel2(trigger) { + const panel = els.goalTextPanel; + if (!panel) return; + panel.classList.remove("hidden"); + panel.innerHTML = ` +
+
输入 Goal 目标
+
用一句话写清要完成的目标。提交后才会进入无人值守执行。
+ + + +
+
+ + +
+ `; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + panel.setAttribute("aria-labelledby", "goal-text-title"); + const input = panel.querySelector("#goal-objective-input"); + panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hideGoalTextPanel()); + panel.querySelector("button[data-action='confirm']")?.addEventListener("click", () => { + const text = String(input?.value ?? "").trim(); + if (!text) { + const error = panel.querySelector("#goal-text-error"); + if (error) error.hidden = false; + input?.focus(); + return; + } + hideGoalTextPanel({ restoreFocus: false }); + enableGoalWithObjective2(text); + }); + activateModal(panel, { initialFocus: "#goal-objective-input", returnFocus: trigger }); +} +function hideGoalTextPanel(options = {}) { + const panel = els.goalTextPanel; + if (!panel || panel.classList.contains("hidden")) return; + deactivateModal(panel, options); + panel.classList.add("hidden"); + panel.replaceChildren(); +} +async function enableGoalWithObjective2(text) { + const previous = state.permissionMode; + applyGoalSnapshot({ + enabled: true, + status: "active", + text, + previousPermissionMode: previous + }); + if (!state.currentSessionId) { + if (currentSessionNeedsModelSelection()) { + applyGoalSnapshot(null, { permissionMode: previous }); + showError("请先重新选择模型来源和模型"); + return; + } + state.turnSubmitting = true; + updateSendButton(); + els.runStatus.textContent = "启动中"; + let result; + try { + result = await postJson("/api/turns", { + requestId: dashboardRequestId(), + prompt: text, + permissionMode: "fullAccess", + goalMode: true, + goalText: text, + clientPreviousPermissionMode: previous, + clientId: dashboardClientId() + }); + } catch (error) { + result = { ok: false, error: errorMessageOf(error) }; + } finally { + state.turnSubmitting = false; + updateSendButton(); + } + if (!result.ok) { + applyGoalSnapshot(null, { permissionMode: previous }); + if (result.trust) { + state.trust = result.trust; + renderTrustPanel(); + } + showError(result.error ?? "Goal 启动失败"); + els.runStatus.textContent = result.status === 403 ? "待信任" : "失败"; + updateSendButton(); + return; + } + adoptGoalRunResult2(result); + await loadSessions(); return; } - const prompt = els.promptInput.value.trim(); - const attachments = state.attachments.slice(); - if (!prompt && attachments.length === 0) { + await submitGoalAction2("enable", { objective: text }); +} +async function submitGoalAction2(action, extra = {}) { + if (state.goalSubmitting) return; + if (!state.currentSessionId) { + if (action === "disable" || action === "clear") { + applyGoalSnapshot(null, { permissionMode: state.goal.previousPermissionMode || "plan" }); + } return; } - if (!state.trust?.trusted) { - showTrustPanel(); + state.goalSubmitting = true; + const result = await postJson("/api/goal", { + sessionId: state.currentSessionId, + action, + objective: extra.objective, + clientPreviousPermissionMode: state.goal.enabled ? state.goal.previousPermissionMode : state.permissionMode, + permissionMode: state.permissionMode + }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); + state.goalSubmitting = false; + if (!result.ok) { + showError(result.error ?? "Goal 操作失败"); return; } - state.turnSubmitting = true; - updateSendButton(); - els.runStatus.textContent = state.running ? "已排队" : "启动中"; - const turnRequest = stableTurnRequest2(prompt, attachments); - let result; - try { - result = await postJson("/api/turns", { - requestId: turnRequest.id, - prompt, - attachments: attachments.map(attachmentPayload2), - sessionId: state.currentSessionId, - clientId: state.currentSessionId ? void 0 : dashboardClientId(), - permissionMode: state.permissionMode, - goalMode: state.goal.enabled === true, - goalText: state.goal.enabled ? state.goal.text : void 0, - clientPreviousPermissionMode: state.goal.enabled ? state.goal.previousPermissionMode : void 0 - }); - } catch (error) { - result = { ok: false, error: errorMessageOf(error) }; - } finally { - state.turnSubmitting = false; - updateSendButton(); + adoptGoalRunResult2(result); + if (result.running === true) { + await loadSessions(); } - if (!result.ok) { - if (Number.isFinite(Number(result.status))) { - state.turnRequest = null; - } - if (result.trust) { - state.trust = result.trust; - renderTrustPanel(); - } - showError(result.error ?? "任务启动失败"); - els.runStatus.textContent = result.status === 403 ? "待信任" : "失败"; - updateSendButton(); - return; +} +function adoptGoalRunResult2(result) { + applyGoalSnapshot(result.goal ?? state.goal, { permissionMode: result.permission?.mode ?? state.permissionMode }); + if (result.sessionStatus) { + updateSessionStatus(result.sessionStatus); } - state.turnRequest = null; - els.promptInput.value = ""; - resizePromptInput(); - clearAttachments2(); state.queue = result.queue ?? state.queue; - state.running = result.running === true || state.running; - applyGoalSnapshot(result.goal ?? state.goal, { permissionMode: result.permission?.mode ?? state.permissionMode }); - updateSessionStatus(result.sessionStatus); - updateTurnChangeStats2(result.changeStats, { replace: true }); renderQueuePanel(); - if (result.running === true) { - els.runStatus.textContent = "运行中"; - setLiveTitle("正在处理你的任务"); - } - updateSendButton(); const previousSessionId = state.currentSessionId; - state.currentSessionId = result.sessionId ?? null; - rememberCurrentSession(result.sessionId ?? null); - if (previousSessionId !== result.sessionId) { - resetEventReplayState(); - } - rememberEventCursor(result.eventCursor); if (result.sessionId) { + state.currentSessionId = result.sessionId; + rememberCurrentSession(result.sessionId); + if (previousSessionId !== result.sessionId) { + resetEventReplayState(); + } + rememberEventCursor(result.eventCursor); ensureEventsConnected(result.sessionId); } - await loadSessions(); -} -function stableTurnRequest2(prompt, attachments) { - const signature = JSON.stringify({ - prompt, - sessionId: state.currentSessionId, - permissionMode: state.permissionMode, - attachments: attachments.map((item) => [item.id, item.name, item.mimeType, item.size]) - }); - if (state.turnRequest?.signature === signature) { - return state.turnRequest; - } - const request = { id: dashboardRequestId(), signature }; - state.turnRequest = request; - return request; -} -function dashboardRequestId() { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } - if (typeof globalThis.crypto?.getRandomValues === "function") { - const bytes = new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); - return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); + if (result.running === true) { + state.running = true; + els.runStatus.textContent = "运行中"; + setLiveTitle("正在推进 Goal"); } - return `turn-${Date.now()}-${Math.random().toString(36).slice(2)}`; + updateSendButton(); } -function dashboardClientId() { - if (state.dashboardClientId) return state.dashboardClientId; - try { - const stored = sessionStorage.getItem(DASHBOARD_CLIENT_STORAGE_KEY); - if (stored) { - state.dashboardClientId = stored; - return stored; - } - } catch { - } - const clientId = `dashboard-${dashboardRequestId()}`; - state.dashboardClientId = clientId; - try { - sessionStorage.setItem(DASHBOARD_CLIENT_STORAGE_KEY, clientId); - } catch { - } - return clientId; +function showPermissionConfirm2(trigger = document.activeElement) { + const panel = els.permissionConfirmPanel; + if (!panel) return; + panel.classList.remove("hidden"); + panel.innerHTML = ` +
+
启用完全访问?
+
当前会话的本机工具、工作区外文件和网络操作将自动获准,直到你切换权限或离开该会话。
+
+
+ + +
+ `; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + panel.setAttribute("aria-labelledby", "permission-confirm-title"); + panel.setAttribute("tabindex", "-1"); + panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hidePermissionConfirm()); + panel.querySelector("button[data-action='confirm']")?.addEventListener("click", () => { + hidePermissionConfirm({ restoreFocus: false }); + setPermissionMode2("fullAccess"); + els.permissionMode.querySelector("button[data-mode='fullAccess']")?.focus(); + }); + activateModal(panel, { initialFocus: "button[data-action='cancel']", returnFocus: trigger }); } -function statusUrl() { - return `/api/status?clientId=${encodeURIComponent(dashboardClientId())}`; +function hidePermissionConfirm(options = {}) { + const panel = els.permissionConfirmPanel; + if (!panel || panel.classList.contains("hidden")) return; + deactivateModal(panel, options); + panel.classList.add("hidden"); + panel.replaceChildren(); } -async function interruptTurn() { - if (!state.currentSessionId) { - return; - } - els.sendButton.disabled = true; - els.sendButton.textContent = "中断中"; - const result = await postJson("/api/turns/interrupt", { - sessionId: state.currentSessionId, - reason: "user" - }, { timeoutMs: DASHBOARD_INTERRUPT_TIMEOUT_MS }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); - els.sendButton.disabled = false; - if (!result.ok) { - showError(result.error ?? "中断失败"); +function updateContextActions2() { + const noSession = !state.currentSessionId; + const busy = state.running || state.turnSubmitting; + const disabled = noSession || busy; + const hint = noSession ? "请先打开一个空闲会话" : busy ? "任务运行期间不能清空或压缩上下文" : "可管理当前会话上下文"; + for (const button of [els.contextClear, els.contextCompact]) { + if (!button) continue; + button.disabled = disabled; + button.title = hint; } - updateSessionStatus(result.sessionStatus); - updateSendButton(); + if (els.contextActionHint) els.contextActionHint.textContent = hint; } -async function guideTurn2(queueItemId = "") { - const source = guideSource2(queueItemId); - if (!source || !state.currentSessionId || !state.running || state.guideSubmitting) { - return; - } - const clientId = `guide-${Date.now()}-${Math.random().toString(16).slice(2)}`; - state.guideSubmitting = true; - setPendingGuide2({ - clientId, - sessionId: state.currentSessionId, - phase: "registering", - preview: source.preview - }); - hideApproval2(); - hideQuestion2(); - els.runStatus.textContent = "引导中"; - setLiveTitle("正在登记引导"); - const result = await postJson("/api/turns/guide", { - sessionId: state.currentSessionId, - guidance: source.guidance, - queueItemId: source.queueItemId, - permissionMode: state.permissionMode - }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); - state.guideSubmitting = false; - if (!result.ok) { - if (state.pendingGuide?.clientId === clientId) { - clearPendingGuide2(); - } else { - renderQueuePanel(); - } - showError(result.error ?? "引导失败"); - return; - } - els.promptInput.value = ""; - state.queue = result.queue ?? state.queue; - updateSessionStatus(result.sessionStatus); - hideApproval2(); - hideQuestion2(); - setPendingGuide2({ - clientId, - sessionId: state.currentSessionId, - phase: result.stopped ? "stopped" : "registered", - preview: source.preview +function announceStatus2(message) { + if (!els.dashboardLiveRegion || !message) return; + els.dashboardLiveRegion.textContent = ""; + requestAnimationFrame(() => { + els.dashboardLiveRegion.textContent = String(message); }); - syncGuideButton(); } -async function cancelQueuedTurn2(queueItemId) { - if (!queueItemId || !state.currentSessionId || state.queueCancelling.has(queueItemId)) { - return; - } - state.queueCancelling.add(queueItemId); - renderQueuePanel(); - const result = await postJson("/api/turns/queue/cancel", { - sessionId: state.currentSessionId, - queueItemId - }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); - state.queueCancelling.delete(queueItemId); - if (!result.ok) { - showError(result.error ?? "取消排队失败"); - renderQueuePanel(); - return; - } - state.queue = result.queue ?? state.queue.filter((item) => item.id !== queueItemId); - updateSessionStatus(result.sessionStatus); - if (result.item?.kind === "guide" && state.pendingGuide?.phase !== "continuing") { - clearPendingGuide2(); - } else { - renderQueuePanel(); - } - syncGuideButton(); +var FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])" +].join(","); +function modalFocusableElements(modal) { + if (!modal) return []; + return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter((node) => node.getAttribute("aria-hidden") !== "true" && !node.closest("[inert]")); } // src/dashboard/public/transcript.ts @@ -2180,7 +2205,7 @@ function visibleTranscriptRole(role) { } // src/dashboard/public/app-ui3.ts -async function cancelBackgroundSubagent(groupId, taskId) { +async function cancelBackgroundSubagent2(groupId, taskId) { const key = backgroundCancelKey3(groupId, taskId); if (!state.currentSessionId || !key || state.backgroundCancelling.has(key)) { return; @@ -2211,7 +2236,7 @@ async function cancelBackgroundSubagent(groupId, taskId) { } } updateLiveStatus3(); - applyIdleRunStatus2("空闲"); + applyIdleRunStatus("空闲"); } function backgroundCancelKey3(groupId, taskId) { const group = String(groupId ?? "").trim(); @@ -2219,10 +2244,10 @@ function backgroundCancelKey3(groupId, taskId) { return group || task ? `${group || "-"}:${task || "-"}` : ""; } function connectEvents3(sessionId) { - clearEventReconnectTimer(); - closeEventSource(); + clearEventReconnectTimer2(); + closeEventSource2(); state.eventSourceSessionId = sessionId; - setConnectionState(state.eventReconnectAttempt > 0 ? "reconnecting" : "connecting"); + setConnectionState2(state.eventReconnectAttempt > 0 ? "reconnecting" : "connecting"); const params = new URLSearchParams({ sessionId }); if (state.lastEventSequence > 0) { params.set("after", String(state.lastEventSequence)); @@ -2240,7 +2265,7 @@ function connectEvents3(sessionId) { if (state.eventSource !== source) return; clearEventConnectTimer3(); markEventConnectionAlive3(false); - setConnectionState("connected"); + setConnectionState2("connected"); }); source.addEventListener("heartbeat", () => { if (state.eventSource === source) { @@ -2253,7 +2278,7 @@ function connectEvents3(sessionId) { try { payload = JSON.parse(event.data); } catch { - setConnectionState("stale"); + setConnectionState2("stale"); return; } markEventConnectionAlive3(true); @@ -2270,7 +2295,7 @@ function connectEvents3(sessionId) { state.eventSource = null; clearEventStaleTimer3(); if (navigator.onLine === false) { - setConnectionState("offline"); + setConnectionState2("offline"); return; } scheduleEventReconnect3(sessionId); @@ -2282,17 +2307,17 @@ function ensureEventsConnected(sessionId) { } connectEvents3(sessionId); } -function disconnectEvents2() { - clearEventReconnectTimer(); +function disconnectEvents() { + clearEventReconnectTimer2(); clearEventConnectTimer3(); clearEventStaleTimer3(); - closeEventSource(); + closeEventSource2(); state.eventSourceSessionId = null; state.eventReconnectAttempt = 0; state.lastEventAt = 0; - setConnectionState("idle"); + setConnectionState2("idle"); } -function closeEventSource() { +function closeEventSource2() { clearEventConnectTimer3(); clearEventStaleTimer3(); state.eventSource?.close(); @@ -2305,7 +2330,7 @@ function markEventConnectionAlive3(stable = true) { state.lastEventAt = Date.now(); clearEventConnectTimer3(); if (["connecting", "stale", "reconnecting"].includes(state.connectionState)) { - setConnectionState("connected"); + setConnectionState2("connected"); } clearEventStaleTimer3(); const sessionId = state.eventSourceSessionId; @@ -2320,10 +2345,10 @@ function armEventConnectTimer3(source, sessionId) { state.eventSource = null; clearEventStaleTimer3(); if (navigator.onLine === false) { - setConnectionState("offline"); + setConnectionState2("offline"); return; } - setConnectionState("stale"); + setConnectionState2("stale"); scheduleEventReconnect3(sessionId); }, EVENT_CONNECT_TIMEOUT_MS); } @@ -2335,20 +2360,20 @@ function armEventStaleTimer3(sessionId, delay) { armEventStaleTimer3(sessionId, remaining); return; } - setConnectionState("stale"); - closeEventSource(); + setConnectionState2("stale"); + closeEventSource2(); scheduleEventReconnect3(sessionId); }, Math.max(1, Number(delay) || 1)); } function scheduleEventReconnect3(sessionId) { if (!sessionId || state.currentSessionId !== sessionId) return; - clearEventReconnectTimer(); + clearEventReconnectTimer2(); state.eventReconnectAttempt += 1; if (state.eventReconnectAttempt > EVENT_RECONNECT_MAX_ATTEMPTS) { - setConnectionState("offline"); + setConnectionState2("offline"); return; } - setConnectionState("reconnecting"); + setConnectionState2("reconnecting"); const delay = Math.min(15e3, 500 * 2 ** (state.eventReconnectAttempt - 1)); state.eventReconnectTimer = setTimeout(() => { state.eventReconnectTimer = null; @@ -2357,16 +2382,16 @@ function scheduleEventReconnect3(sessionId) { } }, delay); } -function reconnectEventsManually() { - clearEventReconnectTimer(); +function reconnectEventsManually2() { + clearEventReconnectTimer2(); state.eventReconnectAttempt = 0; if (!state.currentSessionId) { - bootstrapDashboard(); + bootstrapDashboard2(); return; } connectEvents3(state.currentSessionId); } -function clearEventReconnectTimer() { +function clearEventReconnectTimer2() { if (state.eventReconnectTimer) { clearTimeout(state.eventReconnectTimer); state.eventReconnectTimer = null; @@ -2384,7 +2409,7 @@ function clearEventStaleTimer3() { state.eventStaleTimer = null; } } -function setConnectionState(next) { +function setConnectionState2(next) { const previous = state.connectionState; state.connectionState = next; if (!els.connectionStatus) return; @@ -2407,9 +2432,9 @@ function setConnectionState(next) { const text = els.connectionStatus.querySelector(".connection-label"); if (text) text.textContent = label; if (next !== previous && ["offline", "unavailable", "error", "reconnecting", "stale"].includes(next)) { - announceStatus(label); + announceStatus2(label); } else if (next === "connected" && ["offline", "unavailable", "error", "reconnecting", "stale"].includes(previous)) { - announceStatus("本地网关已重新连接"); + announceStatus2("本地网关已重新连接"); } } function resetEventReplayState() { @@ -2426,25 +2451,25 @@ function handleDashboardEvent3(event) { if (event.type === "session_disposed" || event.type === "error" && /会话不存在/.test(String(event.message ?? ""))) { state.running = false; state.queue = []; - hideApproval2(); - hideQuestion2(); - clearPendingGuide2(); - resetLiveStatus2(); - disconnectEvents2(); + hideApproval(); + hideQuestion(); + clearPendingGuide(); + resetLiveStatus(); + disconnectEvents(); els.runStatus.textContent = "会话已结束"; renderQueuePanel(); updateSendButton(); - scheduleSessionsRefresh2(0); + scheduleSessionsRefresh(0); return; } hideEmptyState3(); updateSessionStatus(event.sessionStatus); - updateTurnChangeStats2(event.turnChangeStats ?? event.changeStats, { + updateTurnChangeStats(event.turnChangeStats ?? event.changeStats, { replace: event.type === "run_state" || event.type === "files_updated" || Boolean(event.turnChangeStats) }); if (event.type === "user_message") { beginEventTurn3(event); - updateTurnChangeStats2(null, { reset: true }); + updateTurnChangeStats(null, { reset: true }); state.lastAssistantFinalSignature = ""; appendMessage3( "user", @@ -2453,10 +2478,10 @@ function handleDashboardEvent3(event) { event.attachments ); state.running = true; - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); if (event.queuedKind === "guide") { els.runStatus.textContent = "引导中"; - setPendingGuide2({ + setPendingGuide({ sessionId: state.currentSessionId, phase: "continuing", preview: event.text @@ -2477,7 +2502,7 @@ function handleDashboardEvent3(event) { return; } if (event.type === "goal_question_skipped") { - appendActivity2({ + appendActivity({ title: "已跳过需求核对", detail: "Goal 无人值守,未打开核对面板", severity: "info", @@ -2490,7 +2515,7 @@ function handleDashboardEvent3(event) { applyGoalSnapshot(event.goal, { permissionMode: event.permission?.mode }); } else if (event.permission?.mode) { if (!(state.goal.enabled && event.permission.mode !== "fullAccess")) { - setPermissionMode(event.permission.mode); + setPermissionMode2(event.permission.mode); } } if (event.running === true) { @@ -2500,9 +2525,9 @@ function handleDashboardEvent3(event) { } state.running = event.running === true; state.queue = event.queue ?? []; - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); if (state.running && event.current?.kind === "guide") { - setPendingGuide2({ + setPendingGuide({ sessionId: state.currentSessionId, phase: "continuing", preview: event.current.preview ?? state.pendingGuide?.preview ?? "" @@ -2510,9 +2535,9 @@ function handleDashboardEvent3(event) { els.runStatus.textContent = "引导中"; setLiveTitle("正在按引导继续"); } else if (!state.running) { - clearPendingGuide2(); - resetLiveStatus2({ keepBackgroundSubagents: true }); - applyIdleRunStatus2("空闲"); + clearPendingGuide(); + resetLiveStatus({ keepBackgroundSubagents: true }); + applyIdleRunStatus("空闲"); } else { els.runStatus.textContent = "运行中"; renderQueuePanel(); @@ -2522,8 +2547,8 @@ function handleDashboardEvent3(event) { } if (event.type === "guide_queued") { state.queue = event.queue ?? []; - scheduleSessionsRefresh2(); - setPendingGuide2({ + scheduleSessionsRefresh(); + setPendingGuide({ sessionId: state.currentSessionId, phase: "registered", preview: event.guidance ?? event.item?.preview ?? state.pendingGuide?.preview ?? "" @@ -2533,7 +2558,7 @@ function handleDashboardEvent3(event) { } if (event.type === "prompt_queued" || event.type === "queue_updated") { state.queue = event.queue ?? []; - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); syncPendingGuideFromQueue3(); els.runStatus.textContent = state.pendingGuide ? "引导中" : state.running ? "运行中" : "已排队"; return; @@ -2543,7 +2568,7 @@ function handleDashboardEvent3(event) { state.queueCancelling.delete(event.item?.id); updateSessionStatus(event.sessionStatus); if (event.item?.kind === "guide" && state.pendingGuide?.phase !== "continuing") { - clearPendingGuide2(); + clearPendingGuide(); } else { renderQueuePanel(); } @@ -2552,7 +2577,7 @@ function handleDashboardEvent3(event) { } if (event.type === "wakeup_queued") { state.queue = event.queue ?? state.queue; - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); clearBackgroundSubagentStatus3(event.groupId); renderQueuePanel(); els.runStatus.textContent = event.running ? "主控续跑已排队" : "主控接续中"; @@ -2561,24 +2586,24 @@ function handleDashboardEvent3(event) { return; } if (event.type === "background_subagent_snapshot") { - reconcileBackgroundSubagentSnapshot2(event.groups, event.at); + reconcileBackgroundSubagentSnapshot(event.groups, event.at); return; } if (event.type === "background_subagent_cancelled") { clearBackgroundSubagentStatus3(event.groupId || event.taskId); - applyIdleRunStatus2("空闲"); + applyIdleRunStatus("空闲"); return; } if (event.type === "background_terminal_cancelled") { clearBackgroundSubagentStatus3(event.taskId); - applyIdleRunStatus2("空闲"); + applyIdleRunStatus("空闲"); return; } if (event.type === "turn_interrupt_requested") { if (event.reason === "guided") { - hideApproval2(); - hideQuestion2(); - setPendingGuide2({ + hideApproval(); + hideQuestion(); + setPendingGuide({ sessionId: state.currentSessionId, phase: "interrupting", preview: state.pendingGuide?.preview ?? state.queue.find((item) => item.kind === "guide")?.preview ?? "" @@ -2593,7 +2618,7 @@ function handleDashboardEvent3(event) { } if (event.type === "guide_stopped") { state.queue = event.queue ?? []; - setPendingGuide2({ + setPendingGuide({ sessionId: state.currentSessionId, phase: "stopped", preview: event.guidance ?? "" @@ -2603,8 +2628,8 @@ function handleDashboardEvent3(event) { return; } if (event.type === "context_cleared") { - hideContextConfirm2(); - appendActivity2({ + hideContextConfirm(); + appendActivity({ title: "上下文已清空", detail: contextSummaryLine3(event.after), severity: "success", @@ -2644,7 +2669,7 @@ function handleDashboardEvent3(event) { appendAssistantDraft3(event); els.runStatus.textContent = state.pendingGuide ? "引导中" : "运行中"; state.running = true; - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); updateSendButton(); return; } @@ -2662,7 +2687,7 @@ function handleDashboardEvent3(event) { return; } if (event.type === "question_resolved") { - hideQuestion2(); + hideQuestion(); if (event.interrupted && state.pendingGuide) { els.runStatus.textContent = "引导中"; setLiveTitle("引导已接管,等待当前轮次收束"); @@ -2672,13 +2697,13 @@ function handleDashboardEvent3(event) { appendMessage3("user", "你", questionResolutionText3(event)); els.runStatus.textContent = "运行中"; state.running = true; - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); updateSendButton(); setLiveTitle(event.cancelled ? "继续处理需求核对结果" : "继续处理你的确认"); return; } if (event.type === "approval_resolved") { - hideApproval2({ approvalId: event.approvalId }); + hideApproval({ approvalId: event.approvalId }); if (state.pendingApproval) { els.runStatus.textContent = "等待确认"; setLiveTitle("等待权限确认"); @@ -2694,7 +2719,7 @@ function handleDashboardEvent3(event) { } els.runStatus.textContent = event.allowed ? "运行中" : "已拒绝"; if (!event.allowed) { - resetLiveStatus2({ keepBackgroundSubagents: true }); + resetLiveStatus({ keepBackgroundSubagents: true }); } return; } @@ -2708,28 +2733,28 @@ function handleDashboardEvent3(event) { state.lastAssistantFinalSignature = finalSignature; collapseAssistantDrafts3(event.text); collapseCompletedActivities3(); - resetLiveStatus2({ keepBackgroundSubagents: true }); - clearPendingGuide2(); + resetLiveStatus({ keepBackgroundSubagents: true }); + clearPendingGuide(); appendMessage3("assistant", "Ant Code", event.text); state.activeTurnId = ""; els.runStatus.textContent = state.backgroundSubagents.size > 0 ? idleRunStatus3("收尾中") : "收尾中"; updateSendButton(); - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); return; } if (event.type === "files_updated") { state.files = event.files ?? []; - renderFiles2(); + renderFiles(); if (shouldKeepGuideFeedback3()) { els.runStatus.textContent = "引导中"; updateLiveStatus3(); } else { - resetLiveStatus2({ keepBackgroundSubagents: true }); - applyIdleRunStatus2("完成"); - clearPendingGuide2(); + resetLiveStatus({ keepBackgroundSubagents: true }); + applyIdleRunStatus("完成"); + clearPendingGuide(); } updateSendButton(); - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); loadSessions(); return; } @@ -2740,12 +2765,12 @@ function handleDashboardEvent3(event) { updateSendButton(); return; } - resetLiveStatus2({ keepBackgroundSubagents: true }); - clearPendingGuide2(); + resetLiveStatus({ keepBackgroundSubagents: true }); + clearPendingGuide(); showError(event.message ?? "任务失败"); - applyIdleRunStatus2("失败"); + applyIdleRunStatus("失败"); updateSendButton(); - scheduleSessionsRefresh2(); + scheduleSessionsRefresh(); } } function shouldSkipDashboardEvent3(event) { @@ -2772,7 +2797,7 @@ function beginEventTurn3(event) { } state.activeTurnId = turnId; } -function renderTranscriptMessages2(messages, options = {}) { +function renderTranscriptMessages(messages, options = {}) { const nodes = []; const list = Array.isArray(messages) ? messages : []; for (const message of list) { @@ -2801,7 +2826,7 @@ function renderTranscriptMessages2(messages, options = {}) { trimTranscriptWindow3({ direction: "append", preserveAnchor: !state.transcriptFollowing }); return nodes; } -function renderSessionFailure2(failure2) { +function renderSessionFailure(failure2) { if (!isPlainObject(failure2) || failure2.kind !== "gateway") { return; } @@ -2812,14 +2837,14 @@ function renderSessionFailure2(failure2) { Number.isInteger(failure2.attempts) && Number(failure2.attempts) > 1 ? `已尝试 ${failure2.attempts} 次` : null, failure2.code ? String(failure2.code) : null ].filter(Boolean); - appendActivity2({ + appendActivity({ title: "模型请求失败", detail: details.join(" · "), severity: "danger", collapsed: false }); } -function setTranscriptPaging2(page = null) { +function setTranscriptPaging(page = null) { const record = isPlainObject(page) ? page : {}; state.transcriptPaging = { cursor: record.cursor ?? record.nextCursor ?? null, @@ -2862,7 +2887,7 @@ function transcriptFirstContentNode3() { if (node === els.emptyState) node = node.nextSibling; return node; } -function handleTranscriptScroll() { +function handleTranscriptScroll2() { syncTranscriptFollowState3(); if (els.transcript.scrollTop > 180) { return; @@ -2904,7 +2929,7 @@ async function loadOlderTranscript3() { state.transcriptPaging.hasMore = page?.hasMore === true; const pageTotal = Number(page?.total); state.transcriptPaging.total = Number.isFinite(pageTotal) ? pageTotal : state.transcriptPaging.total; - renderTranscriptMessages2(result.transcript ?? [], { prepend: true }); + renderTranscriptMessages(result.transcript ?? [], { prepend: true }); state.transcriptPaging.error = ""; renderTranscriptHistoryStatus3(); if (!restoreTranscriptNodeAnchor3(anchor, anchorTop)) { @@ -2920,7 +2945,7 @@ function renderWorkflowPanel3(workflow, summary = null) { if (!record || !todos.length && !steps.length) { state.workflow = null; state.workflowExpanded = false; - renderWorkflowStrip(); + renderWorkflowStrip2(); return; } hideEmptyState3(); @@ -2954,10 +2979,10 @@ function renderWorkflowPanel3(workflow, summary = null) { ${todos.length ? workflowSection3("Todo", todos) : ""} ${steps.length ? workflowSection3("Plan", steps) : ""} `; - renderWorkflowStrip(); - scrollTranscript2(); + renderWorkflowStrip2(); + scrollTranscript(); } -function renderWorkflowStrip() { +function renderWorkflowStrip2() { if (!state.workflow || !state.workflow.todos?.length && !state.workflow.plan?.steps?.length) { els.workflowStrip.classList.add("hidden"); els.workflowStrip.innerHTML = ""; @@ -3030,8 +3055,8 @@ function appendMessage3(kind, label, text, attachments = []) { const wasAtBottom = isTranscriptNearBottom3(); const node = createMessageNode3(kind, label, text, attachments); appendTranscriptNode3(node); - scrollTranscript2({ onlyIfNearBottom: true, wasAtBottom }); - if (kind === "assistant") announceStatus("收到新的助手回复"); + scrollTranscript({ onlyIfNearBottom: true, wasAtBottom }); + if (kind === "assistant") announceStatus2("收到新的助手回复"); } function createMessageNode3(kind, label, text, attachments = []) { hideEmptyState3(); @@ -3043,9 +3068,9 @@ function createMessageNode3(kind, label, text, attachments = []) {
`; const body = node.querySelector(".message-body"); - if (kind === "assistant") renderFinalAssistantBody(body, text); + if (kind === "assistant") renderFinalAssistantBody2(body, text); else { - renderMessageText(body, text ?? "", { markdown: false }); + renderMessageText2(body, text ?? "", { markdown: false }); appendMessageAttachmentChips(body, attachments); } return node; @@ -3101,7 +3126,7 @@ function trimTranscriptWindow3(options = {}) { const willAddMarker = !state.transcriptWindow[markerKey]; const limit = Math.max(1, TRANSCRIPT_DOM_LIMIT - (willAddMarker ? 1 : 0)); const nodes = Array.from(els.transcript.children ?? []); - const toRemove = selectTranscriptNodesToRemove(nodes, limit, direction, isProtectedTranscriptNode3); + const toRemove = selectTranscriptNodesToRemove2(nodes, limit, direction, isProtectedTranscriptNode3); if (toRemove.length === 0) return 0; const anchor = options.preserveAnchor ? captureTranscriptViewportAnchor3(new Set(toRemove)) : null; for (const node of toRemove) node.remove(); @@ -3158,7 +3183,7 @@ function renderTranscriptWindowMarker3(side) { node.setAttribute("aria-live", "off"); node.innerHTML = ``; node.querySelector("button").addEventListener("click", () => { - if (state.currentSessionId) openSession2(state.currentSessionId); + if (state.currentSessionId) openSession(state.currentSessionId); }); state.transcriptWindow[nodeKey] = node; } @@ -3241,22 +3266,22 @@ function appendAssistantDraft3(event) { } draft.text += String(event.text ?? ""); scheduleDraftRender4(draft); - scrollTranscript2({ onlyIfNearBottom: true, wasAtBottom }); + scrollTranscript({ onlyIfNearBottom: true, wasAtBottom }); setLiveTitle("正在生成回复"); } function scheduleDraftRender4(draft) { - scheduleAnimationFrameOnce(draft, "renderFrame", () => renderAssistantDraft4(draft)); + scheduleAnimationFrameOnce2(draft, "renderFrame", () => renderAssistantDraft4(draft)); } function renderAssistantDraft4(draft, options = {}) { const wasAtBottom = isTranscriptNearBottom3(); - if (options.force) cancelScheduledAnimationFrame(draft, "renderFrame"); + if (options.force) cancelScheduledAnimationFrame2(draft, "renderFrame"); if (draft.renderedLength === String(draft.text ?? "").length) { return; } - draft.renderedLength = appendPlainDraftDelta(draft.body, draft.text, draft.renderedLength); - scrollTranscript2({ onlyIfNearBottom: true, wasAtBottom }); + draft.renderedLength = appendPlainDraftDelta2(draft.body, draft.text, draft.renderedLength); + scrollTranscript({ onlyIfNearBottom: true, wasAtBottom }); } -function appendActivity2(activity) { +function appendActivity(activity) { const wasAtBottom = isTranscriptNearBottom3(); const node = document.createElement("div"); node.className = `activity-card ${activity.severity ?? "info"}${activity.collapsed ? "" : " open"}`; @@ -3274,8 +3299,8 @@ function appendActivity2(activity) { toggle.setAttribute("aria-expanded", String(node.classList.contains("open"))); }); appendTranscriptNode3(node); - scrollTranscript2({ onlyIfNearBottom: true, wasAtBottom }); - if (activity.severity === "danger" || activity.severity === "warning") announceStatus(activity.title); + scrollTranscript({ onlyIfNearBottom: true, wasAtBottom }); + if (activity.severity === "danger" || activity.severity === "warning") announceStatus2(activity.title); } function appendContextBoundary3(event = {}) { const wasAtBottom = isTranscriptNearBottom3(); @@ -3289,7 +3314,7 @@ function appendContextBoundary3(event = {}) { `; appendTranscriptNode3(node); - scrollTranscript2({ onlyIfNearBottom: true, wasAtBottom }); + scrollTranscript({ onlyIfNearBottom: true, wasAtBottom }); } function contextBoundaryText4(event = {}) { const detail = String(event.detail ?? "").trim(); @@ -3361,7 +3386,7 @@ function clearBackgroundSubagentStatus3(groupId) { } updateLiveStatus3(); } -function reconcileBackgroundSubagentSnapshot2(groups, snapshotAt) { +function reconcileBackgroundSubagentSnapshot(groups, snapshotAt) { const visibleGroups = Array.isArray(groups) ? groups.filter(backgroundSubagentVisible4) : []; const nextKeys = /* @__PURE__ */ new Set(); const snapshotTime = Date.parse(String(snapshotAt ?? "")) || Date.now(); @@ -3416,7 +3441,7 @@ function reconcileBackgroundSubagentSnapshot2(groups, snapshotAt) { state.liveStatusExpanded = false; } updateLiveStatus3(); - applyIdleRunStatus2("完成"); + applyIdleRunStatus("完成"); } function backgroundSubagentDisplayStatus4(activity, previous = {}) { if (activity.rawType === "subagent_group_wakeup" || activity.wakePromptQueued === true) { @@ -3449,7 +3474,7 @@ function setLiveTitle(title) { state.liveTitle = title; updateLiveStatus3(); } -function toggleLiveStatusDetails() { +function toggleLiveStatusDetails2() { if (state.pendingApproval) { showApproval3(state.pendingApproval); return; @@ -3636,7 +3661,7 @@ function backgroundSubagentMeta4(item) { function backgroundSubagentCancellable4(item) { return item.cancellable !== false && (item.status === "starting" || item.status === "running" || item.status === "stale" || item.status === "lost"); } -function resetLiveStatus2(options = {}) { +function resetLiveStatus(options = {}) { state.running = false; state.liveTitle = ""; state.liveActivities.clear(); @@ -3686,7 +3711,7 @@ function idleRunStatus3(fallback) { } return fallback; } -function applyIdleRunStatus2(fallback = "空闲") { +function applyIdleRunStatus(fallback = "空闲") { if (state.running) { return; } @@ -3702,7 +3727,7 @@ function updateRunStatusForBackground4(fallback = "空闲") { } function updateSessionStatus(status) { if (!status || typeof status !== "object") { - renderComposerStatus(); + renderComposerStatus2(); return; } state.sessionStatus = { @@ -3710,17 +3735,17 @@ function updateSessionStatus(status) { ...status, context: status.context ?? state.sessionStatus?.context ?? null }; - state.models = markCurrentModel2(state.models, state.sessionStatus.model); - renderComposerStatus(); + state.models = markCurrentModel(state.models, state.sessionStatus.model); + renderComposerStatus2(); renderSettingsView4(); } -function updateTurnChangeStats2(stats, options = {}) { +function updateTurnChangeStats(stats, options = {}) { if (options.reset) { - resetTurnChangeStats2(); + resetTurnChangeStats(); return; } if (!stats || typeof stats !== "object") { - renderComposerStatus(); + renderComposerStatus2(); return; } const normalized = normalizeChangeStats4(stats); @@ -3736,11 +3761,11 @@ function updateTurnChangeStats2(stats, options = {}) { approximate: state.turnChangeStats.approximate || normalized.approximate }; } - renderComposerStatus(); + renderComposerStatus2(); } -function resetTurnChangeStats2() { +function resetTurnChangeStats() { state.turnChangeStats = { additions: 0, deletions: 0, files: 0, redacted: false, truncated: false, approximate: false }; - renderComposerStatus(); + renderComposerStatus2(); } function normalizeChangeStats4(stats) { return { @@ -3752,8 +3777,8 @@ function normalizeChangeStats4(stats) { approximate: stats.approximate === true }; } -function renderComposerStatus() { - setConnectionState(state.connectionState); +function renderComposerStatus2() { + setConnectionState2(state.connectionState); if (!els.modelStatus || !els.contextStatus || !els.changeStatus) { return; } @@ -3808,7 +3833,7 @@ function modelStatusHtml4(modelInfo, fallbackModel) { 思考 `; @@ -3827,7 +3852,7 @@ function unresolvedModelStatusHtml4() { `; } -function handleModelStatusActivate(event) { +function handleModelStatusActivate2(event) { const toggle = eventTargetOf(event).closest("#model-status-toggle"); if (!toggle || toggle.disabled || event.type === "click" && event.detail === 0) { return; @@ -3836,7 +3861,7 @@ function handleModelStatusActivate(event) { event.stopPropagation(); toggleModelPanel4(); } -function handleModelStatusKeydown(event) { +function handleModelStatusKeydown2(event) { const toggle = eventTargetOf(event).closest("#model-status-toggle"); if (!toggle || toggle.disabled) { return; @@ -3855,14 +3880,14 @@ function toggleModelPanel4() { } state.modelPanelOpen = !state.modelPanelOpen; renderModelPanel4(); - renderComposerStatus(); + renderComposerStatus2(); } function hideModelPanel() { state.modelPanelOpen = false; renderModelPanel4(); - renderComposerStatus(); + renderComposerStatus2(); } -function showSettingsWorkspace() { +function showSettingsWorkspace2() { const opening = !state.settingsOpen; if (opening) { state.settingsReturnFocus = document.activeElement; @@ -3896,12 +3921,12 @@ async function refreshSettingsConfiguration4() { state.gatewayProfiles = normalizeGatewayProfiles(result.gatewayProfiles); state.agentModelTiers = normalizeAgentModelTiers(result.agentModelTiers); state.visionAgent = normalizeVisionAgent(result.visionAgent); - state.settings = normalizeDashboardSettings(result.settings); + state.settings = normalizeDashboardSettings2(result.settings); state.applyAgentDefaultsOnSwitch = state.settings.agents.syncModelTiersOnSwitch; updateConfigRevisions(result); state.settingsFeedback = null; renderSettingsView4(); - renderComposerStatus(); + renderComposerStatus2(); } catch (error) { if (!isAbortError(error) && isCurrentScopedRequest(request) && state.settingsOpen) { state.settingsFeedback = { tone: "error", message: error instanceof Error ? error.message : "读取设置失败" }; @@ -3928,8 +3953,8 @@ function hideSettingsWorkspace(options = {}) { requestAnimationFrame(() => returnFocus?.focus?.({ preventScroll: true })); } function showModelConfigPanel4(modelId = "", profileId = "", intent = "") { - cancelScopedRequest2("gateway-probe"); - cancelScopedRequest2("model-capabilities-probe"); + cancelScopedRequest("gateway-probe"); + cancelScopedRequest("model-capabilities-probe"); const returnFocus = document.activeElement; const requestedProfileId = String(profileId ?? "").trim(); const requestedModelId = String(modelId ?? "").trim(); @@ -3964,12 +3989,12 @@ function showModelConfigPanel4(modelId = "", profileId = "", intent = "") { returnFocus }); } -function hideModelConfigPanel2() { +function hideModelConfigPanel() { if (state.modelConfigSaving) { return; } - cancelScopedRequest2("gateway-probe"); - cancelScopedRequest2("model-capabilities-probe"); + cancelScopedRequest("gateway-probe"); + cancelScopedRequest("model-capabilities-probe"); state.modelConfigDialogGeneration += 1; state.gatewayProbeRunning = false; state.modelCapabilityProbeRunning = false; @@ -4007,13 +4032,13 @@ function renderModelPanel4() { @@ -4032,10 +4057,10 @@ function modelCapabilityLabels5(model) { if (model?.thinking) labels.push("thinking"); return labels.length > 0 ? labels : ["文本"]; } -function handleModelPanelClick(event) { +function handleModelPanelClick2(event) { event.stopPropagation(); } -async function handleModelPanelChange(event) { +async function handleModelPanelChange2(event) { event.stopPropagation(); const select = eventElement(event)?.closest("select[data-action]"); if (!(select instanceof HTMLSelectElement) || select.disabled) return; @@ -4110,7 +4135,7 @@ function modelSettingsHtml5() {
${models.length} 个 - +
@@ -4120,7 +4145,7 @@ function modelSettingsHtml5() { `; } function transcriptSettingsHtml5() { - const settings = state.settings ?? normalizeDashboardSettings(null); + const settings = state.settings ?? normalizeDashboardSettings2(null); const transcript = settings.transcript; const managed = settings.managed; return ` @@ -4170,10 +4195,10 @@ function transcriptRetentionOptionsHtml5(current) { if (current !== null && Number.isInteger(current) && !options.some(([value]) => value === currentValue)) { options.splice(options.length - 1, 0, [currentValue, `${current} 天(当前)`]); } - return options.map(([value, label]) => ``).join(""); + return options.map(([value, label]) => ``).join(""); } function networkSettingsHtml5() { - const settings = state.settings ?? normalizeDashboardSettings(null); + const settings = state.settings ?? normalizeDashboardSettings2(null); const network = settings.network; return `
@@ -4201,10 +4226,10 @@ function networkSettingsHtml5() { } function networkModeOptionHtml5(value, label, network) { const allowed = Array.isArray(network.allowedModes) && network.allowedModes.includes(value); - return ``; + return ``; } function agentSettingsHtml5() { - const settings = state.settings ?? normalizeDashboardSettings(null); + const settings = state.settings ?? normalizeDashboardSettings2(null); const agents = settings.agents; return ` @@ -4213,7 +4238,7 @@ function agentSettingsHtml5() {
${settingsToggleHtml5("backgroundWakeupEnabled", "允许后台子智能体", agents.backgroundWakeupEnabled)} ${settingsToggleHtml5("backgroundByDefault", "模型子任务默认后台运行", agents.backgroundByDefault)} @@ -4226,7 +4251,7 @@ function agentSettingsHtml5() {
@@ -4235,7 +4260,7 @@ function agentSettingsHtml5() { `; } function reliabilitySettingsHtml5() { - const settings = state.settings ?? normalizeDashboardSettings(null); + const settings = state.settings ?? normalizeDashboardSettings2(null); const reliability = settings.reliability; const managed = settings.managed; return ` @@ -4245,15 +4270,15 @@ function reliabilitySettingsHtml5() {
@@ -4262,10 +4287,10 @@ function reliabilitySettingsHtml5() { `; } function settingsSectionHeading5(kicker, title, id) { - return `
${escapeHtml(kicker)}

${escapeHtml(title)}

`; + return `
${escapeHtml(kicker)}

${escapeHtml(title)}

`; } function settingsToggleHtml5(name, label, checked, detail = "") { - return ``; + return ``; } function managedFieldHtml5(managed) { return managed ? `环境变量管理` : ""; @@ -4283,7 +4308,7 @@ function settingsFormActions5() { } function settingsFeedbackHtml5() { if (!state.settingsFeedback?.message) return ""; - return `
${escapeHtml(state.settingsFeedback.message)}
`; + return `
${escapeHtml(state.settingsFeedback.message)}
`; } function settingsGatewayProfileHtml5(profile) { const confirmingDelete = state.deleteConfirmGatewayProfileId === profile.id; @@ -4299,11 +4324,11 @@ function settingsGatewayProfileHtml5(profile) { ${escapeHtml(profile.ready === false ? `${protocolDisplayName5(profile.gatewayProtocol)} · 配置不完整` : `${protocolDisplayName5(profile.gatewayProtocol)} · ${profile.apiKeyConfigured ? "Key 已配置" : "无 Key"} · ${count} 模型`)}
- - - - - + + + + +
${confirmingDelete ? `
再次点击确认删除;删除当前来源后不会自动切换到其他来源。
` : ""}
@@ -4354,15 +4379,15 @@ function settingsModelHtml5(model, profile, modelCount) {
- - - + + +
${confirmingDelete ? `
${escapeHtml(confirmCopy)}
` : ""} `; } -function handleSettingsRailClick(event) { +function handleSettingsRailClick2(event) { const button = eventTargetOf(event).closest("button[data-settings-section]"); if (!button || state.settingsSaving) return; state.settingsSection = button.dataset.settingsSection || "models"; @@ -4376,7 +4401,7 @@ function initializeSettingsFormTracking5(form) { } form.dataset.changedFields = "[]"; } -function handleSettingsFormChange(event) { +function handleSettingsFormChange2(event) { const form = eventTargetOf(event).closest?.("form[data-settings-form]"); if (!form || state.settingsSaving) return; form.dataset.changedFields = JSON.stringify(changedSettingsFields5(form)); @@ -4422,7 +4447,7 @@ function renderSettingsFeedbackInPlace5() { feedback.setAttribute("role", state.settingsFeedback.tone === "error" ? "alert" : "status"); feedback.textContent = state.settingsFeedback.message; } -async function saveSettingsConfig(event) { +async function saveSettingsConfig2(event) { const form = eventTargetOf(event).closest("form[data-settings-form]"); if (!(form instanceof HTMLFormElement)) return; event.preventDefault(); @@ -4468,7 +4493,7 @@ async function saveSettingsConfig(event) { changedFields }); if (!result.ok) throw new Error(result.error ?? "设置保存失败"); - state.settings = normalizeDashboardSettings(result.settings); + state.settings = normalizeDashboardSettings2(result.settings); state.applyAgentDefaultsOnSwitch = state.settings.agents.syncModelTiersOnSwitch; state.goal.maxAutoContinues = state.settings.agents.goalMaxAutoContinues; state.settingsFeedback = { tone: "success", message: "设置已保存" }; @@ -4476,16 +4501,16 @@ async function saveSettingsConfig(event) { if (els.settingsBack) els.settingsBack.disabled = false; if (result.sessionStatus) updateSessionStatus(result.sessionStatus); else renderSettingsView4(); - announceStatus("设置已保存"); + announceStatus2("设置已保存"); } catch (error) { state.settingsFeedback = { tone: "error", message: error instanceof Error ? error.message : "设置保存失败" }; state.settingsSaving = false; setSettingsFormSaving5(form, false); renderSettingsFeedbackInPlace5(); - announceStatus(state.settingsFeedback.message); + announceStatus2(state.settingsFeedback.message); } } -async function handleSettingsClick(event) { +async function handleSettingsClick2(event) { const action = eventTargetOf(event).closest("[data-action]"); if (!action) return; if (action.dataset.action === "select-default-scope") { @@ -4550,14 +4575,14 @@ function agentModelPickerHtml5(name, label, value) { const inputId = `agent-model-${name}`; const selectId = `${inputId}-select`; return ` -
- - - ${modelId ? `` : ""} + ${modelId ? `` : ""} - + ${modelId ? "已保存,等待目录核对" : "未指定"}
`; @@ -4666,7 +4691,7 @@ function renderModelConfigPanel4() {
@@ -4691,15 +4716,15 @@ function renderModelConfigPanel4() {
@@ -4712,7 +4737,7 @@ function renderModelConfigPanel4() { ${agentModelPickerHtml5("visionAgentModel", "vision", visionAgentModel)}
-
+
思考强度
@@ -4724,7 +4749,7 @@ function renderModelConfigPanel4() {
${effortChoices.map((effort) => ` `).join("")} @@ -4733,7 +4758,7 @@ function renderModelConfigPanel4() { 模型默认强度
@@ -4761,10 +4786,10 @@ function renderModelConfigPanel4() { renderGatewayProbeResult5(); renderReasoningCapabilityStatus5(); } -async function handleModelConfigPanelClick(event) { +async function handleModelConfigPanelClick2(event) { const action = eventTargetOf(event).closest("button[data-action]"); if (action?.dataset.action === "close-model-config") { - hideModelConfigPanel2(); + hideModelConfigPanel(); } else if (action?.dataset.action === "probe-gateway") { await probeGateway5(action.closest("form")); } else if (action?.dataset.action === "select-probed-model") { @@ -4777,7 +4802,7 @@ async function handleModelConfigPanelClick(event) { applyPendingReasoningCapabilities5(action.closest("form")); } } -function handleModelConfigInput(event) { +function handleModelConfigInput2(event) { const target = event.target; const form = target?.closest?.("form"); if (!target || !form) return; @@ -4802,7 +4827,7 @@ function handleModelConfigInput(event) { } } } -function handleModelConfigChange(event) { +function handleModelConfigChange2(event) { if (eventTargetOf(event).matches("select[data-agent-model-select]")) { handleAgentModelSelection5(event.target); } else if (eventTargetOf(event).matches(".agent-model-manual-input")) { @@ -4835,8 +4860,8 @@ function handleModelConfigChange(event) { } function markModelConfigCredentialChanged5(form) { state.modelConfigCredentialRevision += 1; - cancelScopedRequest2("gateway-probe"); - cancelScopedRequest2("model-capabilities-probe"); + cancelScopedRequest("gateway-probe"); + cancelScopedRequest("model-capabilities-probe"); state.gatewayProbeRunning = false; state.modelCapabilityProbeRunning = false; state.modelCapabilityProbeError = ""; @@ -4854,8 +4879,8 @@ function markModelConfigEndpointChanged5(form, options = {}) { syncAgentModelPickersForEndpoint6(form, { retainedCatalogModelIds: options.retainedAgentModelIds }); - cancelScopedRequest2("gateway-probe"); - cancelScopedRequest2("model-capabilities-probe"); + cancelScopedRequest("gateway-probe"); + cancelScopedRequest("model-capabilities-probe"); state.gatewayProbeRunning = false; state.modelCapabilityProbeRunning = false; state.modelCapabilityProbeError = ""; @@ -4876,7 +4901,7 @@ function markModelConfigEndpointChanged5(form, options = {}) { renderReasoningCapabilityStatus5(); } function handleModelConfigModelIdChanged5(form) { - cancelScopedRequest2("model-capabilities-probe"); + cancelScopedRequest("model-capabilities-probe"); state.modelCapabilityProbeRunning = false; state.modelCapabilityProbeError = ""; state.modelCapabilityDiscoveryToken = ""; @@ -4935,7 +4960,7 @@ function syncReasoningDefaultOptions5(form) { id: input.value, label: input.dataset.effortLabel || input.value })); - select.innerHTML = `${efforts.map((effort) => ``).join("")}`; + select.innerHTML = `${efforts.map((effort) => ``).join("")}`; select.disabled = efforts.length === 0; select.value = efforts.some((effort) => effort.id === previous) ? previous : ""; } @@ -5283,10 +5308,10 @@ function renderGatewayProbeResult5(options = {}) { target.innerHTML = ` ${escapeHtml(result.message || "连接成功")} ${result.modelsUrl ? `模型列表 ${escapeHtml(result.modelsUrl)}` : ""} - ${suggestedGatewayUrl && suggestedGatewayUrl !== currentGatewayUrl ? `` : ""} + ${suggestedGatewayUrl && suggestedGatewayUrl !== currentGatewayUrl ? `` : ""} ${(result.models ?? []).length > 0 ? `
- ${(result.models ?? []).map((model) => ``).join("")} + ${(result.models ?? []).map((model) => ``).join("")}
` : `未返回模型列表`} `; @@ -5508,7 +5533,7 @@ function ensureReasoningEffortOptions6(form, efforts) { continue; } const label = document.createElement("label"); - label.innerHTML = ``; + label.innerHTML = ``; const created = label.querySelector("input"); if (created) created.dataset.effortLabel = effort.label; const span = label.querySelector("span"); @@ -5727,7 +5752,7 @@ function manualAgentModelIds6(form) { } // src/dashboard/public/app-ui7.ts -async function saveModelConfig(event) { +async function saveModelConfig2(event) { event.preventDefault(); if (state.modelConfigSaving) { return; @@ -5797,10 +5822,10 @@ async function saveModelConfig(event) { updateSessionStatus(result.sessionStatus); if (!state.currentSessionId) rememberNewTaskModelState(); state.modelConfigSaving = false; - hideModelConfigPanel2(); + hideModelConfigPanel(); hideModelPanel(); renderSettingsView4(); - renderComposerStatus(); + renderComposerStatus2(); if (payload.switchToModel) { const defaultModelId = String(result.modelId ?? payload.modelId ?? "").trim(); showNotice7("模型配置已保存", `${modelSaveTargetLabel7(payload.saveTarget)},默认模型已设为 ${modelDisplayName7(defaultModelId, String(payload.label || payload.modelId || ""))}`); @@ -5813,8 +5838,8 @@ async function saveModelConfig(event) { setModelConfigFormSaving6(form, false); const message = error instanceof Error ? error.message : "保存模型配置失败"; renderModelConfigFailure6(form, message); - announceStatus(message); - renderComposerStatus(); + announceStatus2(message); + renderComposerStatus2(); } } async function saveDefaultModelSelection5(modelId, providerId, scope) { @@ -5843,11 +5868,11 @@ async function saveDefaultModelSelection5(modelId, providerId, scope) { if (result.gatewayConfig) mergeGatewayConfig7(result.gatewayConfig); if (Array.isArray(result.gatewayProfiles)) state.gatewayProfiles = normalizeGatewayProfiles(result.gatewayProfiles); state.settingsFeedback = { tone: "success", message: scope === "global" ? "全局默认模型已保存" : "当前项目默认模型已保存" }; - announceStatus(state.settingsFeedback.message); + announceStatus2(state.settingsFeedback.message); } catch (error) { if (isPlainObject(error) && error.configConflict) await refreshConfigRevisionsAfterConflict7(); state.settingsFeedback = { tone: "error", message: error instanceof Error ? error.message : "保存默认模型失败" }; - announceStatus(state.settingsFeedback.message); + announceStatus2(state.settingsFeedback.message); } finally { state.modelSwitching = false; renderSettingsView4(); @@ -5860,7 +5885,7 @@ async function switchModel5(modelId, options = {}) { const requestSessionId = state.currentSessionId; const requestClientId = requestSessionId ? void 0 : dashboardClientId(); state.modelSwitching = true; - renderComposerStatus(); + renderComposerStatus2(); renderModelPanel4(); renderSettingsView4(); try { @@ -5893,20 +5918,20 @@ async function switchModel5(modelId, options = {}) { if (!state.currentSessionId) rememberNewTaskModelState(); if (options.keepPanelOpen) renderModelPanel4(); else hideModelPanel(); - announceStatus(`模型已切换为 ${modelDisplayName7(modelId)}`); + announceStatus2(`模型已切换为 ${modelDisplayName7(modelId)}`); } catch (error) { if (state.currentSessionId === requestSessionId) { showError(errorMessageOf(error) || "切换模型失败"); } } finally { state.modelSwitching = false; - renderComposerStatus(); + renderComposerStatus2(); renderModelPanel4(); renderSettingsView4(); updateSendButton(); } } -async function handleReasoningEffortChange(event) { +async function handleReasoningEffortChange2(event) { const select = eventTargetOf(event).closest("#reasoning-effort-select"); if (!select || select.disabled) return; await switchReasoningEffort7(select.value); @@ -5923,7 +5948,7 @@ async function switchReasoningEffort7(reasoningEffort) { const requestSessionId = state.currentSessionId; const requestClientId = requestSessionId ? void 0 : dashboardClientId(); state.reasoningEffortSwitching = true; - renderComposerStatus(); + renderComposerStatus2(); try { const normalized = normalizedReasoningEffort6(reasoningEffort); const result = await postJson("/api/reasoning-effort", { @@ -5942,14 +5967,14 @@ async function switchReasoningEffort7(reasoningEffort) { if (Array.isArray(result.gatewayProfiles)) state.gatewayProfiles = normalizeGatewayProfiles(result.gatewayProfiles); updateSessionStatus(result.sessionStatus ?? { reasoningEffort: normalized || null }); if (!state.currentSessionId) rememberNewTaskModelState(); - announceStatus(`思考强度已设为 ${normalized ? reasoningEffortLabel7(normalized) : "默认"}`); + announceStatus2(`思考强度已设为 ${normalized ? reasoningEffortLabel7(normalized) : "默认"}`); } catch (error) { if (state.currentSessionId === requestSessionId) { showError(errorMessageOf(error) || "调整思考强度失败"); } } finally { state.reasoningEffortSwitching = false; - renderComposerStatus(); + renderComposerStatus2(); renderSettingsView4(); } } @@ -5968,7 +5993,7 @@ async function deleteGatewayProfile5(profileId) { const profile = gatewayProfileById4(profileId); const scope = configScope5(profile?.saveTarget) || "project"; try { - const result = await deleteJson2(`/api/gateway-profile/${encodeURIComponent(profileId)}`, { + const result = await deleteJson(`/api/gateway-profile/${encodeURIComponent(profileId)}`, { sessionId: requestSessionId, providerId: profileId, saveTarget: scope, @@ -6001,7 +6026,7 @@ async function deleteGatewayProfile5(profileId) { } finally { state.deletingGatewayProfileId = ""; renderSettingsView4(); - renderComposerStatus(); + renderComposerStatus2(); } } async function deleteModel5(modelId, options = {}) { @@ -6017,11 +6042,11 @@ async function deleteModel5(modelId, options = {}) { } state.deletingModelKey = modelKey; renderSettingsView4(); - renderComposerStatus(); + renderComposerStatus2(); const requestSessionId = state.currentSessionId; const scope = configScope5(options.saveTarget || currentGatewayProfile5()?.saveTarget) || "project"; try { - const result = await deleteJson2(`/api/model-config/${encodeURIComponent(modelId)}`, { + const result = await deleteJson(`/api/model-config/${encodeURIComponent(modelId)}`, { sessionId: requestSessionId, profileId: providerId, providerId, @@ -6055,7 +6080,7 @@ async function deleteModel5(modelId, options = {}) { } finally { state.deletingModelKey = ""; renderSettingsView4(); - renderComposerStatus(); + renderComposerStatus2(); } } function updateConfigRevisions(payload) { @@ -6155,7 +6180,7 @@ function normalizeGatewayConfig(value) { } }; } -function normalizeDashboardSettings(value) { +function normalizeDashboardSettings2(value) { const record = isPlainObject(value) ? value : {}; const transcript = isPlainObject(record.transcript) ? record.transcript : {}; const network = isPlainObject(record.network) ? record.network : {}; @@ -6448,7 +6473,7 @@ function modelSourceLabel4(model) { return gatewayUrl || "未配置来源"; } } -function markCurrentModel2(models, currentModel) { +function markCurrentModel(models, currentModel) { const current = String(currentModel ?? ""); return normalizeModels(models).map((model) => ({ ...model, @@ -6635,11 +6660,11 @@ function collapseCompletedActivities3() { } appendTranscriptNode3(node); state.completedActivities = []; - scrollTranscript2({ onlyIfNearBottom: true }); + scrollTranscript({ onlyIfNearBottom: true }); } function clearAssistantDrafts3() { for (const draft of state.assistantDrafts.values()) { - cancelScheduledAnimationFrame(draft, "renderFrame"); + cancelScheduledAnimationFrame2(draft, "renderFrame"); draft.node?.remove(); } state.assistantDrafts.clear(); @@ -6679,7 +6704,7 @@ function collapseAssistantDrafts3(finalText = "") { list?.append(item); } appendTranscriptNode3(node); - scrollTranscript2({ onlyIfNearBottom: true }); + scrollTranscript({ onlyIfNearBottom: true }); } function isMeaningfulCompletedActivity4(activity) { if (activity.toolName === "agent_run") { @@ -6749,14 +6774,14 @@ function renderApprovalPanel(approval) { `; els.approvalPanel.querySelectorAll("button[data-action]").forEach((button) => { - button.addEventListener("click", () => resolveApproval2(button.dataset.action)); + button.addEventListener("click", () => resolveApproval(button.dataset.action)); }); activateModal(els.approvalPanel, { initialFocus: "button[data-action='allow-once']" }); revealInteractionPanel8(els.approvalPanel, "button[data-action]"); updateLiveStatus3(); - announceStatus(`需要确认 ${approval.toolName ?? "工具"} 权限`); + announceStatus2(`需要确认 ${approval.toolName ?? "工具"} 权限`); } -async function resolveApproval2(action) { +async function resolveApproval(action) { const approval = state.pendingApproval; if (!approval || state.approvalSubmitting) return; state.approvalSubmitting = true; @@ -6773,10 +6798,10 @@ async function resolveApproval2(action) { showError(result.error ?? "权限确认提交失败"); return; } - hideApproval2({ approvalId: approval.id }); + hideApproval({ approvalId: approval.id }); els.runStatus.textContent = state.pendingApproval ? "等待确认" : state.running ? "运行中" : "处理中"; } -function hideApproval2(options = {}) { +function hideApproval(options = {}) { const approvalId = options.approvalId == null || options.approvalId === "" ? "" : String(options.approvalId); if (approvalId) { if (state.pendingApproval?.id && state.pendingApproval.id !== approvalId) { @@ -6849,7 +6874,7 @@ function showQuestion3(question) { initialFocus: ".question-input, button[data-choice], button[data-action='submit']" }); revealInteractionPanel8(els.questionPanel, ".question-input, button[data-choice], button[data-action='submit']"); - announceStatus("需要核对任务需求"); + announceStatus2("需要核对任务需求"); } function revealInteractionPanel8(panel, focusSelector) { if (!panel || panel.classList.contains("hidden")) { @@ -6884,7 +6909,7 @@ function renderQuestionPanel8() { `; - panel.querySelector("button[data-action='return-to-question']")?.addEventListener("click", returnToQuestion2); + panel.querySelector("button[data-action='return-to-question']")?.addEventListener("click", returnToQuestion); return; } panel.removeAttribute("aria-label"); @@ -6915,7 +6940,7 @@ function renderQuestionPanel8() { }); panel.querySelector("button[data-action='review-conversation']")?.addEventListener("click", reviewQuestionConversation8); panel.querySelector("button[data-action='submit']")?.addEventListener("click", submitQuestion8); - panel.querySelector("button[data-action='cancel']")?.addEventListener("click", cancelQuestion2); + panel.querySelector("button[data-action='cancel']")?.addEventListener("click", cancelQuestion); const input = ( /** @type {HTMLTextAreaElement | null} */ panel.querySelector(".question-input") @@ -6938,9 +6963,9 @@ function reviewQuestionConversation8() { renderQuestionPanel8(); activateQuestionReviewBackground8(); els.transcript?.focus?.({ preventScroll: true }); - announceStatus("需求确认已收起,可以查看对话;按 Esc 返回确认"); + announceStatus2("需求确认已收起,可以查看对话;按 Esc 返回确认"); } -function returnToQuestion2() { +function returnToQuestion() { if (!state.pendingQuestion || !state.questionReviewMode) return; deactivateQuestionReviewBackground8(); state.questionReviewMode = false; @@ -6949,7 +6974,7 @@ function returnToQuestion2() { initialFocus: ".question-input, button[data-choice], button[data-action='submit']" }); revealInteractionPanel8(els.questionPanel, ".question-input, button[data-choice], button[data-action='submit']"); - announceStatus("已返回需求确认"); + announceStatus2("已返回需求确认"); } function activateQuestionReviewBackground8() { deactivateQuestionReviewBackground8(); @@ -6957,7 +6982,7 @@ function activateQuestionReviewBackground8() { const panel = els.questionPanel; if (!transcript || !panel) return; const transcriptStage = transcript.closest?.(".transcript-stage") ?? transcript; - const entries = collectModalBackground2(panel).filter((entry) => entry.node !== transcriptStage && !entry.node.contains?.(transcriptStage)); + const entries = collectModalBackground(panel).filter((entry) => entry.node !== transcriptStage && !entry.node.contains?.(transcriptStage)); state.questionReviewInertEntries = entries; for (const entry of entries) entry.node.inert = true; } @@ -7013,7 +7038,7 @@ async function submitQuestion8() { }).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); finishQuestionSubmission8(question, buttons, result); } -async function cancelQuestion2() { +async function cancelQuestion() { const question = state.pendingQuestion; if (!question || state.questionSubmitting) return; state.questionSubmitting = true; @@ -7037,10 +7062,10 @@ function finishQuestionSubmission8(question, buttons, result) { showError(result.error ?? "需求确认提交失败"); return; } - hideQuestion2(); + hideQuestion(); els.runStatus.textContent = state.running ? "运行中" : "处理中"; } -function hideQuestion2() { +function hideQuestion() { deactivateModal(els.questionPanel); deactivateQuestionReviewBackground8(); state.questionReviewMode = false; @@ -7111,12 +7136,12 @@ function renderQueuePanel() { ${guideFeedback} ${queueItems ? `
${queueItems}${hiddenQueueCount ? `
还有 ${hiddenQueueCount} 条排队内容未展开
` : ""}
` : ""} `; - els.queuePanel.querySelector("#guide-button")?.addEventListener("click", () => guideTurn2()); + els.queuePanel.querySelector("#guide-button")?.addEventListener("click", () => guideTurn()); els.queuePanel.querySelectorAll("[data-guide-queue-id]").forEach((button) => { button.addEventListener("click", () => guideTurnFromQueue8(button.dataset.guideQueueId)); }); els.queuePanel.querySelectorAll("[data-cancel-queue-id]").forEach((button) => { - button.addEventListener("click", () => cancelQueuedTurn2(button.dataset.cancelQueueId)); + button.addEventListener("click", () => cancelQueuedTurn(button.dataset.cancelQueueId)); }); syncGuideButton(); } @@ -7134,7 +7159,7 @@ function renderQueueItem8(item, index) { `; } -function setPendingGuide2(guide) { +function setPendingGuide(guide) { state.pendingGuide = { ...state.pendingGuide ?? {}, ...guide, @@ -7143,7 +7168,7 @@ function setPendingGuide2(guide) { renderQueuePanel(); updateLiveStatus3(); } -function clearPendingGuide2() { +function clearPendingGuide() { state.pendingGuide = null; renderQueuePanel(); } @@ -7151,7 +7176,7 @@ function syncPendingGuideFromQueue3() { if (state.pendingGuide) { const stillQueued = state.queue.some((item) => item.kind === "guide"); if (!stillQueued && !state.running && state.pendingGuide.phase === "registered") { - clearPendingGuide2(); + clearPendingGuide(); return; } renderQueuePanel(); @@ -7159,7 +7184,7 @@ function syncPendingGuideFromQueue3() { } const queuedGuide = state.queue.find((item) => item.kind === "guide"); if (queuedGuide) { - setPendingGuide2({ + setPendingGuide({ sessionId: state.currentSessionId, phase: "registered", preview: queuedGuide.preview ?? "" @@ -7212,7 +7237,7 @@ function guideCopy8(phase) { detail: "下一轮会优先按这条引导继续。" }; } -function guideSource2(queueItemId = "") { +function guideSource(queueItemId = "") { const queuedItem = queueItemId ? state.queue.find((item) => item.id === queueItemId && item.kind === "prompt" && !state.queueCancelling.has(item.id)) : null; if (queueItemId && !queuedItem) { return null; @@ -7234,7 +7259,7 @@ function guideTurnFromQueue8(queueItemId) { if (!queueItemId || state.guideSubmitting) { return; } - guideTurn2(queueItemId); + guideTurn(queueItemId); } function guideButtonText8() { if (state.guideSubmitting || state.pendingGuide?.phase === "registering") return "登记中"; @@ -7244,7 +7269,7 @@ function guideButtonText8() { return "引导对话"; } function guideButtonDisabled8() { - return !state.running || state.guideSubmitting || !guideSource2(); + return !state.running || state.guideSubmitting || !guideSource(); } function guideButtonVisible8() { return state.running && (Boolean(els.promptInput.value.trim()) || state.guideSubmitting || state.pendingGuide?.phase === "registering"); @@ -7263,7 +7288,7 @@ function isInterruptError3(message) { return /aborted|abort|interrupted|中断|取消/i.test(String(message ?? "")); } function updateSendButton() { - updateContextActions(); + updateContextActions2(); els.sendButton.setAttribute("aria-busy", String(state.turnSubmitting)); if (state.turnSubmitting) { els.sendButton.textContent = "提交中"; @@ -7291,7 +7316,7 @@ function updateSendButton() { } els.sendButton.disabled = false; } -function showContextConfirm(action) { +function showContextConfirm2(action) { const isClear = action === "clear"; const panel = els.contextPanel; if (!panel) return; @@ -7311,11 +7336,11 @@ function showContextConfirm(action) { panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-labelledby", "context-confirm-title"); panel.setAttribute("tabindex", "-1"); - panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hideContextConfirm2()); + panel.querySelector("button[data-action='cancel']")?.addEventListener("click", () => hideContextConfirm()); panel.querySelector(`button[data-action='${action}']`)?.addEventListener("click", () => runContextAction8(action)); activateModal(panel, { initialFocus: "button[data-action='cancel']" }); } -function hideContextConfirm2() { +function hideContextConfirm() { const panel = els.contextPanel; if (!panel) return; deactivateModal(panel); @@ -7335,12 +7360,12 @@ async function runContextAction8(action) { }, contextActionRequestOptions8(action)).catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) })); if (!result.ok) { showError(result.error ?? "上下文操作失败"); - hideContextConfirm2(); + hideContextConfirm(); return; } state.currentSessionId = result.sessionId ?? state.currentSessionId; - hideContextConfirm2(); - appendActivity2({ + hideContextConfirm(); + appendActivity({ title: action === "clear" ? "上下文已清空" : "上下文已压缩", detail: action === "clear" ? contextSummaryLine3(result.after) : compactResultLine8(result.result), severity: "success", @@ -7383,9 +7408,9 @@ function questionResolutionText3(event) { } return parts.length > 0 ? `需求核对:${parts.join(";")}` : "已确认需求核对"; } -async function showShutdownPanel() { +async function showShutdownPanel2() { const version = ++state.shutdownStatusVersion; - cancelScopedRequest2("shutdown"); + cancelScopedRequest("shutdown"); const request = beginScopedRequest("shutdown"); state.shutdownActivity = null; els.shutdownCopy.textContent = "正在检查主任务、队列和后台任务,请稍候。"; @@ -7393,7 +7418,7 @@ async function showShutdownPanel() { els.shutdownConfirm.textContent = "检查中"; els.shutdownPanel.classList.remove("hidden"); activateModal(els.shutdownPanel, { initialFocus: "#shutdown-cancel" }); - announceStatus("需要确认是否关闭 Dashboard"); + announceStatus2("需要确认是否关闭 Dashboard"); const result = await getJson("/api/lifecycle/status", { signal: request.signal, timeoutMs: DASHBOARD_LIFECYCLE_TIMEOUT_MS @@ -7413,7 +7438,7 @@ async function showShutdownPanel() { els.shutdownCopy.textContent = `无法确认当前活动状态:${detail}。可以取消任务并强制关闭,或返回继续处理。`; els.shutdownConfirm.disabled = false; els.shutdownConfirm.textContent = "强制关闭"; - announceStatus("Dashboard 活动检查超时,可强制关闭"); + announceStatus2("Dashboard 活动检查超时,可强制关闭"); return; } state.shutdownActivity = normalizeLifecycleActivity8(result.activity); @@ -7421,15 +7446,15 @@ async function showShutdownPanel() { } function hideShutdownPanel() { state.shutdownStatusVersion += 1; - cancelScopedRequest2("shutdown"); + cancelScopedRequest("shutdown"); deactivateModal(els.shutdownPanel); els.shutdownPanel.classList.add("hidden"); els.shutdownConfirm.disabled = false; els.shutdownConfirm.textContent = "确认关闭"; } -async function shutdownDashboard() { +async function shutdownDashboard2() { if (!state.shutdownActivity) return; - cancelScopedRequest2("shutdown"); + cancelScopedRequest("shutdown"); const request = beginScopedRequest("shutdown"); els.shutdownConfirm.disabled = true; els.shutdownConfirm.textContent = "正在关闭"; @@ -7453,11 +7478,11 @@ async function shutdownDashboard() { els.shutdownConfirm.disabled = false; els.shutdownConfirm.textContent = state.shutdownActivity.uncertain ? "强制关闭" : state.shutdownActivity.total > 0 ? "重试取消并关闭" : "重试关闭"; els.runStatus.textContent = "关闭失败"; - announceStatus("Dashboard 关闭失败,页面仍可继续使用"); + announceStatus2("Dashboard 关闭失败,页面仍可继续使用"); els.shutdownConfirm.focus({ preventScroll: true }); return; } - disconnectEvents2(); + disconnectEvents(); deactivateModal(els.shutdownPanel, { restoreFocus: false }); els.shutdownPanel.classList.add("hidden"); hideSettingsWorkspace({ restoreFocus: false }); @@ -7475,7 +7500,7 @@ async function shutdownDashboard() { `; lockClosedDashboard8(); - announceStatus("Dashboard 已关闭"); + announceStatus2("Dashboard 已关闭"); } // src/dashboard/public/markdown.ts @@ -8160,7 +8185,7 @@ function renderShutdownActivity8() { els.shutdownConfirm.disabled = false; els.shutdownConfirm.textContent = activity.total > 0 ? "取消任务并关闭" : "确认关闭"; } -function renderFiles2() { +function renderFiles() { els.fileList.innerHTML = ""; if (state.files.length === 0) { const empty = document.createElement("div"); @@ -8251,7 +8276,7 @@ async function openFile9(filePath) { article.className = "markdown-document markdown-body"; article.tabIndex = 0; article.setAttribute("aria-label", `${file.name} Markdown 内容`); - renderMessageText(article, file.content ?? "", { markdown: true, basePath: parentDirectory9(file.relativePath) }); + renderMessageText2(article, file.content ?? "", { markdown: true, basePath: parentDirectory9(file.relativePath) }); els.previewBody.replaceChildren(article); } else if (file.kind === "data") { els.previewBody.classList.add("document-preview-body"); @@ -8259,7 +8284,7 @@ async function openFile9(filePath) { article.className = "markdown-document markdown-body"; article.tabIndex = 0; article.setAttribute("aria-label", `${file.name} 数据预览`); - renderMessageText(article, fencedDataForFile9(file), { markdown: true }); + renderMessageText2(article, fencedDataForFile9(file), { markdown: true }); els.previewBody.replaceChildren(article); } else { els.previewBody.classList.add("document-preview-body"); @@ -8267,7 +8292,7 @@ async function openFile9(filePath) { } } function localOpenButtonHtml(relativePath) { - return ``; + return ``; } function handleLocalFileOpenClick(event) { const button = eventTargetOf(event).closest("[data-open-local]"); @@ -8507,7 +8532,7 @@ function renderSheetCellHtml9(line) { } return `
${escapeHtml(match[1])}
${escapeHtml(match[2])}
`; } -function resetPreview2(message = "任务产物会显示在这里") { +function resetPreview(message = "任务产物会显示在这里") { els.previewBody.className = "preview-body"; els.previewBody.innerHTML = `
${escapeHtml(message)}
`; } @@ -8606,7 +8631,7 @@ function hideLightbox() { state.lightboxIndex = 0; state.tableLightboxSheetIndex = 0; } -function setPermissionMode(mode) { +function setPermissionMode2(mode) { if (!mode) return; if (state.goal?.enabled && mode !== "fullAccess") { mode = "fullAccess"; @@ -8620,10 +8645,10 @@ function setPermissionMode(mode) { }); document.body.classList.toggle("full-access-active", mode === "fullAccess"); els.modeDescription.textContent = state.goal?.enabled ? MODE_DESCRIPTIONS.goal : MODE_DESCRIPTIONS[mode] ?? MODE_DESCRIPTIONS.plan; - updateContextActions(); - renderGoalControls(); + updateContextActions2(); + renderGoalControls2(); } -function clearTranscript2() { +function clearTranscript() { cancelTranscriptAnimationFrames8(); resetTranscriptWindow4(); els.transcript.innerHTML = ""; @@ -8633,7 +8658,7 @@ function clearTranscript2() { state.workflow = null; state.workflowNode = null; state.workflowExpanded = false; - renderWorkflowStrip(); + renderWorkflowStrip2(); state.assistantDrafts.clear(); state.transcriptPaging = { cursor: null, @@ -8651,12 +8676,12 @@ function clearTranscript2() { } function cancelTranscriptAnimationFrames8() { clearAssistantDraftTimers9(); - cancelScheduledAnimationFrame(state, "transcriptScrollFrame"); + cancelScheduledAnimationFrame2(state, "transcriptScrollFrame"); state.transcriptScrollForce = false; } function clearAssistantDraftTimers9() { for (const draft of state.assistantDrafts.values()) { - cancelScheduledAnimationFrame(draft, "renderFrame"); + cancelScheduledAnimationFrame2(draft, "renderFrame"); } } function hideEmptyState3() { @@ -8664,7 +8689,7 @@ function hideEmptyState3() { } function showError(message) { hideEmptyState3(); - appendActivity2({ + appendActivity({ title: "发生错误", detail: message ?? "", severity: "danger", @@ -8673,22 +8698,22 @@ function showError(message) { } function showNotice7(message, detail = "本地配置已更新") { hideEmptyState3(); - appendActivity2({ + appendActivity({ title: message, detail: detail == null ? "本地配置已更新" : String(detail), severity: "info", collapsed: false }); } -function renderBootstrapLoading() { +function renderBootstrapLoading2() { els.projectPath.textContent = "正在连接"; els.runStatus.textContent = "连接中"; els.sendButton.disabled = true; - setConnectionState("connecting"); + setConnectionState2("connecting"); } -function renderBootstrapFailure(error) { +function renderBootstrapFailure2(error) { const failure2 = bootstrapFailurePresentation9(error, navigator.onLine !== false); - setConnectionState(failure2.connectionState); + setConnectionState2(failure2.connectionState); els.projectPath.textContent = failure2.projectLabel; els.runStatus.textContent = "初始化失败"; els.sendButton.disabled = true; @@ -8706,11 +8731,11 @@ function renderBootstrapFailure(error) { if (typeof window.location?.reload === "function") { window.location.reload(); } else { - bootstrapDashboard(); + bootstrapDashboard2(); } }); } -function dashboardPayloadError(payload, fallback) { +function dashboardPayloadError2(payload, fallback) { const error = new Error(String(payload?.error ?? fallback)); for (const key of ["code", "status", "requestId", "configPath"]) { if (payload?.[key] !== void 0) Object.defineProperty(error, key, { value: payload[key] }); @@ -8748,13 +8773,13 @@ function bootstrapFailurePresentation9(error, online = true) { message: diagnosticMessage }; } -function clearBootstrapStatus() { +function clearBootstrapStatus2() { if (state.connectionState === "connecting" && !state.currentSessionId) { - setConnectionState("idle"); + setConnectionState2("idle"); } } -function scrollTranscript2(options = {}) { - if (!shouldFollowTranscript({ +function scrollTranscript(options = {}) { + if (!shouldFollowTranscript2({ force: options.force, following: state.transcriptFollowing, onlyIfNearBottom: options.onlyIfNearBottom, @@ -8769,7 +8794,7 @@ function scrollTranscript2(options = {}) { state.newReplyAvailable = false; state.transcriptScrollForce = state.transcriptScrollForce || options.force === true; updateTranscriptJump9(); - scheduleAnimationFrameOnce(state, "transcriptScrollFrame", () => { + scheduleAnimationFrameOnce2(state, "transcriptScrollFrame", () => { const force = state.transcriptScrollForce; state.transcriptScrollForce = false; if (!force && !state.transcriptFollowing) { @@ -8790,10 +8815,10 @@ function syncTranscriptFollowState3() { if (nearBottom) state.newReplyAvailable = false; updateTranscriptJump9(); } -function followTranscript() { +function followTranscript2() { state.transcriptFollowing = true; state.newReplyAvailable = false; - scrollTranscript2({ force: true }); + scrollTranscript({ force: true }); } function updateTranscriptJump9() { if (!els.transcriptJump) return; @@ -8803,7 +8828,7 @@ function updateTranscriptJump9() { els.transcriptJump.setAttribute("aria-label", state.newReplyAvailable ? "有新回复,回到底部" : "回到底部"); } function beginScopedRequest(scope, key = "") { - cancelScopedRequest2(scope); + cancelScopedRequest(scope); const controller = new AbortController(); const request = { scope, key, controller, signal: controller.signal }; state.requestScopes.set(scope, request); @@ -8817,7 +8842,7 @@ function finishScopedRequest(request) { state.requestScopes.delete(request.scope); } } -function cancelScopedRequest2(scope) { +function cancelScopedRequest(scope) { const request = state.requestScopes.get(scope); if (!request) return; state.requestScopes.delete(scope); @@ -8847,7 +8872,7 @@ async function postJson(url, body, options = {}) { signal: options.signal }, options); } -async function deleteJson2(url, body = {}, options = {}) { +async function deleteJson(url, body = {}, options = {}) { return dashboardFetch9(url, { method: "DELETE", credentials: "same-origin", @@ -9041,7 +9066,7 @@ function imageAttachmentLine9(item) { ].filter(Boolean); return `[图片附件:${parts.join(" · ")}]`; } -function renderMessageText(node, text, options = {}) { +function renderMessageText2(node, text, options = {}) { if (!node) return; node.classList.toggle("markdown-body", options.markdown === true); const html = options.markdown ? renderMarkdown(text ?? "", { basePath: options.basePath, lightweight: options.lightweight === true }) : escapeHtml(text ?? ""); @@ -9709,10 +9734,10 @@ function formatBytes9(value) { function escapeHtml(value) { return String(value ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } -function escapeAttribute2(value) { +function escapeAttribute(value) { return escapeHtml(value); } -function formatTime2(value) { +function formatTime(value) { if (!value) return ""; const date = new Date(typeof value === "number" || value instanceof Date ? value : String(value)); if (Number.isNaN(date.getTime())) return ""; @@ -9739,7 +9764,7 @@ function formatRelativeTime4(value) { } // src/dashboard/public/app.ts -await init(); +await init2(); export { CURRENT_SESSION_STORAGE_KEY, DASHBOARD_API_VERSION, @@ -9771,30 +9796,30 @@ export { activateModal, activateQuestionReviewBackground8 as activateQuestionReviewBackground, addAttachmentFiles, - adoptGoalRunResult, + adoptGoalRunResult2 as adoptGoalRunResult, agentModelPickerHtml5 as agentModelPickerHtml, agentModelTiersSummary5 as agentModelTiersSummary, agentSettingsHtml5 as agentSettingsHtml, - announceStatus, + announceStatus2 as announceStatus, apiFileUrl10 as apiFileUrl, - appendActivity2 as appendActivity, + appendActivity, appendAgentModelOptions6 as appendAgentModelOptions, appendAssistantDraft3 as appendAssistantDraft, appendContextBoundary3 as appendContextBoundary, appendMessage3 as appendMessage, appendMessageAttachmentChips, - appendPlainDraftDelta, + appendPlainDraftDelta2 as appendPlainDraftDelta, appendTranscriptNode3 as appendTranscriptNode, applyGatewayDiscoveredModel6 as applyGatewayDiscoveredModel, applyGoalSnapshot, - applyIdleRunStatus2 as applyIdleRunStatus, + applyIdleRunStatus, applyPendingReasoningCapabilities5 as applyPendingReasoningCapabilities, applyProbedModel5 as applyProbedModel, applyReasoningCapabilityCandidate6 as applyReasoningCapabilityCandidate, applySuggestedGatewayUrl5 as applySuggestedGatewayUrl, armEventConnectTimer3 as armEventConnectTimer, armEventStaleTimer3 as armEventStaleTimer, - attachmentPayload2 as attachmentPayload, + attachmentPayload, backgroundCancelKey3 as backgroundCancelKey, backgroundSubagentCancellable4 as backgroundSubagentCancellable, backgroundSubagentCompactLabel4 as backgroundSubagentCompactLabel, @@ -9804,45 +9829,45 @@ export { backgroundSubagentTitle4 as backgroundSubagentTitle, backgroundSubagentVisible4 as backgroundSubagentVisible, beginEventTurn3 as beginEventTurn, - beginPreviewResize, + beginPreviewResize2 as beginPreviewResize, beginScopedRequest, - bindEvents, + bindEvents2 as bindEvents, bindRichContent3 as bindRichContent, bindTableLightboxControls9 as bindTableLightboxControls, - bootstrapDashboard, + bootstrapDashboard2 as bootstrapDashboard, bootstrapFailurePresentation9 as bootstrapFailurePresentation, - cancelBackgroundSubagent, - cancelQuestion2 as cancelQuestion, - cancelQueuedTurn2 as cancelQueuedTurn, - cancelScheduledAnimationFrame, - cancelScopedRequest2 as cancelScopedRequest, + cancelBackgroundSubagent2 as cancelBackgroundSubagent, + cancelQuestion, + cancelQueuedTurn, + cancelScheduledAnimationFrame2 as cancelScheduledAnimationFrame, + cancelScopedRequest, cancelTranscriptAnimationFrames8 as cancelTranscriptAnimationFrames, canonicalSettingsField5 as canonicalSettingsField, captureTranscriptViewportAnchor3 as captureTranscriptViewportAnchor, changedSettingsFields5 as changedSettingsFields, - clampedPreviewWidth, + clampedPreviewWidth2 as clampedPreviewWidth, classifyComposerFile, clearAssistantDraftTimers9 as clearAssistantDraftTimers, clearAssistantDrafts3 as clearAssistantDrafts, - clearAttachments2 as clearAttachments, + clearAttachments, clearBackgroundSubagentStatus3 as clearBackgroundSubagentStatus, - clearBootstrapStatus, + clearBootstrapStatus2 as clearBootstrapStatus, clearEventConnectTimer3 as clearEventConnectTimer, - clearEventReconnectTimer, + clearEventReconnectTimer2 as clearEventReconnectTimer, clearEventStaleTimer3 as clearEventStaleTimer, clearModelConfigFailure6 as clearModelConfigFailure, - clearPendingGuide2 as clearPendingGuide, + clearPendingGuide, clearPermissionWaitActivity, clearReasoningCapabilityControls6 as clearReasoningCapabilityControls, - clearTranscript2 as clearTranscript, - closeActiveModal2 as closeActiveModal, - closeEventSource, + clearTranscript, + closeActiveModal, + closeEventSource2 as closeEventSource, collapseAssistantDrafts3 as collapseAssistantDrafts, collapseCompletedActivities3 as collapseCompletedActivities, - collectModalBackground2 as collectModalBackground, + collectModalBackground, columnLabel9 as columnLabel, compactResultLine8 as compactResultLine, - composerHeightFor, + composerHeightFor2 as composerHeightFor, configMutationMetadata6 as configMutationMetadata, configRevisionConflictMessage7 as configRevisionConflictMessage, configScope5 as configScope, @@ -9853,7 +9878,7 @@ export { contextBoundaryText4 as contextBoundaryText, contextSummaryLine3 as contextSummaryLine, copyCodeBlock10 as copyCodeBlock, - copySessionId2 as copySessionId, + copySessionId, createMessageNode3 as createMessageNode, currentGatewayCatalogModels6 as currentGatewayCatalogModels, currentGatewayProbeResult6 as currentGatewayProbeResult, @@ -9867,47 +9892,47 @@ export { dashboardCsrfToken9 as dashboardCsrfToken, dashboardFetch9 as dashboardFetch, dashboardJsonHeaders9 as dashboardJsonHeaders, - dashboardPayloadError, + dashboardPayloadError2 as dashboardPayloadError, dashboardRequestId, dataLanguageForExtension9 as dataLanguageForExtension, deactivateModal, deactivateQuestionReviewBackground8 as deactivateQuestionReviewBackground, - defaultGoalMaxAutoContinues, + defaultGoalMaxAutoContinues2 as defaultGoalMaxAutoContinues, deleteGatewayProfile5 as deleteGatewayProfile, - deleteJson2 as deleteJson, + deleteJson, deleteModel5 as deleteModel, - deleteSession2 as deleteSession, - disconnectEvents2 as disconnectEvents, + deleteSession, + disconnectEvents, documentAttachmentLine, documentChipLabel, els, emptyBackgroundSubagent, - emptyGoalSnapshot, + emptyGoalSnapshot2 as emptyGoalSnapshot, emptySessionStatus, - enableGoalWithObjective, + enableGoalWithObjective2 as enableGoalWithObjective, ensureEventsConnected, ensureReasoningEffortOptions6 as ensureReasoningEffortOptions, environmentGatewayDefaultNote5 as environmentGatewayDefaultNote, errorMessageOf, - escapeAttribute2 as escapeAttribute, + escapeAttribute, escapeHtml, eventElement, eventTargetOf, fencedDataForFile9 as fencedDataForFile, filePreviewUrl9 as filePreviewUrl, - finishPreviewResize, + finishPreviewResize2 as finishPreviewResize, finishQuestionSubmission8 as finishQuestionSubmission, finishScopedRequest, firstFiniteNumber8 as firstFiniteNumber, firstVisionModelId5 as firstVisionModelId, - focusModalInitialTarget2 as focusModalInitialTarget, + focusModalInitialTarget, focusTrapTarget, - followTranscript, + followTranscript2 as followTranscript, formatBytes9 as formatBytes, formatContextUsage4 as formatContextUsage, formatNumber9 as formatNumber, formatRelativeTime4 as formatRelativeTime, - formatTime2 as formatTime, + formatTime, formatTokenCount5 as formatTokenCount, gatewayCredentialAction6 as gatewayCredentialAction, gatewayProfileById4 as gatewayProfileById, @@ -9922,8 +9947,8 @@ export { guideButtonText8 as guideButtonText, guideButtonVisible8 as guideButtonVisible, guideCopy8 as guideCopy, - guideSource2 as guideSource, - guideTurn2 as guideTurn, + guideSource, + guideTurn, guideTurnFromQueue8 as guideTurnFromQueue, handleActivity3 as handleActivity, handleAgentModelSelection5 as handleAgentModelSelection, @@ -9931,41 +9956,42 @@ export { handleDashboardEvent3 as handleDashboardEvent, handleGlobalKeydown, handleLocalFileOpenClick, - handleModelConfigChange, - handleModelConfigInput, + handleModelConfigChange2 as handleModelConfigChange, + handleModelConfigInput2 as handleModelConfigInput, handleModelConfigModelIdChanged5 as handleModelConfigModelIdChanged, - handleModelConfigPanelClick, - handleModelPanelChange, - handleModelPanelClick, - handleModelStatusActivate, - handleModelStatusKeydown, - handlePermissionModeKeydown, - handlePreviewResizeKeydown, - handleReasoningEffortChange, - handleResponsiveFileNavigation, - handleSessionAction2 as handleSessionAction, - handleSettingsClick, - handleSettingsFormChange, - handleSettingsRailClick, - handleTranscriptScroll, + handleModelConfigPanelClick2 as handleModelConfigPanelClick, + handleModelPanelChange2 as handleModelPanelChange, + handleModelPanelClick2 as handleModelPanelClick, + handleModelStatusActivate2 as handleModelStatusActivate, + handleModelStatusKeydown2 as handleModelStatusKeydown, + handlePermissionModeKeydown2 as handlePermissionModeKeydown, + handlePreviewResizeKeydown2 as handlePreviewResizeKeydown, + handleReasoningEffortChange2 as handleReasoningEffortChange, + handleResponsiveFileNavigation2 as handleResponsiveFileNavigation, + handleSessionAction, + handleSettingsClick2 as handleSettingsClick, + handleSettingsFormChange2 as handleSettingsFormChange, + handleSettingsRailClick2 as handleSettingsRailClick, + handleTranscriptScroll2 as handleTranscriptScroll, hasAgentModelTiers7 as hasAgentModelTiers, - hideApproval2 as hideApproval, - hideContextConfirm2 as hideContextConfirm, + hideApproval, + hideContextConfirm, hideEmptyState3 as hideEmptyState, hideGoalConfirm, hideGoalTextPanel, hideLightbox, - hideModelConfigPanel2 as hideModelConfigPanel, + hideModelConfigPanel, hideModelPanel, hidePermissionConfirm, - hideQuestion2 as hideQuestion, + hideQuestion, hideSettingsWorkspace, hideShutdownPanel, idleRunStatus3 as idleRunStatus, imageAttachmentLine9 as imageAttachmentLine, + imageMimeFromName, imagePreviewUrl10 as imagePreviewUrl, - init, - initialSessionId2 as initialSessionId, + init2 as init, + initialSessionId, initializeAgentModelPickerSnapshot5 as initializeAgentModelPickerSnapshot, initializeSettingsFormTracking5 as initializeSettingsFormTracking, interruptTurn, @@ -9984,7 +10010,7 @@ export { isSafeInlineBitmapUrl10 as isSafeInlineBitmapUrl, isTranscriptNearBottom3 as isTranscriptNearBottom, isWorkspaceRelativeToBase11 as isWorkspaceRelativeToBase, - latestBackgroundSessionId2 as latestBackgroundSessionId, + latestBackgroundSessionId, lifecycleActivitySummary8 as lifecycleActivitySummary, linkifyFileTextNodes9 as linkifyFileTextNodes, liveStatusTitle4 as liveStatusTitle, @@ -9996,7 +10022,7 @@ export { lockClosedDashboard8 as lockClosedDashboard, managedFieldHtml5 as managedFieldHtml, manualAgentModelIds6 as manualAgentModelIds, - markCurrentModel2 as markCurrentModel, + markCurrentModel, markEventConnectionAlive3 as markEventConnectionAlive, markModelConfigCredentialChanged5 as markModelConfigCredentialChanged, markModelConfigEndpointChanged5 as markModelConfigEndpointChanged, @@ -10026,7 +10052,7 @@ export { normalizeChangeStats4 as normalizeChangeStats, normalizeComparableText3 as normalizeComparableText, normalizeConfigSource7 as normalizeConfigSource, - normalizeDashboardSettings, + normalizeDashboardSettings2 as normalizeDashboardSettings, normalizeFileReferencePath10 as normalizeFileReferencePath, normalizeGatewayConfig, normalizeGatewayProbeModels6 as normalizeGatewayProbeModels, @@ -10042,18 +10068,18 @@ export { normalizeVisionAgent, normalizeWorkflowStatus3 as normalizeWorkflowStatus, normalizedReasoningEffort6 as normalizedReasoningEffort, - normalizedResponsiveView, - observeRunStatus, + normalizedResponsiveView2 as normalizedResponsiveView, + observeRunStatus2 as observeRunStatus, officePreviewBodyHtml9 as officePreviewBodyHtml, officePreviewMeta9 as officePreviewMeta, openFile9 as openFile, openLocalFile, - openSession2 as openSession, + openSession, parentDirectory9 as parentDirectory, - permissionIndexForKey, + permissionIndexForKey2 as permissionIndexForKey, postJson, previewText8 as previewText, - previewWidthBounds, + previewWidthBounds2 as previewWidthBounds, primaryLiveActivity4 as primaryLiveActivity, probeGateway5 as probeGateway, probeModelCapabilities5 as probeModelCapabilities, @@ -10063,7 +10089,7 @@ export { questionResolutionText3 as questionResolutionText, rawFileUrl9 as rawFileUrl, readDocumentAttachment, - readImageAttachment2 as readImageAttachment, + readImageAttachment, reasoningCapabilityCandidate6 as reasoningCapabilityCandidate, reasoningCapabilityIsActionable6 as reasoningCapabilityIsActionable, reasoningCapabilityStatusText6 as reasoningCapabilityStatusText, @@ -10071,10 +10097,10 @@ export { reasoningEffortCatalog5 as reasoningEffortCatalog, reasoningEffortFallbackLabel7 as reasoningEffortFallbackLabel, reasoningEffortLabel7 as reasoningEffortLabel, - reconcileBackgroundSubagentSnapshot2 as reconcileBackgroundSubagentSnapshot, - reconnectEventsManually, + reconcileBackgroundSubagentSnapshot, + reconnectEventsManually2 as reconnectEventsManually, refreshConfigRevisionsAfterConflict7 as refreshConfigRevisionsAfterConflict, - refreshNewTaskModelState2 as refreshNewTaskModelState, + refreshNewTaskModelState, refreshSettingsConfiguration4 as refreshSettingsConfiguration, reliabilitySettingsHtml5 as reliabilitySettingsHtml, rememberCurrentSession, @@ -10086,22 +10112,22 @@ export { renderAgentModelPickers5 as renderAgentModelPickers, renderApprovalPanel, renderAssistantDraft4 as renderAssistantDraft, - renderAttachmentStrip2 as renderAttachmentStrip, + renderAttachmentStrip, renderBackgroundSubagentStatus4 as renderBackgroundSubagentStatus, - renderBootstrapFailure, - renderBootstrapLoading, + renderBootstrapFailure2 as renderBootstrapFailure, + renderBootstrapLoading2 as renderBootstrapLoading, renderCompactTableHtml9 as renderCompactTableHtml, - renderComposerStatus, + renderComposerStatus2 as renderComposerStatus, renderExpandedTableHtml9 as renderExpandedTableHtml, - renderFiles2 as renderFiles, - renderFinalAssistantBody, + renderFiles, + renderFinalAssistantBody2 as renderFinalAssistantBody, renderGatewayProbeResult5 as renderGatewayProbeResult, - renderGoalControls, - renderGoalStatusBar, + renderGoalControls2 as renderGoalControls, + renderGoalStatusBar2 as renderGoalStatusBar, renderGuideFeedback8 as renderGuideFeedback, renderLightboxImage9 as renderLightboxImage, renderLinkedText9 as renderLinkedText, - renderMessageText, + renderMessageText2 as renderMessageText, renderModelConfigFailure6 as renderModelConfigFailure, renderModelConfigPanel4 as renderModelConfigPanel, renderModelPanel4 as renderModelPanel, @@ -10110,8 +10136,8 @@ export { renderQueueItem8 as renderQueueItem, renderQueuePanel, renderReasoningCapabilityStatus5 as renderReasoningCapabilityStatus, - renderSessionFailure2 as renderSessionFailure, - renderSessions2 as renderSessions, + renderSessionFailure, + renderSessions, renderSettingsFeedbackInPlace5 as renderSettingsFeedbackInPlace, renderSettingsView4 as renderSettingsView, renderSheetCellHtml9 as renderSheetCellHtml, @@ -10120,64 +10146,64 @@ export { renderTableHtml9 as renderTableHtml, renderTablePreview9 as renderTablePreview, renderTranscriptHistoryStatus3 as renderTranscriptHistoryStatus, - renderTranscriptMessages2 as renderTranscriptMessages, + renderTranscriptMessages, renderTranscriptWindowMarker3 as renderTranscriptWindowMarker, renderTrustPanel, renderWorkflowPanel3 as renderWorkflowPanel, - renderWorkflowStrip, + renderWorkflowStrip2 as renderWorkflowStrip, replaceFileReferences10 as replaceFileReferences, - requestGoalMode, - requestPermissionMode, + requestGoalMode2 as requestGoalMode, + requestPermissionMode2 as requestPermissionMode, resetEventReplayState, - resetLiveStatus2 as resetLiveStatus, - resetPreview2 as resetPreview, + resetLiveStatus, + resetPreview, resetTranscriptWindow4 as resetTranscriptWindow, - resetTurnChangeStats2 as resetTurnChangeStats, + resetTurnChangeStats, resizePromptInput, - resolveApproval2 as resolveApproval, + resolveApproval, resolveAtomicModelSelection7 as resolveAtomicModelSelection, resolveAttachedFilePath, resolveDisplayFilePath10 as resolveDisplayFilePath, responseJson9 as responseJson, - responsiveLayoutMode, - restoreBackgroundSnapshot2 as restoreBackgroundSnapshot, + responsiveLayoutMode2 as responsiveLayoutMode, + restoreBackgroundSnapshot, restoreInitialSession, - restoreModalAttribute2 as restoreModalAttribute, - restoreNewTaskModelState2 as restoreNewTaskModelState, - restorePreviewWidth, + restoreModalAttribute, + restoreNewTaskModelState, + restorePreviewWidth2 as restorePreviewWidth, restoreTranscriptNodeAnchor3 as restoreTranscriptNodeAnchor, restoreTranscriptViewportAnchor3 as restoreTranscriptViewportAnchor, - returnToQuestion2 as returnToQuestion, + returnToQuestion, revealInteractionPanel8 as revealInteractionPanel, reviewQuestionConversation8 as reviewQuestionConversation, runContextAction8 as runContextAction, saveDefaultModelSelection5 as saveDefaultModelSelection, - saveModelConfig, - saveSettingsConfig, - scheduleAnimationFrameOnce, + saveModelConfig2 as saveModelConfig, + saveSettingsConfig2 as saveSettingsConfig, + scheduleAnimationFrameOnce2 as scheduleAnimationFrameOnce, scheduleDraftRender4 as scheduleDraftRender, scheduleEventReconnect3 as scheduleEventReconnect, - scheduleSessionsRefresh2 as scheduleSessionsRefresh, + scheduleSessionsRefresh, scopedDefaultModelLabel5 as scopedDefaultModelLabel, - scrollTranscript2 as scrollTranscript, - selectTranscriptNodesToRemove, + scrollTranscript, + selectTranscriptNodesToRemove2 as selectTranscriptNodesToRemove, sendPrompt, - sessionMeta2 as sessionMeta, - sessionStatusView2 as sessionStatusView, - sessionsNeedRefresh2 as sessionsNeedRefresh, - setConnectionState, + sessionMeta, + sessionStatusView, + sessionsNeedRefresh, + setConnectionState2 as setConnectionState, setFormControlsSaving5 as setFormControlsSaving, setLiveTitle, setModelConfigFormSaving6 as setModelConfigFormSaving, - setPendingGuide2 as setPendingGuide, - setPermissionMode, + setPendingGuide, + setPermissionMode2 as setPermissionMode, setPreviewWidth, - setResponsiveSurfaceInert, + setResponsiveSurfaceInert2 as setResponsiveSurfaceInert, setResponsiveView, - setSessionsRefreshState2 as setSessionsRefreshState, + setSessionsRefreshState, setSettingsFormSaving5 as setSettingsFormSaving, - setSidebarCollapsed2 as setSidebarCollapsed, - setTranscriptPaging2 as setTranscriptPaging, + setSidebarCollapsed, + setTranscriptPaging, settingsControlValue5 as settingsControlValue, settingsDisabled5 as settingsDisabled, settingsFeedbackHtml5 as settingsFeedbackHtml, @@ -10187,32 +10213,32 @@ export { settingsModelHtml5 as settingsModelHtml, settingsSectionHeading5 as settingsSectionHeading, settingsToggleHtml5 as settingsToggleHtml, - shouldFollowTranscript, + shouldFollowTranscript2 as shouldFollowTranscript, shouldKeepGuideFeedback3 as shouldKeepGuideFeedback, shouldSkipDashboardEvent3 as shouldSkipDashboardEvent, showApproval3 as showApproval, - showContextConfirm, + showContextConfirm2 as showContextConfirm, showError, - showGoalConfirm, - showGoalTextPanel, + showGoalConfirm2 as showGoalConfirm, + showGoalTextPanel2 as showGoalTextPanel, showImageLightbox9 as showImageLightbox, showModelConfigPanel4 as showModelConfigPanel, showNotice7 as showNotice, - showPermissionConfirm, + showPermissionConfirm2 as showPermissionConfirm, showQuestion3 as showQuestion, - showSettingsWorkspace, - showShutdownPanel, + showSettingsWorkspace2 as showSettingsWorkspace, + showShutdownPanel2 as showShutdownPanel, showTableLightbox9 as showTableLightbox, showTrustPanel, - shutdownDashboard, + shutdownDashboard2 as shutdownDashboard, shutdownRequestBody8 as shutdownRequestBody, shutdownResultIsClosed8 as shutdownResultIsClosed, sourceBadge7 as sourceBadge, sourceLabel5 as sourceLabel, - stableTurnRequest2 as stableTurnRequest, + stableTurnRequest, state, statusUrl, - submitGoalAction, + submitGoalAction2 as submitGoalAction, submitQuestion8 as submitQuestion, summarizeWorkflow3 as summarizeWorkflow, switchModel5 as switchModel, @@ -10221,15 +10247,15 @@ export { syncGatewayUrlHint5 as syncGatewayUrlHint, syncGuideButton, syncPendingGuideFromQueue3 as syncPendingGuideFromQueue, - syncPreviewResizeHandle, + syncPreviewResizeHandle2 as syncPreviewResizeHandle, syncReasoningDefaultOptions5 as syncReasoningDefaultOptions, syncResponsiveNavigation, syncSettingsRail5 as syncSettingsRail, syncTranscriptFollowState3 as syncTranscriptFollowState, - syncVisualViewport, + syncVisualViewport2 as syncVisualViewport, tablePreviewMeta9 as tablePreviewMeta, tableTruncationNote9 as tableTruncationNote, - toggleLiveStatusDetails, + toggleLiveStatusDetails2 as toggleLiveStatusDetails, toggleModelPanel4 as toggleModelPanel, toggleQuestionChoice8 as toggleQuestionChoice, toggleSidebar, @@ -10244,16 +10270,16 @@ export { unresolvedModelStatusHtml4 as unresolvedModelStatusHtml, updateAgentModelPickerManualStatus5 as updateAgentModelPickerManualStatus, updateConfigRevisions, - updateContextActions, + updateContextActions2 as updateContextActions, updateLiveActivity4 as updateLiveActivity, updateLiveStatus3 as updateLiveStatus, - updatePreviewResize, + updatePreviewResize2 as updatePreviewResize, updateRunStatusForBackground4 as updateRunStatusForBackground, - updateRunStatusTone, + updateRunStatusTone2 as updateRunStatusTone, updateSendButton, updateSessionStatus, updateTranscriptJump9 as updateTranscriptJump, - updateTurnChangeStats2 as updateTurnChangeStats, + updateTurnChangeStats, userMessageDisplayText9 as userMessageDisplayText, userTranscriptDisplayText, workflowItem3 as workflowItem, diff --git a/src/dashboard/public/styles.css b/src/dashboard/public/styles.css index b2d0e97..0135551 100644 --- a/src/dashboard/public/styles.css +++ b/src/dashboard/public/styles.css @@ -3089,6 +3089,24 @@ body > .approval-panel:not(.hidden), padding: 4px; } +.attachment-page-range { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.attachment-page-range input { + width: 100%; + min-width: 0; + height: 28px; + color: var(--text-soft); + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; +} + .attachment-chip img, .attachment-chip-file { aspect-ratio: 1; diff --git a/src/dashboard/runtime/turn-queue.ts b/src/dashboard/runtime/turn-queue.ts index 60df363..04dd6a5 100644 --- a/src/dashboard/runtime/turn-queue.ts +++ b/src/dashboard/runtime/turn-queue.ts @@ -259,8 +259,14 @@ export function validateTurnSubmission(input: DashboardRequestInput = {}): TurnS if (!matchesDocumentSignature(decoded, ext)) { return { ok: false, status: 400, code: "DOCUMENT_SIGNATURE_MISMATCH", error: "文档内容与文件名类型不匹配" }; } + const pageStart = item.pageStart === undefined ? undefined : Number(item.pageStart); + const pageEnd = item.pageEnd === undefined ? undefined : Number(item.pageEnd); + if ([pageStart, pageEnd].some((page) => page !== undefined && (!Number.isSafeInteger(page) || page < 1)) || (pageStart !== undefined && pageEnd !== undefined && pageEnd < pageStart)) { + return { ok: false, status: 400, code: "INVALID_PDF_PAGE_RANGE", error: "PDF 页码必须为正整数,结束页不能小于起始页" }; + } attachments.push({ type: "document", + ...(ext === ".pdf" ? { pageStart, pageEnd } : {}), data, mimeType: mimeTypeForDocumentExt(ext), name, @@ -390,6 +396,8 @@ export function turnRequestFingerprint(input: DashboardRequestInput = {}) { updateFingerprintField(hash, `${index}:name`, String(attachment.name ?? "")); updateFingerprintField(hash, `${index}:mimeType`, String(attachment.mimeType ?? attachment.mime_type ?? "")); updateFingerprintField(hash, `${index}:size`, String(attachment.size ?? "")); + updateFingerprintField(hash, `${index}:pageStart`, String(attachment.pageStart ?? "")); + updateFingerprintField(hash, `${index}:pageEnd`, String(attachment.pageEnd ?? "")); updateFingerprintField(hash, `${index}:data`, String(attachment.data ?? "")); } return hash.digest("hex"); diff --git a/src/tools/composer-documents.ts b/src/tools/composer-documents.ts index 909a69f..40ff983 100644 --- a/src/tools/composer-documents.ts +++ b/src/tools/composer-documents.ts @@ -1,17 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parseDocumentBufferAsync } from "./document-tools.ts"; -import { encodePng } from "./png-encode.ts"; -import { extractImages, getDocumentProxy } from "unpdf"; +import type { PdfVisionDocument } from "./pdf-render.ts"; export const COMPOSER_UPLOAD_DIR = "ant-code-uploads"; export const MAX_DOCUMENT_ATTACHMENTS = 4; export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; -export const MAX_VISION_PDF_PAGES = 2; -export const PDF_SPARSE_TEXT_CHARS = 40; export const COMPOSER_DOCUMENT_PREVIEW_CHARS = 4000; -export const MAX_VISION_PAGE_EDGE = 1280; -export const MAX_VISION_PAGE_PNG_BYTES = 1_500_000; +export const MAX_VISION_PDF_PAGES = 2; export const ALLOWED_DOCUMENT_EXTENSIONS = new Set([ ".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".markdown", ".csv", ".json", ".html", ".htm" @@ -47,6 +43,8 @@ type ComposerDocument = { data: string; ext: string; path?: string; + pageStart?: number; + pageEnd?: number; }; export function extensionOfName(name: unknown) { @@ -88,7 +86,9 @@ export function normalizeComposerDocuments(value: unknown): ComposerDocument[] { size: Number(record.size ?? 0) || 0, data, ext, - path: String(record.path ?? "").trim() || undefined + path: String(record.path ?? "").trim() || undefined, + pageStart: record.pageStart === undefined ? undefined : Number(record.pageStart), + pageEnd: record.pageEnd === undefined ? undefined : Number(record.pageEnd) }); if (documents.length >= MAX_DOCUMENT_ATTACHMENTS) { break; @@ -130,10 +130,11 @@ export async function ingestComposerDocuments(input: { cwd: string; attachments?: unknown; existingImageCount?: number; -}): Promise<{ promptAppendix: string; visionImages: ComposerImage[] }> { + signal?: AbortSignal; +}): Promise<{ promptAppendix: string; visionImages: ComposerImage[]; pdfDocuments: PdfVisionDocument[] }> { const documents = normalizeComposerDocuments(input.attachments); if (documents.length === 0) { - return { promptAppendix: "", visionImages: [] }; + return { promptAppendix: "", visionImages: [], pdfDocuments: [] }; } const uploadRoot = path.join(path.resolve(input.cwd), COMPOSER_UPLOAD_DIR); await fs.mkdir(uploadRoot, { recursive: true }); @@ -142,10 +143,10 @@ export async function ingestComposerDocuments(input: { "Composer attached the following files. They are already saved. Do not search the whole workspace to find them. Use document_intake or read_file on the saved paths if you need more than the preview." ]; const visionImages: ComposerImage[] = []; - const existingImages = Math.max(0, Number(input.existingImageCount) || 0); - const remainingVisionSlots = Math.max(0, 6 - existingImages); + const pdfDocuments: PdfVisionDocument[] = []; for (const document of documents) { + input.signal?.throwIfAborted(); const relativePath = typeof document.path === "string" && document.path.trim() ? document.path.replace(/\\/g, "/") : `${COMPOSER_UPLOAD_DIR}/${uniqueUploadName(document.name)}`; @@ -156,70 +157,23 @@ export async function ingestComposerDocuments(input: { if (!document.path) { await fs.writeFile(savedAbs, buffer); } - const parsed = await parseDocumentBufferAsync(buffer, document.ext); - const sparsePdf = document.ext === ".pdf" && parsed.content.replace(/\s+/g, "").length < PDF_SPARSE_TEXT_CHARS; - if (sparsePdf && remainingVisionSlots - visionImages.length > 0) { - const pageBudget = Math.min(MAX_VISION_PDF_PAGES, remainingVisionSlots - visionImages.length, existingImages > 0 ? 1 : MAX_VISION_PDF_PAGES); - const pages = await pdfEmbeddedImagesAsPng(buffer, pageBudget); - visionImages.push(...pages.images.slice(0, pageBudget)); - parsed.notes.push(...pages.notes); + const parsed = await parseDocumentBufferAsync(buffer, document.ext, { + pageStart: document.pageStart, + maxPages: document.pageEnd === undefined ? undefined : document.pageEnd - (document.pageStart ?? 1) + 1 + }); + if (document.ext === ".pdf") { + schedulePdfVision(document, parsed, savedAbs, pdfDocuments); } sections.push(formatDocumentSection(relativePath, document.name, parsed.content, parsed.notes, parsed.supported)); } return { promptAppendix: sections.join("\n\n"), - visionImages: visionImages.slice(0, remainingVisionSlots) + visionImages, + pdfDocuments }; } -export async function pdfEmbeddedImagesAsPng(buffer: Buffer, maxPages: number) { - const notes: string[] = []; - const images: ComposerImage[] = []; - try { - const pdf = await getDocumentProxy(new Uint8Array(buffer)); - const pageCount = Number(pdf.numPages) || 0; - const limit = Math.min(Math.max(1, maxPages), pageCount, MAX_VISION_PDF_PAGES); - for (let page = 1; page <= limit; page += 1) { - const extracted = await extractImages(pdf, page); - const best = pickLargestImage(extracted); - if (!best || !best.data || !best.width || !best.height) { - continue; - } - const channels = best.channels === 1 || best.channels === 3 || best.channels === 4 ? best.channels : 4; - const scaled = downscaleRawImage(best.width, best.height, channels, best.data, MAX_VISION_PAGE_EDGE); - const png = encodePng(scaled.width, scaled.height, channels, scaled.data); - if (png.length > MAX_VISION_PAGE_PNG_BYTES) { - notes.push(`Page ${page} image was still larger than ${MAX_VISION_PAGE_PNG_BYTES} bytes after downscale and was skipped.`); - continue; - } - images.push({ - type: "image", - name: `pdf-page-${page}.png`, - mimeType: "image/png", - size: png.length, - data: png.toString("base64") - }); - } - if (images.length > 0) { - notes.push(`No usable PDF text layer; sent ${images.length} embedded page image(s) to the vision model (pages 1-${images.length}).`); - } else { - notes.push("No usable PDF text layer and no embedded page images. Vision was not given raster pages; OCR is not bundled."); - } - } catch (error) { - notes.push(`PDF page-image extraction failed: ${error instanceof Error ? error.message : String(error)}`); - } - return { images, notes }; -} - -function pickLargestImage(images: Array<{ width?: number; height?: number; data?: Uint8ClampedArray; channels?: number }>) { - return [...images].sort((left, right) => { - const leftArea = Number(left.width ?? 0) * Number(left.height ?? 0); - const rightArea = Number(right.width ?? 0) * Number(right.height ?? 0); - return rightArea - leftArea; - })[0]; -} - export async function ensureComposerUploadIgnore(cwd: string) { const root = path.resolve(cwd); const uploadDir = path.join(root, COMPOSER_UPLOAD_DIR); @@ -268,31 +222,56 @@ function uniqueUploadName(name: string) { return `${Date.now()}-${stem}${ext}`; } -function downscaleRawImage( - width: number, - height: number, - channels: 1 | 3 | 4, - data: Uint8Array | Uint8ClampedArray | Buffer, - maxEdge: number +export function resolvePdfVisionWindow(input: { pageStart?: number; pageEnd?: number; totalPages: number }) { + const totalPages = Math.max(1, Number(input.totalPages) || 1); + const explicitStart = Number.isInteger(input.pageStart) && Number(input.pageStart) >= 1; + const explicitEnd = Number.isInteger(input.pageEnd) && Number(input.pageEnd) >= 1; + const pageStart = Math.min(totalPages, explicitStart ? Number(input.pageStart) : 1); + const requestedEnd = explicitEnd ? Number(input.pageEnd) : pageStart + MAX_VISION_PDF_PAGES - 1; + return { + pageStart, + pageEnd: Math.min(totalPages, Math.max(pageStart, requestedEnd)), + explicit: explicitStart || explicitEnd + }; +} + +function schedulePdfVision( + document: ComposerDocument, + parsed: { supported?: boolean; sparseText?: boolean; totalPages?: number; notes: string[] }, + savedAbs: string, + pdfDocuments: PdfVisionDocument[] ) { - const edge = Math.max(width, height); - if (edge <= maxEdge) { - return { width, height, data }; + if (!parsed.supported) { + return; } - const scale = maxEdge / edge; - const nextWidth = Math.max(1, Math.round(width * scale)); - const nextHeight = Math.max(1, Math.round(height * scale)); - const next = new Uint8Array(nextWidth * nextHeight * channels); - for (let y = 0; y < nextHeight; y += 1) { - const sourceY = Math.min(height - 1, Math.floor(y / scale)); - for (let x = 0; x < nextWidth; x += 1) { - const sourceX = Math.min(width - 1, Math.floor(x / scale)); - const sourceIndex = (sourceY * width + sourceX) * channels; - const destIndex = (y * nextWidth + x) * channels; - next.set(data.subarray(sourceIndex, sourceIndex + channels), destIndex); - } + if (!parsed.sparseText) { + parsed.notes.push("This PDF has a usable text layer. Answer from the extracted text. If the preview is insufficient, call document_intake or read_file on the saved path. Do not send pages to vision automatically."); + return; + } + const totalPages = Number(parsed.totalPages) || 0; + if (totalPages < 1) { + return; + } + const window = resolvePdfVisionWindow({ + pageStart: document.pageStart, + pageEnd: document.pageEnd, + totalPages + }); + pdfDocuments.push({ + name: document.name, + path: savedAbs, + pageStart: window.pageStart, + pageEnd: window.pageEnd + }); + const unreadFrom = window.pageEnd + 1; + parsed.notes.push( + `No usable text layer; rendered pages ${window.pageStart}-${window.pageEnd} of ${totalPages} as full pages for vision. Cite those page numbers. Do not assume they contain the abstract or summary.` + ); + if (unreadFrom <= totalPages) { + parsed.notes.push( + `Pages ${unreadFrom}-${totalPages} have not been visually read. If more is needed, state that uncovered range, ask whether to continue the next pages or do a full visual read, and mention extra time and model cost. Full visual read is explicit: the user must set start and end pages on the paperclip and resend this PDF.` + ); } - return { width: nextWidth, height: nextHeight, data: next }; } function formatDocumentSection(relativePath: string, originalName: string, content: string, notes: string[], supported: boolean) { diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index 17939f5..83d9e95 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -627,8 +627,8 @@ export const BUILT_IN_TOOLS = Object.freeze([ parallel: { type: "boolean" }, background: { type: "boolean" }, groupId: { type: "string" }, - waitForGroup: { type: "string", enum: ["all", "any", "none"] }, - wakeParent: { type: "boolean" }, + waitForGroup: { type: "string", enum: ["all", "any", "none"], description: "Group-wide wait condition. Use the same value for every task in a group. Conflicts merge as all > any > none." }, + wakeParent: { type: "boolean", description: "Group-wide parent wakeup. Use true for every task when the parent will wait for results. Joining a group cannot disable an existing wakeup request." }, wakeReason: { type: "string" }, purpose: { type: "string", enum: ["explore", "research", "plan", "execute", "verify", "review", "browser", "visual"] }, difficulty: { type: "string", enum: ["quick", "standard", "deep"] }, diff --git a/src/tools/document-tools.ts b/src/tools/document-tools.ts index 6c9888f..56058c9 100644 --- a/src/tools/document-tools.ts +++ b/src/tools/document-tools.ts @@ -21,6 +21,7 @@ const PDF_LIMITS = Object.freeze({ defaultMaxPages: 20, maxPagesCap: 50 }); +const PDF_SPARSE_TEXT_CHARS = 40; type OfficeZipLimits = { maxArchiveBytes: number; @@ -47,6 +48,7 @@ type DocumentParseResult = { pageEnd?: number; totalPages?: number; truncated?: boolean; + sparseText?: boolean; }; type ZipLocalEntry = { @@ -187,8 +189,9 @@ export async function parseDocumentBufferAsync(buffer: Buffer, ext: string, opti } async function parsePdfTextLayer(buffer: Buffer, options: Record = EMPTY_RECORD): Promise { + let pdf: Awaited> | undefined; try { - const pdf = await getDocumentProxy(new Uint8Array(buffer)); + pdf = await getDocumentProxy(new Uint8Array(buffer)); const extracted = await extractText(pdf, { mergePages: false }); const pages = Array.isArray(extracted.text) ? extracted.text.map((page) => String(page ?? "")) : [String(extracted.text ?? "")]; const totalPages = extracted.totalPages || pages.length; @@ -202,14 +205,15 @@ async function parsePdfTextLayer(buffer: Buffer, options: Record `--- page ${pageStart + index} ---\n${page.replace(/\s+$/g, "")}`) .join("\n\n") .trim(); - const visibleChars = joined.replace(/\s+/g, "").length; + const visibleChars = slice.join("").replace(/\s+/g, "").length; const notes = [ `Extracted the PDF text layer from pages ${pageStart}-${Math.max(pageStart, pageEnd)} of ${totalPages}.` ]; if (truncated) { notes.push(`This call was windowed to at most ${maxPages} pages. Pass pageStart=${pageEnd + 1} to continue.`); } - if (visibleChars < 40) { + const sparseText = visibleChars < PDF_SPARSE_TEXT_CHARS; + if (sparseText) { notes.push("Very little extractable text was found. This PDF may be scanned images; OCR is not bundled."); } return { @@ -220,7 +224,8 @@ async function parsePdfTextLayer(buffer: Buffer, options: Record loadPdfCanvas()); + const bytes = await fs.readFile(document.path, { signal: options.signal }); + const pdf = await getDocumentProxy(new Uint8Array(bytes), { + CanvasFactory, useSystemFonts: true + }); + try { + const first = document.pageStart ?? 1; + const last = document.pageEnd ?? pdf.numPages; + if (!Number.isInteger(first) || !Number.isInteger(last) || first < 1 || last < first || last > pdf.numPages) { + throw new Error(`Invalid PDF page range ${first}-${last}; document has ${pdf.numPages} pages.`); + } + let images: PdfPageImage[] = []; + for (let pageNumber = first; pageNumber <= last; pageNumber += 1) { + options.signal?.throwIfAborted(); + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1 }); + const scale = Math.min(2, PDF_RENDER_MAX_EDGE / Math.max(viewport.width, viewport.height)); + const factory = new CanvasFactory(); + const drawing = factory.create(Math.max(1, Math.ceil(viewport.width * scale)), Math.max(1, Math.ceil(viewport.height * scale))); + try { + const render = page.render({ canvas: drawing.canvas as unknown as HTMLCanvasElement, canvasContext: drawing.context as unknown as CanvasRenderingContext2D, viewport: page.getViewport({ scale }) }); + const abort = () => render.cancel(); + const timeout = setTimeout(abort, PDF_RENDER_TIMEOUT_MS); + options.signal?.addEventListener("abort", abort, { once: true }); + try { + options.signal?.throwIfAborted(); + await render.promise; + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abort); + } + options.signal?.throwIfAborted(); + // JPEG bounds request size even for full-page scans with photographic noise. + const canvas = drawing.canvas as import("@napi-rs/canvas").Canvas; + const image = await canvas.encode("jpeg", 90); + if (image.length > 8 * 1024 * 1024) throw new Error(`PDF page ${pageNumber} exceeds the 8 MiB image limit.`); + images.push({ type: "image", name: `${document.name} / page ${pageNumber}`, mimeType: "image/jpeg", size: image.length, data: image.toString("base64") }); + } finally { + factory.destroy(drawing); + page.cleanup(); + } + if (images.length === PDF_VISION_BATCH_PAGES || pageNumber === last) { + yield { images, pageStart: pageNumber - images.length + 1, pageEnd: pageNumber, totalPages: pdf.numPages }; + images = []; + } + } + } finally { + await pdf.loadingTask.destroy(); + } +} diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts index 5154b81..13e411c 100644 --- a/src/tools/runtime.ts +++ b/src/tools/runtime.ts @@ -1288,17 +1288,17 @@ async function startBackgroundAgentTool( ? input.taskId.trim() : `task-${crypto.randomUUID()}`; const groupId = normalizeGroupId(input.groupId) ?? `group-${crypto.randomUUID()}`; - const waitFor = pickEnum( + let waitFor = pickEnum( input.waitForGroup, ["all", "any", "none"] as const, pickEnum(options.config?.agents?.backgroundWakeup?.defaultWaitFor, ["all", "any", "none"] as const, "all") ?? "all" ) ?? "all"; - const wakeParent = input.wakeParent !== false && options.config?.agents?.backgroundWakeup?.autoQueueParentPrompt !== false; + let wakeParent = input.wakeParent !== false && options.config?.agents?.backgroundWakeup?.autoQueueParentPrompt !== false; const wakeReason = typeof input.wakeReason === "string" && input.wakeReason.trim() ? input.wakeReason.trim() : "后台子任务完成后继续主控编排"; const groupStore = createAgentTaskGroupStore({ cwd: options.cwd }); - await groupStore.ensureGroup({ + const ensuredGroup = await groupStore.ensureGroup({ id: groupId, parentSessionId: options.parentSessionId ?? null, status: "running", @@ -1312,6 +1312,12 @@ async function startBackgroundAgentTool( background: true } }); + if (!ensuredGroup.ok) { + return { ok: false, error: ensuredGroup.error }; + } + const wakePolicyMerged = ensuredGroup.group.waitFor !== waitFor || ensuredGroup.group.wakeParent !== wakeParent; + waitFor = ensuredGroup.group.waitFor as "all" | "any" | "none"; + wakeParent = ensuredGroup.group.wakeParent; await runHooks({ config: options.config, cwd: options.cwd, @@ -1410,7 +1416,7 @@ async function startBackgroundAgentTool( taskId, groupId, taskStatus: "running", - outputSummary: `后台子智能体 ${profile.name} 已启动;group=${groupId},完成后将${wakeParent ? "自动唤醒主控" : "记录结果" }。`, + outputSummary: `后台子智能体 ${profile.name} 已启动;group=${groupId},完成后将${wakeParent && waitFor !== "none" ? "自动唤醒主控" : "记录结果" }。${wakePolicyMerged ? "同组唤醒设置已合并,保留已开启的唤醒并采用更严格的等待条件。" : ""}`, result: { taskId, groupId, @@ -1418,7 +1424,8 @@ async function startBackgroundAgentTool( status: "running", background: true, waitForGroup: waitFor, - wakeParent + wakeParent, + wakePolicyMerged } }; } @@ -1484,8 +1491,8 @@ async function finalizeBackgroundAgentTool(options: ToolRuntimeOptions, state: { taskId: state.taskId, profile: state.profile.name, status: group.status, - waitFor: state.waitFor, - wakeParent: state.wakeParent, + waitFor: group.waitFor, + wakeParent: group.wakeParent, completed: summary.completed, summary: summary.summary }); @@ -1529,8 +1536,8 @@ async function finalizeBackgroundAgentTool(options: ToolRuntimeOptions, state: { taskId: state.taskId, profile: state.profile.name, status: group?.status, - waitFor: state.waitFor, - wakeParent: state.wakeParent, + waitFor: group?.waitFor, + wakeParent: group?.wakeParent, wakePrompt, summary: group?.summary }); diff --git a/src/version.ts b/src/version.ts index a77fee4..fd93780 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const FALLBACK_VERSION = "2.0.10"; +const FALLBACK_VERSION = "2.0.11"; /** * @param {NodeJS.ProcessEnv} [env] diff --git a/tests/browser/dashboard-browser.test.ts b/tests/browser/dashboard-browser.test.ts index 7fde96a..15877a9 100644 --- a/tests/browser/dashboard-browser.test.ts +++ b/tests/browser/dashboard-browser.test.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import test, { after, before } from "node:test"; import { createDashboardServer } from "../../src/dashboard/server.ts"; +import { visualPdf } from "../fixtures/pdf-vision.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const BROWSER_PATH = process.env.ANT_CODE_BROWSER_EXECUTABLE ?? { @@ -83,6 +84,39 @@ after(async () => { ]); }); +test("composer accepts PNG without MIME and sends PDF page ranges on desktop and mobile", async () => { + for (const width of [390, 1280]) { + await withDashboardPage({ width, height: 900 }, async (page) => { + let sent; + await page.route("**/api/turns", async (route) => { + sent = route.request().postDataJSON(); + await route.fulfill({ status: 400, contentType: "application/json", body: JSON.stringify({ ok: false, error: "test capture" }) }); + }); + await page.locator("#attachment-input").setInputFiles({ name: "paper.pdf", mimeType: "application/pdf", buffer: visualPdf() }); + await page.getByRole("spinbutton", { name: "paper.pdf 起始页" }).fill("3"); + await page.getByRole("spinbutton", { name: "paper.pdf 结束页" }).fill("5"); + await page.evaluate(() => { + const clipboardData = new DataTransfer(); + const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="), (value) => value.charCodeAt(0)); + clipboardData.items.add(new File([bytes], "pasted.png", { type: "" })); + document.querySelector("#prompt-input").dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true })); + }); + await page.locator('.attachment-chip:has-text("pasted.png")').waitFor(); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await fs.mkdir(path.join(ROOT, ".tmp_validation"), { recursive: true }); + await page.screenshot({ path: path.join(ROOT, ".tmp_validation", `pdf-composer-${width}.png`) }); + await page.locator("#prompt-input").fill("Read the selected PDF pages and image"); + await Promise.all([ + page.waitForResponse((response) => response.url().endsWith("/api/turns")), + page.locator("#send-button").click() + ]); + assert.equal(sent.attachments[0].pageStart, 3); + assert.equal(sent.attachments[0].pageEnd, 5); + assert.equal(sent.attachments[1].mimeType, "image/png"); + }); + } +}); + test("dashboard has no page overflow and keeps core navigation reachable from 320 to 1440", async () => { for (const width of [320, 390, 768, 1024, 1280, 1440]) { await withDashboardPage({ width, height: width < 768 ? 844 : 900 }, async (page) => { diff --git a/tests/fixtures/pdf-vision.ts b/tests/fixtures/pdf-vision.ts new file mode 100644 index 0000000..8101e40 --- /dev/null +++ b/tests/fixtures/pdf-vision.ts @@ -0,0 +1,25 @@ +// Synthetic PDF with vector graphics, text, and two distinct embedded images per page. +export function visualPdf(pageCount = 5, text = true) { + const objects: string[] = ["", "", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"]; + const pages: number[] = []; + for (let page = 1; page <= pageCount; page += 1) { + const pageId = objects.length + 1; + const contentId = pageId + 1; + const imageId = pageId + 2; + pages.push(pageId); + objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F1 3 0 R >> /XObject << /Red ${imageId} 0 R /Blue ${imageId + 1} 0 R >> >> /Contents ${contentId} 0 R >>`); + const content = `0 0.7 0 rg 20 200 260 100 re f\nq 80 0 0 80 20 60 cm /Red Do Q\nq 80 0 0 80 180 60 cm /Blue Do Q\n${text ? `0 0 0 rg BT /F1 24 Tf 20 350 Td (Page ${page} - PDF vision) Tj ET` : ""}`; + objects.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`); + for (const pixel of ["FF0000>", "0000FF>"]) { + objects.push(`<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length ${pixel.length} >>\nstream\n${pixel}\nendstream`); + } + } + objects[0] = "<< /Type /Catalog /Pages 2 0 R >>"; + objects[1] = `<< /Type /Pages /Kids [${pages.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageCount} >>`; + let pdf = "%PDF-1.4\n"; + const offsets = [0]; + objects.forEach((object, index) => { offsets.push(Buffer.byteLength(pdf)); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; }); + const xref = Buffer.byteLength(pdf); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Root 1 0 R /Size ${objects.length + 1} >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(pdf); +} diff --git a/tests/unit/agent-task-group-store.test.ts b/tests/unit/agent-task-group-store.test.ts index dddfada..4c4abb6 100644 --- a/tests/unit/agent-task-group-store.test.ts +++ b/tests/unit/agent-task-group-store.test.ts @@ -101,6 +101,51 @@ test("ensureGroup preserves concurrent task ids for the same group", async () => assert.deepEqual(read.group.taskIds.sort(), ["task-a", "task-b"]); }); +for (const order of ["enabled-first", "disabled-first", "concurrent"] as const) { + test(`ensureGroup preserves wakeup with conflicting policies: ${order}`, async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-group-wake-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const enabled = { id: "mixed", taskIds: ["enabled"], waitFor: "all", wakeParent: true, wakeReason: "collect results" }; + const disabled = { id: "mixed", taskIds: ["disabled"], waitFor: "none", wakeParent: false, wakeReason: "no wake" }; + const stores = [createAgentTaskGroupStore({ cwd }), createAgentTaskGroupStore({ cwd })]; + if (order === "concurrent") { + await Promise.all([stores[0].ensureGroup(enabled), stores[1].ensureGroup(disabled)]); + } else { + const policies = order === "enabled-first" ? [enabled, disabled] : [disabled, enabled]; + await stores[0].ensureGroup(policies[0]); + await stores[1].ensureGroup(policies[1]); + } + const read = await stores[0].readGroup("mixed"); + assert.equal(read.ok, true); + if (!read.ok) return; + assert.deepEqual(read.group.taskIds.sort(), ["disabled", "enabled"]); + assert.equal(read.group.wakeParent, true); + assert.equal(read.group.waitFor, "all"); + assert.equal(read.group.wakeReason, "collect results"); + assert.equal(summarizeGroupStatus([{ status: "completed" }, { status: "running" }], read.group).completed, false); + assert.equal(summarizeGroupStatus([{ status: "completed" }, { status: "completed" }], read.group).completed, true); + }); +} + +test("ensureGroup merges wait policies without enabling explicitly silent groups", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-group-policy-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const store = createAgentTaskGroupStore({ cwd }); + for (const [first, second, expected] of [ + ["none", "none", "none"], ["none", "any", "any"], ["any", "none", "any"], + ["any", "all", "all"], ["all", "any", "all"] + ]) { + const id = `${first}-${second}`; + await store.ensureGroup({ id, waitFor: first, wakeParent: false, taskIds: ["a"] }); + await store.ensureGroup({ id, waitFor: second, wakeParent: false, taskIds: ["b"] }); + const read = await store.readGroup(id); + assert.equal(read.ok, true); + if (!read.ok) continue; + assert.equal(read.group.waitFor, expected); + assert.equal(read.group.wakeParent, false); + } +}); + test("task group stores coalesce one history scan across active sessions", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-group-scan-cache-")); const root = path.join(cwd, ".lab-agent", "task-groups"); diff --git a/tests/unit/pdf-vision.test.ts b/tests/unit/pdf-vision.test.ts new file mode 100644 index 0000000..545225e --- /dev/null +++ b/tests/unit/pdf-vision.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { renderPdfBatches, loadPdfCanvas } from "../../src/tools/pdf-render.ts"; +import { ingestComposerDocuments, MAX_VISION_PDF_PAGES } from "../../src/tools/composer-documents.ts"; +import { parseDocumentBufferAsync } from "../../src/tools/document-tools.ts"; +import { createSession, runSessionTurn } from "../../src/core/session.ts"; +import { prepareVisionAttachmentsForTurn } from "../../src/core/session-messages.ts"; +import http from "node:http"; +import { validateTurnSubmission, turnRequestFingerprint } from "../../src/dashboard/runtime/turn-queue.ts"; +import { mapSessionEventToDashboard } from "../../src/dashboard/events.ts"; +import { visualPdf } from "../fixtures/pdf-vision.ts"; +import { createRecordingGateway, listen, close, serverUrl, mockGatewayEnvWithoutModel } from "./session-helpers.ts"; + +test("PDF page ranges are validated, retained and included in request identity", () => { + const attachment = { type: "document", name: "paper.pdf", data: visualPdf().toString("base64"), pageStart: 2, pageEnd: 4 }; + const input = { prompt: "read", attachments: [attachment] }; + const valid = validateTurnSubmission(input); + assert.equal(valid.ok, true); + if (valid.ok) assert.equal(valid.attachments[0].pageStart, 2); + for (const range of [{ pageStart: 0 }, { pageStart: 2.5 }, { pageEnd: 1 }]) { + assert.equal(validateTurnSubmission({ ...input, attachments: [{ ...attachment, ...range }] }).ok, false); + } + assert.notEqual(turnRequestFingerprint(input), turnRequestFingerprint({ ...input, attachments: [{ ...attachment, pageEnd: 5 }] })); + const events = mapSessionEventToDashboard({ type: "pdf_vision_progress", name: "paper.pdf", pageStart: 3, pageEnd: 4, totalPages: 5, stage: "analyzing" }); + assert.match(JSON.stringify(events), /3-4 \/ 5/); +}); + +test("PDF rendering covers all pages, vector content, text and multiple images", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-render-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const file = path.join(cwd, "five.pdf"); + await fs.writeFile(file, visualPdf()); + const batches = []; + for await (const batch of renderPdfBatches({ name: "five.pdf", path: file })) batches.push(batch); + assert.deepEqual(batches.map((batch) => [batch.pageStart, batch.pageEnd]), [[1, 2], [3, 4], [5, 5]]); + const { createCanvas, loadImage } = loadPdfCanvas(); + const image = await loadImage(Buffer.from(batches[0].images[0].data, "base64")); + const canvas = createCanvas(image.width, image.height); + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + const pixel = (x: number, y: number) => Array.from(context.getImageData(x * 2, y * 2, 1, 1).data); + assert.ok(pixel(50, 150)[1] > 150, "vector rectangle must be green"); + assert.ok(pixel(50, 300)[0] > 200, "first embedded image must be red"); + assert.ok(pixel(210, 300)[2] > 200, "second embedded image must be blue"); + const textPixels = context.getImageData(40, 50, 500, 65).data; + assert.ok(textPixels.some((value, index) => index % 4 !== 3 && value < 50), "text must render"); + assert.equal(batches[2].images[0].name, "five.pdf / page 5"); +}); + +test("PDF ranges, cancellation and invalid documents do not silently skip pages", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-range-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const file = path.join(cwd, "pages.pdf"); + await fs.writeFile(file, visualPdf()); + const batches = []; + for await (const batch of renderPdfBatches({ name: "pages.pdf", path: file, pageStart: 3, pageEnd: 4 })) batches.push(batch); + assert.deepEqual(batches.map((batch) => [batch.pageStart, batch.pageEnd]), [[3, 4]]); + await assert.rejects(async () => { for await (const _ of renderPdfBatches({ name: "bad", path: file, pageEnd: 6 })) {} }, /Invalid PDF page range/); + const controller = new AbortController(); + const iterator = renderPdfBatches({ name: "pages", path: file }, { signal: controller.signal }); + await iterator.next(); + controller.abort(); + await assert.rejects(iterator.next(), /abort/i); + await fs.writeFile(file, "%PDF-broken"); + await assert.rejects(async () => { for await (const _ of renderPdfBatches({ name: "bad", path: file })) {} }); +}); + +test("four-page scans default to the first vision window and ignore synthetic page headings", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-scan-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const buffer = visualPdf(4, false); + const parsed = await parseDocumentBufferAsync(buffer, ".pdf"); + assert.equal(parsed.sparseText, true); + assert.ok(parsed.notes.some((note) => /Very little/.test(note))); + const ingested = await ingestComposerDocuments({ cwd, attachments: [{ type: "document", name: "scan.pdf", data: buffer.toString("base64") }] }); + assert.equal(ingested.pdfDocuments.length, 1); + assert.deepEqual( + [ingested.pdfDocuments[0].pageStart, ingested.pdfDocuments[0].pageEnd], + [1, MAX_VISION_PDF_PAGES] + ); + assert.match(ingested.promptAppendix, /pages 1-2 of 4/); + assert.match(ingested.promptAppendix, /Pages 3-4 have not been visually read/); + let count = 0; + for await (const batch of renderPdfBatches(ingested.pdfDocuments[0])) count += batch.images.length; + assert.equal(count, MAX_VISION_PDF_PAGES); + const continued = await ingestComposerDocuments({ + cwd, + attachments: [{ type: "document", name: "scan.pdf", data: buffer.toString("base64"), pageStart: 3 }] + }); + assert.deepEqual([continued.pdfDocuments[0].pageStart, continued.pdfDocuments[0].pageEnd], [3, 4]); + const full = await ingestComposerDocuments({ + cwd, + attachments: [{ type: "document", name: "scan.pdf", data: buffer.toString("base64"), pageStart: 1, pageEnd: 4 }] + }); + assert.deepEqual([full.pdfDocuments[0].pageStart, full.pdfDocuments[0].pageEnd], [1, 4]); +}); + +test("text-layer PDFs are ingested without automatic page vision", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-text-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const ingested = await ingestComposerDocuments({ + cwd, + attachments: [{ type: "document", name: "paper.pdf", data: visualPdf().toString("base64"), pageStart: 1, pageEnd: 5 }] + }); + assert.equal(ingested.pdfDocuments.length, 0); + assert.match(ingested.promptAppendix, /usable text layer/); + assert.match(ingested.promptAppendix, /Page 1 - PDF vision/); +}); + +for (const mainVision of [true, false]) { + test(`scanned PDF session analyzes the default window; mainVision=${mainVision}`, async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-session-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + await fs.writeFile(path.join(cwd, "lab-agent.config.json"), JSON.stringify({ + modelAlias: mainVision ? "vision-model" : "text-model", + models: [{ id: "vision-model", modalities: ["text", "image"] }, { id: "text-model", modalities: ["text"] }], + agents: { vision: { enabled: true, model: "vision-model" } } + })); + const requests = []; + const server = await listen(createRecordingGateway(requests), "127.0.0.1"); + t.after(() => close(server)); + const env = mockGatewayEnvWithoutModel(serverUrl(server)); + const session = await createSession({ cwd, mode: "interactive", env }); + const events = []; + await runSessionTurn(session, { + prompt: "read the attached scan", + env, + attachments: [{ type: "document", name: "scan.pdf", data: visualPdf(5, false).toString("base64") }], + onEvent: (event) => { events.push(event); } + }); + const visual = requests.filter((request) => request.messages.some((message) => Array.isArray(message.content) && message.content.some((block) => block.type === "image"))); + assert.equal(visual.length, 1); + const pageImages = visual.flatMap((request) => request.messages.flatMap((message) => Array.isArray(message.content) ? message.content.filter((block) => block.type === "image") : [])); + assert.equal(pageImages.length, MAX_VISION_PDF_PAGES); + assert.equal(visual.every((request) => request.model === "vision-model"), true); + assert.equal(events.filter((event) => event.type === "pdf_vision_progress" && event.stage === "completed").length, 1); + assert.match(JSON.stringify(requests.at(-1).messages), /pages 1-2 of 5/); + assert.equal(JSON.stringify(session.messages).includes(pageImages[0].data), false); + }); +} + +for (const mainVision of [true, false]) { + test(`explicit PDF page range analyzes every selected page; mainVision=${mainVision}`, async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-full-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + await fs.writeFile(path.join(cwd, "lab-agent.config.json"), JSON.stringify({ + modelAlias: mainVision ? "vision-model" : "text-model", + models: [{ id: "vision-model", modalities: ["text", "image"] }, { id: "text-model", modalities: ["text"] }], + agents: { vision: { enabled: true, model: "vision-model" } } + })); + const requests = []; + const server = await listen(createRecordingGateway(requests), "127.0.0.1"); + t.after(() => close(server)); + const env = mockGatewayEnvWithoutModel(serverUrl(server)); + const session = await createSession({ cwd, mode: "interactive", env }); + const events = []; + await runSessionTurn(session, { + prompt: "read all figures", + env, + attachments: [{ type: "document", name: "scan.pdf", data: visualPdf(5, false).toString("base64"), pageStart: 1, pageEnd: 5 }], + onEvent: (event) => { events.push(event); } + }); + const visual = requests.filter((request) => request.messages.some((message) => Array.isArray(message.content) && message.content.some((block) => block.type === "image"))); + assert.equal(visual.length, 3); + const pageImages = visual.flatMap((request) => request.messages.flatMap((message) => Array.isArray(message.content) ? message.content.filter((block) => block.type === "image") : [])); + assert.equal(pageImages.length, 5); + assert.equal(visual.every((request) => request.model === "vision-model"), true); + assert.equal(events.filter((event) => event.type === "pdf_vision_progress" && event.stage === "completed").length, 3); + assert.match(JSON.stringify(requests.at(-1).messages), /pages 5-5 of 5/); + assert.equal(JSON.stringify(session.messages).includes(pageImages[0].data), false); + }); +} + +for (const failure of ["empty", "gateway", "cancel", "unavailable"]) { + test(`PDF vision stops without claiming complete results: ${failure}`, async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-pdf-failure-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const file = path.join(cwd, "five.pdf"); + await fs.writeFile(file, visualPdf()); + let requests = 0; + const server = await listen(http.createServer(async (req, res) => { + for await (const _ of req) {} + requests += 1; + res.writeHead(failure === "gateway" ? 400 : 200, { "content-type": "application/json" }); + res.end(JSON.stringify(failure === "gateway" ? { error: { code: "VISION_FAILED", message: "mock failure" } } : { model: "vision-model", content: [], toolCalls: [], stopReason: "stop" })); + }), "127.0.0.1"); + t.after(() => close(server)); + const env = mockGatewayEnvWithoutModel(serverUrl(server)); + await fs.writeFile(path.join(cwd, "lab-agent.config.json"), JSON.stringify({ modelAlias: "vision-model", models: [{ id: "vision-model", modalities: failure === "unavailable" ? ["text"] : ["text", "image"] }], agents: { vision: { enabled: false } } })); + const session = await createSession({ cwd, mode: "interactive", env }); + const controller = new AbortController(); + if (failure === "cancel") controller.abort(); + const result = await prepareVisionAttachmentsForTurn({ session, pdfDocuments: [{ name: "five.pdf", path: file }], signal: controller.signal, eventOptions: {} }); + assert.equal(result.ok, false); + assert.equal(result.status, failure === "cancel" ? "interrupted" : failure === "unavailable" ? "vision_unavailable" : "vision_error"); + assert.equal(requests, ["cancel", "unavailable"].includes(failure) ? 0 : 1); + }); +} diff --git a/tests/unit/tools.test.ts b/tests/unit/tools.test.ts index f271f3d..aa91018 100644 --- a/tests/unit/tools.test.ts +++ b/tests/unit/tools.test.ts @@ -31,6 +31,8 @@ import { } from "../../src/tools/web-tools.ts"; import { createWorkflowState, syncWorkflowCompletionOnFinal } from "../../src/tools/workflow-tools.ts"; import { listBackgroundAgentTasks } from "../../src/agents/background-registry.ts"; +import { createAgentTaskGroupStore } from "../../src/agents/task-group-store.ts"; +import { createAgentTaskStore } from "../../src/agents/task-store.ts"; import { listBackgroundTerminalTasks } from "../../src/agents/background-terminal-registry.ts"; import { resolveBashInvocation } from "../../src/tools/shell-tools.ts"; @@ -1185,6 +1187,58 @@ test("background agent_run returns task and group immediately", async () => { assert.equal(wakeup?.wakeParent, true); }); +for (const enabledFirst of [true, false]) { + test(`background agent_run wakes a mixed-policy group: enabledFirst=${enabledFirst}`, async (t) => { + const cwd = await makeTempWorkspace(); + const parentSessionId = `mixed-wake-${enabledFirst}`; + const events = []; + t.after(async () => { + await waitFor(async () => listBackgroundAgentTasks({ parentSessionId }).length === 0); + await fs.rm(cwd, { recursive: true, force: true }); + }); + const store = createAgentTaskGroupStore({ cwd }); + await createAgentTaskStore({ cwd }).createTask({ + id: "first", groupId: "mixed", parentSessionId, profile: "explorer", status: "completed", output: "first result" + }); + await store.ensureGroup({ + id: "mixed", parentSessionId, taskIds: ["first"], + waitFor: enabledFirst ? "all" : "none", wakeParent: enabledFirst, wakeReason: "collect results" + }); + const runtime = createToolRuntime({ + cwd, + env: { LAB_AGENT_TRANSCRIPT_ENABLED: "false" }, + config: { + networkMode: "offline", allowedHosts: [], + lab: { gatewayUrl: "", gatewayProtocol: "lab-agent-gateway", gatewayApiKey: null }, + agents: { backgroundWakeup: { enabled: true, autoQueueParentPrompt: true, defaultWaitFor: "all" }, profiles: [] }, + mcp: { servers: [] } + }, + policy: { fullAccess: true }, parentSessionId, + onBackgroundAgentEvent: (event) => events.push(event) + }); + const result = await runtime.execute("agent_run", { + profile: "explorer", taskId: "second", groupId: "mixed", background: true, + waitForGroup: enabledFirst ? "none" : "all", wakeParent: !enabledFirst, + query: "inspect in background" + }); + assert.equal(result.ok, true); + assert.equal(result.result.waitForGroup, "all"); + assert.equal(result.result.wakeParent, true); + assert.equal(result.result.wakePolicyMerged, enabledFirst); + await waitFor(async () => listBackgroundAgentTasks({ parentSessionId }).length === 0); + const read = await store.readGroup("mixed"); + assert.equal(read.ok, true); + if (!read.ok) return; + assert.ok(read.group.wakePromptQueuedAt); + assert.match(read.group.wakePrompt, /first/); + assert.match(read.group.wakePrompt, /second/); + const wakes = events.filter((event) => event.type === "subagent_group_wakeup"); + assert.equal(wakes.length, 1); + assert.equal(wakes[0].waitFor, "all"); + assert.equal(wakes[0].wakeParent, true); + }); +} + test("background agent_run persists a failed child when the gateway fetch fails", async () => { const cwd = await makeTempWorkspace(); const runtime = createToolRuntime({ @@ -2002,7 +2056,9 @@ test("composer document ingest saves an attached PDF and extracts its text layer assert.match(ingested.promptAppendix, /Attached document: paper\.pdf/); assert.match(ingested.promptAppendix, /Page One/); assert.match(ingested.promptAppendix, /Do not glob the workspace/); + assert.match(ingested.promptAppendix, /usable text layer/); assert.equal(ingested.visionImages.length, 0); + assert.equal(ingested.pdfDocuments.length, 0); const saved = await fs.readdir(path.join(cwd, "ant-code-uploads")); assert.equal(saved.some((name) => name.endsWith("paper.pdf")), true); }); @@ -2724,14 +2780,14 @@ function twoPageHelloPdf() { 1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>>>endobj -4 0 obj<>stream -BT /F1 12 Tf 20 100 Td (Page One) Tj ET +4 0 obj<>stream +BT /F1 12 Tf 20 100 Td (Page One has a usable extracted text layer.) Tj ET endstream endobj 5 0 obj<>endobj 6 0 obj<>>>>>endobj -7 0 obj<>stream -BT /F1 12 Tf 20 100 Td (Page Two) Tj ET +7 0 obj<>stream +BT /F1 12 Tf 20 100 Td (Page Two continues the extracted text layer.) Tj ET endstream endobj trailer<>