diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2fca36794..5f0b67d72 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -224,9 +224,11 @@ jobs: FINAL_STATUS="success" FAILURE_REASON="" + NEXT_ACTION="No extra action required." if [[ "${DOCS_ONLY}" == 'true' ]]; then SUMMARY_NOTE="Only docs/markdown changes detected -> quality checks not required." + NEXT_ACTION="No action required for docs-only changes." else SUMMARY_NOTE="Quality checks evaluated against changed areas." @@ -257,26 +259,58 @@ jobs: FAILURE_REASON="gui smoke failed" fi fi + + if [[ -n "${FAILURE_REASON}" ]]; then + if [[ "${FAILURE_REASON}" == "integrity failed" ]]; then + NEXT_ACTION='Run `npm run verify:app-version` and sync package / Cargo / Tauri version files.' + elif [[ "${FAILURE_REASON}" == "frontend failed" ]]; then + NEXT_ACTION='Run `npm run verify:local` (or `npm run lint && npm run typecheck && npm test`) to reproduce the frontend failure locally.' + elif [[ "${FAILURE_REASON}" == "bridge/contracts failed" ]]; then + if [[ ",${BRIDGE_REASONS}," == *",harness_cleanup_contract,"* ]]; then + NEXT_ACTION='Run `npm run harness:cleanup-report:check` first, then `npm run test:contracts`.' + elif [[ ",${BRIDGE_REASONS}," == *",bridge_runtime,"* ]]; then + NEXT_ACTION='Run `npm run test:bridge` first, then `npm run test:contracts`.' + else + NEXT_ACTION='Run `npm run verify:local` or `npm run test:bridge && npm run test:contracts` to reproduce the bridge/contracts failure.' + fi + elif [[ "${FAILURE_REASON}" == "gui smoke failed" ]]; then + NEXT_ACTION='Run `npm run verify:gui-smoke -- --timeout-ms 480000` and inspect DevBridge / headless Tauri readiness.' + fi + fi fi { echo "## Quality Summary" echo - echo "| Item | Value |" - echo "| --- | --- |" - echo "| changed_count | ${CHANGED_COUNT} |" - echo "| docs_only | ${DOCS_ONLY} |" - echo "| bridge_reasons | ${BRIDGE_REASONS_DISPLAY} |" - echo "| integrity | ${INTEGRITY_RESULT} |" - echo "| frontend | ${FRONTEND_RESULT} |" - echo "| bridge_contracts | ${BRIDGE_RESULT} |" - echo "| gui_smoke | ${GUI_SMOKE_RESULT} |" - echo "| final_status | ${FINAL_STATUS} |" + echo "**Final status:** ${FINAL_STATUS}" echo - echo "${SUMMARY_NOTE}" + echo "### Scope" + echo + echo "- changed_count: ${CHANGED_COUNT}" + echo "- docs_only: ${DOCS_ONLY}" + echo "- bridge_reasons: ${BRIDGE_REASONS_DISPLAY}" + echo + echo "### Required Gates" + echo + echo "| Gate | Required | Result |" + echo "| --- | --- | --- |" + echo "| integrity | ${INTEGRITY_REQUIRED} | ${INTEGRITY_RESULT} |" + echo "| frontend | ${FRONTEND_REQUIRED} | ${FRONTEND_RESULT} |" + echo "| bridge_contracts | ${BRIDGE_REQUIRED} | ${BRIDGE_RESULT} |" + echo "| gui_smoke | ${GUI_SMOKE_REQUIRED} | ${GUI_SMOKE_RESULT} |" + echo + echo "### Notes" + echo + echo "- ${SUMMARY_NOTE}" + echo + echo "### Recommended Next Action" + echo + echo "- ${NEXT_ACTION}" if [[ -n "${FAILURE_REASON}" ]]; then echo - echo "Failure reason: ${FAILURE_REASON}" + echo "### Failure" + echo + echo "- ${FAILURE_REASON}" fi } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 1dc0127d2..b32ab6f17 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ docs/roadmap/* !docs/roadmap/artifacts/architecture-blueprint.md !docs/roadmap/artifacts/framework-boundary.md !docs/roadmap/artifacts/system-prompt-and-schema-contract.md +!docs/roadmap/harness-engine/ +!docs/roadmap/harness-engine/README.md +!docs/roadmap/harness-engine/diagrams.md docs/gongzonghao/ docs/bussniss/ docs/oem/ @@ -101,4 +104,4 @@ governance/ !src/lib/governance/*.mjs !src/lib/governance/*.test.ts -src-tauri/crates/aster-rust/target/ \ No newline at end of file +src-tauri/crates/aster-rust/target/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index eb6fb711d..5ed2fb812 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,38 +1,39 @@ -## Lime v1.9.0 +## Lime v1.10.0 ### ✨ 主要更新 -- 本次 `v1.9.0` 已收口当前工作区全部改动,核心集中在 Agent 聊天工作台、General Workbench、Service Skill、Team Workspace、Artifact / Timeline 展示与输入发送主链 -- `src/components/agent/**`、`src/components/workspace/**`、`src/lib/api/**`、`src/features/browser-runtime/**`、`src/components/settings-v2/**` 一批界面、运行时与回归测试已一并进入本次发布 -- 浏览器运行时、现有会话桥接、工具展示、团队协作、项目选择、技能目录、工作台工具命令与内容同步相关边界已同步更新 -- 工程文档 `docs/aiprompts/commands.md`、`playwright-e2e.md`、`quality-workflow.md` 已随当前实现一起更新 +- 本次 `v1.10.0` 重点把 Harness Engine 的验证事实源进一步收口到同一条主链:`evidence / analysis / review / dashboard / cleanup` 现在共享同一套 verification facts 语义,前端 review 与 evidence 展示也开始复用统一的验证结果区块 +- Agent 工作台继续围绕 General Workbench、Harness 状态、Tool Search / Tool Call、Inline Process Step、Message List 与 Review Decision 做交互收敛,工作区输入发送与场景运行时同步补齐了一批回归测试 +- 资源工作台补上图片资源工作台与分类浏览能力,Provider Pool 同步把 Prompt Cache 认知前置到配置 UI,`anthropic-compatible` 渠道与官方兼容 Host 的展示口径进一步统一 +- 仓库治理继续做减法:独立 `terminal / tools / image-gen / video` 页面面已下线,只保留当前主路径需要的运行时与 API 能力,侧边栏与旧页面残留同步清退 +- `docs/roadmap/harness-engine/`、`docs/aiprompts/quality-workflow.md`、`docs/aiprompts/terminal.md`、`docs/aiprompts/providers.md` 等文档已按当前实现刷新,长期路线图与工程边界描述同步更新 -### 🔗 依赖与版本同步 +### 🔗 版本与发布同步 -- `aster-core` / `aster-models` 已内置到 `src-tauri/crates/aster-rust/`,不再依赖外部仓库本地 override -- 应用与 CLI 发布版本提升到 `1.9.0` -- 应用版本入口已对齐到 `1.9.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` -- `packages/lime-cli-npm/package.json`、README 发布示例与本地 `package-lock.json` 已同步到 `1.9.0` -- `src-tauri/Cargo.lock` 已在本轮 Rust 校验后同步更新到当前 workspace 状态 - -### ⚠️ 发布说明 - -- 本次发布 tag 为 `v1.9.0` -- 本次发布以当前工作区完整改动为准,不复用旧 tag -- 当前 release note 已按这次完整发布内容刷新 +- 应用、Rust workspace 与 CLI npm wrapper 版本已统一提升到 `1.10.0` +- 应用版本入口已对齐到 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `package-lock.json`、`src-tauri/Cargo.lock`、`packages/lime-cli-npm/package.json` 与 CLI README 示例已同步到当前版本 +- 本次发布目标 tag 为 `v1.10.0` ### 🧪 已执行校验 - `npm run verify:app-version` -- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` +- `npm test -- src/components/settings-v2/system/about/index.test.tsx` - `cargo test --manifest-path "src-tauri/Cargo.toml"` -- `cargo clippy --manifest-path "src-tauri/Cargo.toml"` +- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前包含 1 条 `clippy::if_same_then_else` 告警,位置在 `src-tauri/crates/core/src/models/provider_pool_model.rs` - `npm run lint` -### 📝 文档同步 +### ⏳ 待执行发布动作 -- 发布说明已更新为当前这次完整的 `v1.9.0` 内容,可直接作为 GitHub Release note 使用 +- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` +- 创建并推送 `v1.10.0` tag +- 推送当前分支到 GitHub + +### 📝 发布说明 + +- 本次发布说明按当前工作区完整改动刷新,重点覆盖 Harness Engine 验证闭环、Agent Workspace 交互收口、资源工作台与 Provider 配置体验,以及旧页面面的治理减法 +- 由于 `cargo fmt --all` 和 `git tag / git push` 具有批量改写或发布风险,当前 release note 已明确把它们标记为待执行动作;完成后可直接作为 GitHub Release note 使用 --- -**完整变更**: `v1.8.0` -> `v1.9.0` +**完整变更**: `v1.9.0` -> `v1.10.0` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 43e7852fc..0c5de7678 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -68,6 +68,7 @@ 同理,聊天运行时初始化的 `aster_agent_init` 在浏览器 DevBridge 模式下也不能再被放进 `mockPriorityCommands`。只要桥接在线,它就必须优先读取后端真实 `provider_name / model_name`,让聊天入口拿到当前运行时模型。 进一步地,围绕运行时模型解析的真相命令:`aster_agent_init`、`get_default_provider`、`get_provider_pool_overview`、`get_api_key_providers`、`get_model_registry`、`get_provider_alias_config`、`fetch_provider_models_auto`、`get_model_registry_provider_ids`,在浏览器 DevBridge 模式下如果桥接失败,必须直接抛错,不能再通过 `safeInvoke` 静默退回 mock;否则前端会把“后端未连上 / 命令失败”误显示成假的 Provider / 模型列表。 同时要明确,`aster_agent_init` 只负责初始化 Agent,并不保证已经完成 Provider 配置;当它未返回 `provider_name / model_name` 时,前端不得把本地硬编码默认值当作真实模型,而应继续回退到 `get_default_provider` + 已配置 Provider/模型注册表解析链,拿到当前工作区真正可用的 `provider/model`。 +同一条约束也适用于 Prompt Cache 能力判断:运行时与前端都不得因为某个自定义 Provider “长得像 Anthropic 协议”就推断它支持官方 Anthropic Automatic Prompt Caching。当前事实源必须继续按 ProviderType 判断:`anthropic` 走自动缓存能力,`anthropic-compatible` 只保留显式 `cache_control` 语义;若上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段。 文档导出链路同样遵循这条路径。当前主入口为 `src/lib/api/document-export.ts`,统一承接: diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 79328b472..06d7f7e30 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -213,6 +213,7 @@ lime/ ### 7. 多 Provider 与兼容层 - OAuth 与 API Key Provider 并存 - 凭证池、模型路由、协议兼容与 HTTP Server 作为底层支撑 +- Prompt Cache 等运行时能力按 ProviderType 判断;`anthropic-compatible` 只表示 Anthropic wire format 兼容,不等于自动 Prompt Cache 能力 ### 8. 本地优先与可扩展 - 桌面应用、本地工作区、插件与外部工具扩展 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index 740ee6aa8..b4c7d9ce8 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -140,10 +140,13 @@ npm run test:contracts 1. 进入 `设置 -> AI 服务商` 2. 确认默认落在 `服务商设置`,左侧能看到 Provider 列表,右侧是当前 Provider 配置 -3. 确认首屏不会默认混入 OEM Offer、套餐或云端模型目录 -4. 点击 `云端服务` -5. 确认 OEM 会话、Offer 卡片、默认来源和模型目录改为在该页单独展示 -6. 如当前环境故意破坏了 `models/index.json`,确认 Provider 模型区会提示“模型真相源异常”,而不是静默显示空态 +3. 如果列表中存在 `anthropic-compatible` Provider,确认左侧会展示 `显式缓存` badge,而不是暗示自动 Prompt Cache +4. 点进该 Provider 后,确认右侧头部仍展示 `显式缓存` badge +5. 进入编辑区后,确认 `Provider 类型 / API Host` 附近会提示“Anthropic 兼容不等于自动 Prompt Cache,需要显式 cache_control” +6. 确认首屏不会默认混入 OEM Offer、套餐或云端模型目录 +7. 点击 `云端服务` +8. 确认 OEM 会话、Offer 卡片、默认来源和模型目录改为在该页单独展示 +9. 如当前环境故意破坏了 `models/index.json`,确认 Provider 模型区会提示“模型真相源异常”,而不是静默显示空态 ### 社媒内容工作流 diff --git a/docs/aiprompts/providers.md b/docs/aiprompts/providers.md index ad84ef974..a30d656bf 100644 --- a/docs/aiprompts/providers.md +++ b/docs/aiprompts/providers.md @@ -128,22 +128,29 @@ anthropic-version: 2023-06-01 ## Prompt Cache 能力边界 -Lime 当前把 Prompt Cache 能力视为 **Provider 类型能力**,而不是“请求长得像哪家协议”: +Lime 当前把 Prompt Cache 能力视为 **Provider 显式声明优先、类型默认兜底**,而不是“请求长得像哪家协议”: -- `anthropic` / `claude` / `claude-oauth`:声明为 `automatic` -- `anthropic-compatible`:声明为 `explicit_only` +- `anthropic` / `claude` / `claude-oauth`:默认 `automatic` +- `anthropic-compatible`:默认 `explicit_only`,但自定义 Provider 可显式声明为 `automatic` - 其它 Provider:默认 `not_applicable` +前台提示层额外保留一个**已知官方 Host 例外**: + +- 对 `https://open.bigmodel.cn/api/anthropic` 这类智谱官方 Anthropic 兼容 Host,Lime 前台不再把它误报成“仅显式缓存” +- 这只影响 UI 提示与 badge 收口,不代表 Lime 会把该 Host 直接等同于 Anthropic `cache_control` 自动注入语义 + 这条事实源当前收敛在: - 前端:`src/lib/model/providerPromptCacheSupport.ts` - 后端:Provider 类型与运行时能力判断链 +- 模型注册表映射:只负责 provider/model 目录归一,不参与 Prompt Cache 能力推断 需要特别注意: 1. `anthropic-compatible` 只表示接入方兼容 Anthropic wire format,不等于上游已经实现 Anthropic Automatic Prompt Caching 2. Lime 不会因为某个自定义渠道“长得像 Anthropic”就默认把它当成官方 Anthropic 自动缓存能力 -3. 对 `anthropic-compatible` 渠道,Lime 只保留显式 `cache_control` 语义;如果上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段 +3. 对自定义 `anthropic-compatible` 渠道,只有在上游明确声明支持 Automatic Prompt Cache 时才应配置为 `automatic` +4. 若未声明自动缓存,Lime 只保留显式 `cache_control` 语义;如果上游没有实现 Automatic Prompt Cache,`cached_input_tokens` 为空不能直接归因到 Lime 没发字段 排查这类问题时,优先确认三件事: diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 07547c3d4..ad1c5c408 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -96,6 +96,7 @@ - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 - 如果目标区域已有 snapshot / 结构化快照机制,沿用现有机制 - 不要因为“只是 UI”就跳过回归 +- 如果改动涉及 Provider 类型切换、Prompt Cache 提示或模型/协议能力认知,至少补到“列表扫描态、详情头部、创建/编辑入口、聊天发送前或结果解释”中的实际受影响落点,避免同一语义只在单点出现 ### 4. 配置与依赖改动必须成组提交 @@ -198,6 +199,8 @@ node scripts/check-generated-slop-report.mjs --input "" 同时,`scripts/report-generated-slop.mjs`、`scripts/check-generated-slop-report.mjs`、`scripts/harness-eval-history-record.mjs`、`scripts/harness-eval-trend-report.mjs`、`scripts/lib/generated-slop-report-core.mjs`、`scripts/lib/harness-dashboard-core.mjs` 这条 harness cleanup/report 主链,在 `verify:local` 的 smart 模式里默认也按 bridge/contracts 风险处理。 本地 `verify:local` 输出里如果看到 `bridge 校验(harness cleanup contract)`,说明命中的就是这条 cleanup/report 契约门禁,而不是普通 DevBridge 变更。 CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge_reasons`,并写入 `GITHUB_STEP_SUMMARY`,用于区分这次是 `harness_cleanup_contract`、`bridge_runtime`,还是 `workflow_full_suite` / `fallback_full_suite` 这类全量触发。 +结果摘要默认按 `Scope / Required Gates / Notes / Recommended Next Action / Failure` 分段,优先让人一眼看清“为什么触发”“哪些门禁必跑”“最终为什么失败”,以及失败后本地最应该先跑哪条命令。 +如果命中的是 `harness_cleanup_contract`,推荐动作应优先指向 `npm run harness:cleanup-report:check`,而不是只给一条泛化的 bridge 校验建议。 作用: @@ -460,6 +463,12 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 资源索引损坏时,GUI 会明确提示“模型真相源异常” - 不会再静默回退数据库或把错误伪装成空模型列表 +如果本轮修改了 Provider 类型与 Prompt Cache 能力边界,还应额外确认: + +- `anthropic-compatible` 不会再被 UI 或运行时误显示成“自动 Prompt Cache” +- Provider Pool 的列表、详情、创建和编辑入口中,受影响落点会继续提示“显式 cache_control” +- 聊天侧 `ModelSelector / Inputbar / MessageList / TokenUsageDisplay` 与 Provider Pool 的口径保持一致 + ### Layer 4:交互型 E2E 入口: diff --git a/docs/aiprompts/terminal.md b/docs/aiprompts/terminal.md index 6bd789902..b29bde372 100644 --- a/docs/aiprompts/terminal.md +++ b/docs/aiprompts/terminal.md @@ -1,8 +1,8 @@ -# 内置终端 +# 终端底层能力 ## 概述 -内置终端模块提供 PTY 管理和会话管理功能。 +Lime 仍保留终端底层能力,用于复用运行时、诊断与会话管理;独立前端 `terminal / sysinfo / files / web` 页面已经下线,不再保留 `src/components/terminal/` 页面模块。 ## 目录结构 @@ -13,9 +13,11 @@ src-tauri/src/terminal/ ├── session.rs # 会话管理 └── commands.rs # 终端命令 -src/components/terminal/ -├── Terminal.tsx # 终端组件 -└── TerminalTabs.tsx # 多标签管理 +src/lib/api/terminal.ts +src/lib/terminal/ +├── store/ # 终端状态与输入态 +├── stickers/ # 终端贴纸状态 +└── vdom/ # VDOM 状态与类型 ``` ## PTY 管理 @@ -49,35 +51,9 @@ impl PtyManager { } ``` -## 前端组件 +## 前端边界 -```tsx -// src/components/terminal/Terminal.tsx -export function Terminal({ sessionId }: { sessionId: string }) { - const termRef = useRef(null); - const xtermRef = useRef(); - - useEffect(() => { - const xterm = new XTerm(); - xterm.open(termRef.current!); - xtermRef.current = xterm; - - // 监听输出 - listen(`terminal-output-${sessionId}`, (event) => { - xterm.write(event.payload); - }); - - // 发送输入 - xterm.onData((data) => { - invoke('terminal_write', { sessionId, data }); - }); - - return () => xterm.dispose(); - }, [sessionId]); - - return
; -} -``` +前端当前只允许通过 `src/lib/api/terminal.ts` 和 `src/lib/terminal/*` 复用终端会话、事件和状态能力,不再新增独立页面壳。 ## Tauri 命令 diff --git a/docs/content/01.introduction/2.installation.md b/docs/content/01.introduction/2.installation.md index f64ab0c8f..2d6ffad2a 100644 --- a/docs/content/01.introduction/2.installation.md +++ b/docs/content/01.introduction/2.installation.md @@ -59,7 +59,7 @@ Lime 当前仅提供 macOS 与 Windows 桌面端安装包,Linux 版本已暂 启动 Lime 后,你应该看到: 1. 主窗口正常打开 -2. 左侧出现主要入口(AI Agent、项目、资源、图片生成等) +2. 左侧出现主要入口(AI Agent、项目、资源、设置等) 3. 可以进入设置页并看到版本信息 ## 常见安装问题 diff --git a/docs/content/01.introduction/3.quickstart.md b/docs/content/01.introduction/3.quickstart.md index e3c028841..ef11f9b0d 100644 --- a/docs/content/01.introduction/3.quickstart.md +++ b/docs/content/01.introduction/3.quickstart.md @@ -26,7 +26,7 @@ navigation: 1. 用一句话描述你的目标 2. 让 Agent 先给结构,再生成首稿 -3. 如需视觉内容,进入图片生成功能继续产出与迭代 +3. 如需视觉内容,在 AI Agent 中用 `@素材` 搜图或触发图片生成,再把结果沉淀到资源库 ## 步骤 3:沉淀到资源库 @@ -51,7 +51,7 @@ navigation: ### 我可以直接改图吗? -可以。上传参考图后,若所选模型支持编辑接口,会自动走编辑链路。 +可以。先在资源库上传参考图,再从 Claw 发起图片任务;若所选模型支持编辑接口,会自动走对应链路。 ### 我还需要排查底层协议怎么办? @@ -61,4 +61,4 @@ navigation: - [首页与工作台](/user-guide/dashboard) - 理解核心导航 - [资源库](/user-guide/resources) - 管理创作资产 -- [图片生成与编辑](/user-guide/image-generation) - 深入图片链路 +- [图片生成与素材链路](/user-guide/image-generation) - 理解 Claw 与资源页如何协同 diff --git a/docs/content/02.user-guide/1.dashboard.md b/docs/content/02.user-guide/1.dashboard.md index 38f70e8d6..d7b4185c4 100644 --- a/docs/content/02.user-guide/1.dashboard.md +++ b/docs/content/02.user-guide/1.dashboard.md @@ -15,15 +15,14 @@ navigation: - **AI Agent**:对话、任务推进、内容初稿 - **项目**:按创作目标管理长期内容 - **资源**:统一查看文档、图片、语音、视频 -- **图片生成**:生成图片、参考图编辑、结果回流资源库 - **设置**:调整主题、模块开关、连接与高级选项 ## 推荐工作方式 1. 先在项目中选择一个主题方向 2. 在 AI Agent 中完成结构和首稿 -3. 需要视觉时进入图片生成 -4. 回到资源库统一管理结果 +3. 需要视觉时在 AI Agent 中使用 `@素材` 或图片任务 +4. 回到资源库统一管理本地图片、图库素材与回流结果 ## 主题方向 diff --git a/docs/content/02.user-guide/12.settings.md b/docs/content/02.user-guide/12.settings.md index 10594aced..4679c1de2 100644 --- a/docs/content/02.user-guide/12.settings.md +++ b/docs/content/02.user-guide/12.settings.md @@ -23,7 +23,7 @@ navigation: 你可以按使用习惯定制入口: - 启用或停用工作区主题(如社媒、短视频、小说) -- 启用或停用导航模块(如 AI Agent、项目、图片生成、终端、工具、插件) +- 启用或停用导航模块(如 AI Agent、项目、资源、设置、插件) 这样可以让侧边栏更聚焦,减少干扰。 @@ -49,7 +49,7 @@ navigation: ### 个人创作者 -- 保留:AI Agent、项目、资源、图片生成 +- 保留:AI Agent、项目、资源 - 关闭:暂时不用的高级模块 - 目的:让工作台聚焦在“日常产出” diff --git a/docs/content/02.user-guide/14.resources.md b/docs/content/02.user-guide/14.resources.md index 81ef6a204..cce5f23b5 100644 --- a/docs/content/02.user-guide/14.resources.md +++ b/docs/content/02.user-guide/14.resources.md @@ -41,9 +41,15 @@ navigation: ## 资源与创作联动 -### 从图片生成回流资源库 +### 从 Claw 与图片任务回流资源库 -在图片生成页选择目标资源库后,成功生成的图片可自动写入当前项目。 +在 Claw 中发起图片生成任务,或从图片任务结果执行入库后,成功生成的图片会自动写入当前项目。 + +资料库的图片视图还统一承接: + +- 本地图片上传 +- 我的图片库浏览 +- 选图后插入当前画布 ### 从资源继续对话创作 diff --git a/docs/content/02.user-guide/15.image-generation.md b/docs/content/02.user-guide/15.image-generation.md index 513494d81..3847274e1 100644 --- a/docs/content/02.user-guide/15.image-generation.md +++ b/docs/content/02.user-guide/15.image-generation.md @@ -1,55 +1,76 @@ --- -title: 图片生成与编辑 -description: 通过文本与参考图完成图片生成、编辑与资产沉淀 +title: 图片生成与素材链路 +description: 通过 Claw 与资源库完成搜图、图片生成、编辑与资产沉淀 navigation: icon: i-heroicons-photo --- -# 图片生成与编辑 +# 图片生成与素材链路 -图片生成页用于完成从“文字描述”到“可用图片素材”的全过程。 +Lime 不再把图片能力拆成独立页面。 +现在的事实源是: + +- Claw:负责联网搜图、图片生成、参考图编辑和任务推进 +- 资源库:负责本地图片上传、我的图片库浏览、结果沉淀和插图复用 +- 设置:负责图片模型、Provider 与联网搜图 Key 配置 ## 基本流程 -1. 选择模型与参数(尺寸、比例、数量) -2. 输入提示词 -3. 可选上传参考图 -4. 生成后选图并沉淀到资源库 +1. 在 AI Agent 中明确视觉目标 +2. 需要找参考图时,用 Claw `@素材` 进行联网搜图 +3. 需要生成或编辑图片时,在 Claw 发起对应图片任务 +4. 结果自动或手动沉淀到资源库 +5. 在资源库图片视图继续筛选、上传、插图或复用 -## 参考图与编辑 +## 联网搜图与生成 -### 上传参考图 +### 联网搜图 -可上传参考图作为创作输入,帮助模型更贴近目标风格或构图。 +当你需要灵感图、风格参考或可复用素材时: -### 编辑链路 +- 在 Claw 中使用 `@素材` +- 联网图片搜索结果会以任务结果或素材候选的形式返回 +- 选中的图片可以继续进入正文插图、封面或图片任务链路 -当模型支持图片编辑接口时,系统会优先尝试编辑端点; -若不可用,会自动回退到可用生成端点,尽量保障出图成功率。 +### 图片生成与编辑 -## 历史记录 +当你已经明确提示词或参考图后: -历史区域会保存你的生成记录,支持: +- 在 Claw 发起图片生成任务 +- 如模型支持参考图编辑,系统会优先走编辑链路 +- 若某条接口不可用,运行时会回退到可用生成链路,尽量保障出图成功率 -- 查看单张或批次结果 -- 重新选择目标图继续迭代 -- 将历史结果补录到资源库 +## 本地图片与我的图片库 + +本地图片与历史沉淀图片已经统一收口到资源库图片视图,你可以在这里: + +- 上传本地图片 +- 浏览“我的图片库” +- 选图后直接插入当前画布 +- 在当前项目下统一管理图片资产 ## 与资源库联动 -### 目标资源库 +### 结果回流 -生成前可指定目标资源库(项目),用于自动沉淀图片资产。 +图片任务会根据当前项目和资源库选择自动回流;如果需要,也可以在结果完成后再手动入库。 -### 补录历史 +### 插图复用 -如果历史图片尚未入库,可使用“补录历史到资源库”进行批量回填。 +进入资源库的图片可以直接被当前画布复用,不需要再回到旧图片页面挑选。 + +## 配置入口 + +相关配置分布在两个位置: + +- 图片模型与默认策略:设置中的媒体服务配置 +- 联网图片搜索 Key:设置中的网络搜索配置 ## 实用建议 ### 先定方向再出图 -先在 AI Agent 里明确画面目标,再进入图片生成功能,会减少无效尝试。 +先在 AI Agent 里明确画面目标,再决定是 `@素材` 搜图还是直接发起图片任务,会减少无效尝试。 ### 一次只改一个变量 @@ -57,4 +78,4 @@ navigation: ### 把可用版本及时入库 -选中可用图片后尽快入库,方便后续在资源页检索和复用。 +选中可用图片后尽快入库,方便后续在资源页检索、插图和复用。 diff --git a/docs/content/05.troubleshooting/1.common-issues.md b/docs/content/05.troubleshooting/1.common-issues.md index 1e2671e28..880ace7e9 100644 --- a/docs/content/05.troubleshooting/1.common-issues.md +++ b/docs/content/05.troubleshooting/1.common-issues.md @@ -48,8 +48,8 @@ navigation: ### 处理建议 -- 回到图片生成页确认目标资源库 -- 对历史结果执行“补录到资源库” +- 先确认当前项目与资源库选择一致 +- 回看 Claw 中对应图片任务是否已经成功并完成入库 - 回资源页切换“全部”核对总量 ## 生成失败或超时 diff --git a/docs/content/index.md b/docs/content/index.md index a5bcd57b5..aa83f38eb 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -7,7 +7,7 @@ navigation: false # Lime 文档中心 Lime 是创作类 AI Agent 平台。 -你可以在同一个工作台里完成对话、创作、图片生成、项目沉淀与资源复用。 +你可以在同一个工作台里完成对话、创作、Claw 素材与图片任务、项目沉淀和资源复用。 ## 从这里开始 @@ -34,7 +34,7 @@ Lime 是创作类 AI Agent 平台。 - [首页与工作台](/user-guide/dashboard) - [资源库](/user-guide/resources) - [运行时 AGENTS 规则](/user-guide/runtime-agents) -- [图片生成与编辑](/user-guide/image-generation) +- [图片生成与素材链路](/user-guide/image-generation) - [设置](/user-guide/settings) - [插件中心](/user-guide/plugins) diff --git a/docs/roadmap/harness-engine/README.md b/docs/roadmap/harness-engine/README.md new file mode 100644 index 000000000..caf49225f --- /dev/null +++ b/docs/roadmap/harness-engine/README.md @@ -0,0 +1,395 @@ +# Lime Harness Engine 对照路线图与长期检查表 + +> 状态:进行中,P0 已完成首刀收口 +> 更新时间:2026-04-13 +> 对照基线:LangChain 博文《The Anatomy of an Agent Harness》 +> 目标:把 Lime 当前已经具备的 Harness 能力、真实缺口、后续建设优先级和长期复查口径收敛到一份可执行文档,而不是继续停留在抽象口号层。 + +配套图纸: + +- `docs/roadmap/harness-engine/diagrams.md` + +## 1. 先给结论 + +按 LangChain 这篇文章的标准看,Lime **已经不是“只有模型壳”的产品**,而是已经具备较完整 Harness 底座的 Agent 工作台。 + +但更准确的判断不是“已经完全成熟”,而是: + +- **底座型 Harness:已基本成形** +- **闭环型 Harness:仍是部分完成** +- **长时自治型 Harness Engine:还没有完全收口** + +一句话总结: + +**Lime 当前最大的短板,不是“没有工具”或“没有运行时”,而是“证据闭环、长期执行闭环、动态装配闭环还不够强”。** + +--- + +## 2. 本文使用的判断标准 + +LangChain 这篇文章把 Harness 定义为: + +> 模型之外的一切代码、配置、执行环境、工具、约束、状态与编排逻辑。 + +因此本文不只看 prompt,也不只看 tool 数量,而是按下面这些维度对 Lime 做判断: + +1. 系统提示词与规则注入 +2. 文件系统与 durable state +3. Bash / code execution +4. sandbox / approval / execution policy +5. tools / skills / MCP / browser runtime +6. memory / search / AGENTS 注入 +7. context rot 治理 +8. long-horizon execution +9. verification / replay / review / evidence +10. just-in-time tool/context assembly +11. trace-driven harness self-improvement + +--- + +## 3. Lime 当前总判断 + +### 3.1 已经成立的部分 + +Lime 当前已经明确具备以下 Harness 基础设施: + +- system prompt 与 memory prompt 注入 +- workspace / filesystem / artifact 持久化边界 +- bash 与通用代码执行入口 +- sandbox / approval / restriction profile +- skills / MCP / browser / workspace tools +- 子代理委派、handoff、evidence、replay 基础链 +- context compaction、tool output compression、tool io offload + +这说明 Lime 的主问题已经不是“缺零件”,而是“怎样把这些零件收敛成更强的闭环”。 + +### 3.2 仍然偏弱的部分 + +Lime 当前仍然缺少下面三类关键闭环: + +1. **证据闭环不够强** + `runtime -> evidence -> verification outcome -> review -> regression -> promote` + 这条链已经有雏形,但还没有形成默认强约束。 + +2. **长时执行闭环不够强** + 当前已有 queue / resume / provider continuation / auto continue / subagent,但还没有把“任务未完成时必须继续推进到完成标准”变成统一的 runtime 纪律。 + +3. **动态装配闭环不够强** + 当前有 catalog、surface、skill progressive disclosure,但 tool/context 仍偏“预配置”,而不是更强的 per-turn JIT 组装。 + +--- + +## 4. 对照矩阵 + +| LangChain Harness 能力 | Lime 当前状态 | 当前事实源 | 结论 | +| ------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| System Prompts / 规则注入 | 已落地 | `src-tauri/src/services/memory_profile_prompt_service.rs`、`src-tauri/src/services/memory_source_resolver_service.rs` | Lime 已把 profile、memory source、project rule 注入到 system prompt 主链,不是裸 prompt 模式 | +| Filesystem / Durable Storage | 已落地 | `docs/aiprompts/overview.md`、`src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs` | Workspace、artifact、项目目录、文件工具都已经进入主链 | +| Bash / Code Execution | 已落地 | `src-tauri/src/agent_tools/catalog.rs`、`src-tauri/src/agent_tools/execution.rs` | Lime 已具备通用执行能力,不依赖“预先定义完所有工具” | +| Sandbox / Approval / Policy | 已落地 | `src-tauri/src/agent_tools/execution.rs`、`docs/aiprompts/commands.md` | restriction profile、sandbox profile、warning policy 都已进入 runtime 主链 | +| Tools / Skills / MCP / Browser | 已落地 | `docs/aiprompts/skill-standard.md`、`docs/aiprompts/command-runtime.md`、`src-tauri/src/agent_tools/catalog.rs` | Lime 已有较完整 capability surface,不是单一 chat tool 模型 | +| Memory / Search / AGENTS 注入 | 已落地 | `src-tauri/src/services/memory_source_resolver_service.rs`、`docs/aiprompts/overview.md` | 记忆与规则文件已进入生产链,Web/MCP/search 也已存在 | +| Context Rot 治理 | 部分落地 | `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src-tauri/crates/aster-rust/crates/aster/src/context_mgmt/mod.rs`、`src-tauri/crates/agent/src/tool_io_offload.rs`、`src-tauri/crates/aster-rust/crates/aster/src/context/compressor.rs` | 已有 compact、tool output compression、tool offload,但产品侧可见性与默认治理还不够强 | +| Long-Horizon Execution | 部分落地 | `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs`、`src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs`、`src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` | 已有 auto continue、provider continuation、subagent、queue/resume,但还没形成统一 completion loop | +| Verification / Replay / Review | 部分落地 | `docs/aiprompts/harness-engine-governance.md`、`src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs`、`src/lib/agentRuntime/harnessVerificationPresentation.ts`、`src-tauri/src/services/runtime_review_decision_service.rs` | evidence / replay / analysis / review 已成链;前端 verification 展示语义已收敛到共享 helper,review template 也开始直接携带同一份 structured verification summary,但验证结果尚未成为所有后续动作的默认硬约束 | +| Just-in-Time Tool / Context Assembly | 待加强 | `src-tauri/src/agent_tools/catalog.rs`、`docs/aiprompts/skill-standard.md`、`docs/aiprompts/command-runtime.md` | 已有 surface/profile/skill progressive disclosure,但仍偏静态 catalog,不够按任务即时裁剪 | +| Trace-Driven Harness Self-Improvement | 待建设 | `docs/aiprompts/harness-engine-governance.md`、现有 evidence/replay/export 主链 | 已经具备取证底座,但还未形成“基于 trace 自动发现缺口并推进治理”的稳定平台能力 | + +--- + +## 5. 关键事实源与它们分别证明了什么 + +### 5.1 Prompt / Memory / Rules + +- `src-tauri/src/services/memory_profile_prompt_service.rs` + 证明 Lime 已把用户画像与 memory prompt 合并进 system prompt,而不是只靠前端临时拼接。 +- `src-tauri/src/services/memory_source_resolver_service.rs` + 证明 Lime 已支持 user memory、durable memory、project rule、多层目录记忆来源解析。 + +### 5.2 Workspace / Tool Surface / Execution + +- `src-tauri/src/agent_tools/catalog.rs` + 证明 Lime 已有 tool catalog、surface profile、capability、lifecycle、permission plane 这些 Harness 级抽象。 +- `src-tauri/src/agent_tools/execution.rs` + 证明 Lime 已把 warning policy、restriction profile、sandbox profile 做成统一执行策略,而不是 scattered 规则。 +- `src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs` + 证明 workspace tool 不只是文件读写,还承担 output summary、metadata、observability 编码职责。 + +### 5.3 Skills / Scene / Browser / MCP + +- `docs/aiprompts/skill-standard.md` + 证明 Lime 对 skill 的理解已经是 bundle,而不是单一 Markdown 提示词。 +- `docs/aiprompts/command-runtime.md` + 证明 Lime 已把 `@`、`/`、`scene`、`ServiceSkill`、tool/runtime binding 做成明确产品主链。 +- `docs/aiprompts/overview.md` + 证明 browser runtime、plugin、MCP、terminal、artifact、workspace 都已进入总架构。 + +### 5.4 Context Rot / Offload / Continuation + +- `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` + 证明 Lime 已有 `agent_runtime_compact_session`、resume thread、thread read model、evidence export 这类 runtime 操作主链。 +- `src-tauri/crates/aster-rust/crates/aster/src/context_mgmt/mod.rs` + 证明 Aster 已有 continuation message 与 compact 后续写逻辑,Lime 不是完全没有 continuation。 +- `src-tauri/crates/aster-rust/crates/aster/src/context/compressor.rs` + 证明 tool output 已有 head/tail compression。 +- `src-tauri/crates/agent/src/tool_io_offload.rs` + 证明 Lime 已有通用 tool arguments/results offload、preview、eviction policy 与 `offload_file` 协议。 +- `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` + 证明 Lime 已有 auto continue、provider continuation state 恢复与 runtime 级 continuation 配置。 + +### 5.5 Evidence / Replay / Review / UI + +- `docs/aiprompts/harness-engine-governance.md` + 证明 Lime 已明确要求 evidence pack 作为事实源,replay / analysis / review / UI 都应复用它。 +- `src/lib/agentRuntime/harnessVerificationPresentation.ts` + 证明前端 verification label / variant / description 已开始从 `HarnessStatusPanel` 本地解释收敛到共享 helper,GUI 消费层不再各自维护一套语义。 +- `src-tauri/src/services/runtime_review_decision_service.rs` + 证明 review decision 模板不再只携带 failure / recovered 文本列表,而开始直接透传 structured verification summary,review 消费层可以继续复用 evidence 同一份事实。 +- `src/components/agent/chat/components/HarnessStatusPanel.tsx` + 证明前端已经能消费 evidence pack,并开始在 evidence / review 两个消费面直接复用共享 verification presentation helper;但展示仍偏状态卡,不是完整治理闭环。 + +--- + +## 6. 当前进度看板 + +### 6.1 按能力维度统计 + +- 已落地:6 项 +- 部分落地:3 项 +- 待加强:1 项 +- 待建设:1 项 + +### 6.2 按建设层次统计 + +| 层次 | 当前状态 | 说明 | +| ------------ | -------- | ------------------------------------------------------------------------------------ | +| 底座层 | 高 | prompt、memory、workspace、tool、sandbox、subagent、artifact 都已进入现役主链 | +| 运行时治理层 | 中高 | catalog、execution policy、compact、offload、evidence 已存在,但默认动作链还不够统一 | +| 闭环验证层 | 中 | replay / review / evidence 已有,verification outcome 到修复决策还不够强绑定 | +| 长时自治层 | 中 | continuation / queue / resume / subagent 已有,但 completion loop 仍偏弱 | +| 自我改进层 | 低 | 已能导出 trace 和证据,但还没有稳定的 trace-driven governance 平台 | + +### 6.3 本文建议的总体评级 + +- **当前阶段评级:B** +- **更准确描述:Harness 底座较强,闭环能力中等,自治能力未完全收口** + +### 6.4 本轮已落地 + +- `agent_runtime_export_evidence_pack` 现已把 `observabilitySummary` 直接返回到前端消费层,而不再只埋在导出文件里。 +- `observabilitySummary.verificationSummary` 现已补充显式 outcome,以及失败 / 恢复焦点列表。 +- `HarnessStatusPanel` 现已开始直接展示验证结果、失败焦点和恢复结果,不再只显示 `known_gaps`。 +- `analysis handoff` 现已开始显式携带 verification failure / recovered outcomes,外部诊断不再只看到 gap signals。 +- `review decision` 模板现已复用 analysis-context 里的 verification failure / recovered outcomes,人工审核不再只靠简报文字猜测。 +- `runtime_review_decision_service` 现已补上定向回归测试,覆盖“非空 recovered outcomes 从 evidence / analysis 透传到 review decision”的主链守卫,避免 review 层退回空结果假绿。 +- `harness-eval-runner` 现已把 `currentRecoveredObservabilityVerificationOutcomes` 与 `currentRecoveredVerificationCaseCount` 作为 summary 一级事实导出,trend / cleanup / dashboard 不再只能从 `currentObservabilityVerificationOutcomes` 二次筛 recovered。 +- `harness-eval-history-record` 现已优先复用 `summary.breakdowns/totals` 与 `trend.classificationDeltas/latest.totals` 里的 verification facts 来写入 failure / recovered 摘要,只把 cleanup 保留为兼容兜底,不再让历史记录层反向依赖 cleanup 作为事实源。 +- `scripts/lib/harness-verification-facts.mjs` 现已成为 cleanup core / history record / dashboard 共用的 verification role 判定边界,`blocking_failure / advisory_failure / recovered` 不再在多个脚本里各自维护一套常量与判断。 +- `harness-dashboard-core` 现已优先直接消费 `trend.classificationDeltas/latest.totals` 与 `summary.breakdowns` 来渲染 verification 统计卡和 focus table,只把 cleanup 保留给 recommendations / governance / doc freshness 这些真正属于 cleanup 的派生面。 +- `generated-slop-report-core` 现已把 verification focus 选择、current/degraded/recovered 切分和 summary 组合收回 `harness-verification-facts` 共享 helper,不再在 cleanup core 内部重复维护一套“从 trend classification deltas 推导 verification 视图”的本地逻辑。 +- `generated-slop-report-core` 中原本私有的 verification follow-up 规则,现也已收回 `scripts/lib/harness-verification-facts.mjs` 共享 helper;cleanup recommendation 只负责编排 P0/P1/P2 动作,不再自己维护 `guiSmoke/browserVerification/artifactValidator` 的补证据与回归语义。 +- `harness-eval-history-record` 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享推导来生成 failure focus、current recovered baseline 和 case counts;history-record 不再自己维护一套 failure/recovered 焦点挑选与 cleanup fallback 计数逻辑。 +- `harness-dashboard-core` 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享推导来生成 verification focus rows、current recovered baseline 与说明文案;dashboard 不再自己维护一套“trend / summary / cleanup 三选一”的 verification 视图拼装逻辑。 +- `generated-slop-report-core` 的 signals / text output 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 compact formatter;cleanup report 不再自己手写 `signal (outcome)` 标签格式,避免 recommendation、signals、dashboard 三处名称再度漂移。 +- `generated-slop-report-core` 的 verification summary signals 现在也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 summarizer;cleanup 不再自己维护 failure / advisory / recovered / degraded baseline 的摘要句式,避免 signals、review 口径和后续展示再次漂移。 +- `generated-slop-report-core` 的 recommendation rationale 里涉及 verification 的 blocking / advisory / recovered 摘要片段,现也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 builder;cleanup recommendation 不再自己维护 verification 解释句模板。 +- `generated-slop-report-core` 的 `observability-evidence-follow-up` 里原本混合 verification / observability 的 rationale 与 backlog 文案,现也已改为复用 `scripts/lib/harness-verification-facts.mjs` 的共享 builder;cleanup recommendation 进一步退回“只编排、不解释”的消费层角色。 +- `src/lib/agentRuntime/harnessVerificationPresentation.ts` 现已成为前端 verification label / badge / description 的共享展示边界,`HarnessStatusPanel` 不再自己维护 `blocking_failure / advisory_failure / recovered` 的中文文案与说明句式。 +- `analysis-brief.md` 现已直接从 `observability.summary.verificationSummary` 生成紧凑的结构化验证摘要,外部 AI 先读 brief 时就能看到 `Artifact / Browser / GUI Smoke` 的同源 outcome 与统计,不必等到再下钻 `analysis-context.json`。 +- `runtime_review_decision_service` 现已把 `analysis-context.json` 中的 structured verification summary 一并透传到 review template / review-decision.json,review 面不再只剩 failure / recovered 文本列表。 +- `runtime_review_decision_service` 现已开始基于 `verification_summary + failure/recovered outcomes` 预填 review template 的默认 `followup_actions / regression_requirements`;阻塞失败会直接回挂到 replay / evidence / browser / GUI smoke 等默认动作,而不是继续留空等人工从零编排。 +- `runtime_review_decision_service` 现已把 review template 默认动作进一步收口到 verification facts 共享语义:Artifact / Browser / GUI Smoke 的 follow-up 与 regression requirement 现在直接镜像 cleanup helper 的动作链,review 不再继续维护另一套手写句式。 +- 前端 tauri mock、API 归一化测试与 `HarnessStatusPanel` 现也已对齐这组 facts-based 默认动作,浏览器 mock / 本地 UI 回归不再停留在“review 模板始终空白动作”的旧语义。 +- `HarnessStatusPanel` 的 review decision 区块现已与 evidence pack 区块复用同一段 verification summary 展示,不再在 review 面再维护一套独立的 verification UI 解释。 +- `RuntimeReviewDecisionDialog` 现已直接复用同一份 `HarnessVerificationSummarySection`,reviewer 在真正填写审核结论时看到的 verification facts 与 evidence / review 面板保持同源,不再在对话框里丢失事实基线。 +- `review-decision.md` 现已直接从 `verification_summary` 生成紧凑的结构化验证摘要,人工审核产物本身也能看到 `Artifact / Browser / GUI Smoke` 的同源 outcome 与统计,不再只剩 failure / recovered 文本列表。 + +这意味着 Phase A 已从“只有 evidence 文件里有事实”推进到“evidence、analysis、review、GUI 展示开始共享同一份 verification facts”。 + +--- + +## 7. 最关键的缺口,不要再发散 + +### 缺口 1:Verification 还没有真正控制后续动作 + +当前 Lime 已有: + +- evidence pack +- replay case +- analysis handoff +- review decision template +- GUI smoke / contracts / quality workflow + +但仍缺: + +- 统一的 verification outcome 模型,直接控制 review / promote / cleanup 优先级 +- 失败后默认回挂到“补验证 / 重放 / 修复 / 再验证”的固定动作链 +- promote / queue continuation / runtime action executor 还没有直接消费这组 outcome,verification 仍未真正成为统一动作调度器 + +这意味着 Lime 已经能“看见问题”,但还没有完全做到“看见问题以后所有后续动作都按同一事实推进”。 + +### 缺口 2:Long-horizon completion loop 还不够硬 + +当前 Lime 已有: + +- queue / resume +- subagent runtime +- provider continuation +- auto continue +- compact / overflow recovery + +但仍缺: + +- 明确的 completion goal 与 exit criteria +- 更强的“未完成不得退出”统一 runtime 纪律 +- 长任务中计划、验证、恢复、交接的标准化闭环 + +这意味着 Lime 现在更像“支持长任务”,还不完全像“强约束地把长任务做完”。 + +### 缺口 3:Tool / Context 仍偏静态装配 + +当前 Lime 已有: + +- tool surface profile +- skill progressive disclosure +- command runtime 场景分型 +- scene / ServiceSkill / browser assist 等收口规则 + +但仍缺: + +- per-turn 动态组装工具面 +- 基于任务类型裁剪 detour tools 的统一机制 +- evidence 驱动的动态上下文注入,而不是更多静态预配 + +这意味着 Lime 已经知道“哪些能力存在”,但还没有稳定做到“当前任务只拿到真正需要的那一组能力和上下文”。 + +--- + +## 8. 接下来只优先做这 3 件事 + +### P0:把 Verification Outcome 提升成 Harness 一级事实 + +目标: + +- 让 `evidence pack -> replay -> analysis -> review -> cleanup -> dashboard -> UI` 全部消费同一份 verification outcome + +最低动作: + +- 统一 verification outcome 字段,不允许下游自己再拼第二套真假判断 +- 区分 `current gap`、`degraded gap`、`not_applicable` +- 让 review / promote / cleanup 的推荐动作只基于同一份 outcome 计算 + +完成标准: + +- 同一线程的 failure / recovered / advisory 状态,在 evidence、review、cleanup、UI 中不再出现语义漂移 + +### P1:把 Long-Horizon 执行从“支持”升级为“约束” + +目标: + +- 让 Lime 对复杂任务不只是“可以继续”,而是“默认会继续直到满足完成标准” + +最低动作: + +- 给复杂任务补 completion goal / done criteria +- 把 auto continue、provider continuation、queue resume、subagent handoff 接成统一策略 +- 让中断、恢复、交接、继续执行都能回挂到同一条 runtime 事实链 + +完成标准: + +- 长任务出现暂停、压缩、续跑、交接时,仍能在同一 session 语义内解释“还差什么、为什么继续、何时结束” + +### P2:把 Tool / Context 装配从 catalog 驱动升级为 task 驱动 + +目标: + +- 让 runtime 在发起 turn 时更像“装配能力包”,而不是“打开一个大工具箱” + +最低动作: + +- 按任务类型定义基础 tool surface 模板 +- 对图片、浏览器、站点、分析、转写、研究等场景,建立 detour tool 剔除规则 +- 把 skill、memory、browser preload、verification context 统一成更强的 JIT 注入模型 + +完成标准: + +- 当前任务不再默认暴露明显无关的 tool,且上下文噪音可被稳定压低 + +--- + +## 9. 分阶段演进路线 + +| 阶段 | 目标 | 状态 | 备注 | +| ------- | ----------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Phase A | Harness 事实源收敛 | 进行中 | `evidence pack / replay / analysis / review` 已成链;verification summary 已回挂到前端导出结果、状态面板与 review template,`HarnessStatusPanel` 的 evidence / review verification 展示语义也已收敛到 `src/lib/agentRuntime/harnessVerificationPresentation.ts`,review decision 已补 structured summary 透传,eval runner 也已导出 current recovered verification 一级 breakdown | +| Phase B | Context rot 治理产品化 | 进行中 | compact、compression、offload 已有;下一步是让 UI、review、自动动作全部懂这些信号 | +| Phase C | Long-horizon 执行约束化 | 未完成 | continuation 能力存在,但 completion loop 还没成为平台纪律 | +| Phase D | JIT 装配与场景裁剪 | 未完成 | 现阶段仍偏 static catalog + 手工场景约束 | +| Phase E | Trace-driven self-improvement | 未完成 | 目前取证能力具备,但还没形成长期治理平台 | + +--- + +## 10. 长期检查表 + +这部分不是“建议”,而是后续每轮治理都应该复查的口径。 + +### 10.1 每次改 Harness Runtime 都检查 + +1. 是否继续只有一个事实源,还是又在 UI / analysis / replay 里拼了第二套真相? +2. 新增信号是否区分了 `exported / not_applicable / degraded / missing`? +3. 新增能力是否落在 `current` 主链,而不是又扩了一条 compat 旁路? +4. prompt、tool、sandbox、runtime metadata、UI 展示是否仍是同一条 contract? + +### 10.2 每周检查 + +1. evidence pack 与 review template 是否存在字段漂移 +2. known gaps 是否还在错误地把 `not_applicable` 当缺口 +3. `output_truncated` 与 `offload_file` 是否能在前端稳定消费 +4. context compaction 是否仍能在 thread read / replay / analysis 中一致呈现 +5. 子代理、queue、resume、handoff 是否仍按同一 session 语义工作 + +### 10.3 每月检查 + +1. 哪些工具在当前任务中是长期噪音源,应该被 JIT 剔除 +2. 哪些验证已经真实发生,哪些只是文档里提到但没进入 evidence 主链 +3. 哪些 replay case 无法稳定复现,需要补环境、artifact 或 telemetry +4. 哪些 compat / deprecated surface 仍在偷偷长新逻辑 +5. 哪些 HarnessStatusPanel、cleanup report、dashboard 文案与后端事实不一致 + +### 10.4 每季度检查 + +1. 长任务完成率是否提高,而不是只提高“能力数量” +2. 验证失败后是否更快回挂到补证据、补回放、补修复、补回归 +3. 工具面是否比上季度更轻,而不是更重 +4. 取证与治理链是否减少了人工判断分歧 +5. 是否还在新增并行事实源、旁路协议、临时兼容层 + +--- + +## 11. 平台治理红线 + +后续只要出现下面任一情况,都应视为 Harness Engine 治理倒退: + +1. 在 `analysis / replay / review / UI` 各自重新拼装第二套 runtime 真相 +2. 为了图省事,把所有线程都写成同一种 known gap 模板 +3. 在 `compat / deprecated` 路径继续长新功能 +4. 为了“多给模型一点能力”,默认暴露更多无关工具和上下文 +5. verification 没真实发生,却在证据层假装发生过 +6. evidence 已经修正,展示层和治理层仍沿用旧字段、旧语义 + +--- + +## 12. 对 Lime 的最终定位 + +Lime 后续不应该把自己建设成“更多工具的聊天壳”,而应该明确建设成: + +**一个以 workspace、artifact、verification、evidence、review 和长期治理为中心的 Harness Engine 平台。** + +换句话说,Lime 的长期竞争力不在“会不会调模型”,而在: + +- 是否能把模型接入稳定的执行环境 +- 是否能把任务过程沉淀成可追溯证据 +- 是否能把失败变成可修复、可回放、可治理的工程对象 +- 是否能在长期演进中减少而不是放大系统熵 + +这才是 Lime 后续对齐 Claude Code / Codex / LangChain Harness 思路时,真正应该抓住的主线。 diff --git a/docs/roadmap/harness-engine/diagrams.md b/docs/roadmap/harness-engine/diagrams.md new file mode 100644 index 000000000..49a4a6a7d --- /dev/null +++ b/docs/roadmap/harness-engine/diagrams.md @@ -0,0 +1,146 @@ +# Lime Harness Engine 架构图与流程图 + +> 状态:进行中 +> 更新时间:2026-04-13 +> 作用:把 Harness Engine 的关键结构、时序和治理闭环画成可复查的图,而不是只靠长文描述。 + +## 1. 总体架构图 + +```mermaid +flowchart TB + User[用户 / 人工审核] --> UI[前端工作台 UI] + UI --> RuntimeAPI[agent_runtime_* 命令边界] + RuntimeAPI --> Runtime[Aster / Lime Runtime] + + Runtime --> Prompt[System Prompt / Memory Prompt] + Runtime --> ToolSurface[Tool Surface / Skills / MCP / Browser] + Runtime --> Policy[Sandbox / Approval / Restriction Policy] + Runtime --> Session[Session / Thread / Queue / Resume / Continuation] + Runtime --> Workspace[Workspace / Filesystem / Artifact] + + Prompt --> Memory[AGENTS / Project Rules / Durable Memory] + ToolSurface --> Exec[Bash / File Tools / Browser Tools / Subagent Tools] + Workspace --> Artifact[Artifact / Timeline / Runtime Snapshot] + + Session --> Evidence[Evidence Pack] + Artifact --> Evidence + Runtime --> Evidence + + Evidence --> Replay[Replay Case] + Evidence --> Analysis[Analysis Handoff] + Evidence --> Review[Review Decision] + Evidence --> Dashboard[Cleanup / Dashboard / Trend] + Evidence --> StatusPanel[HarnessStatusPanel] + + Replay --> Governance[治理与回归决策] + Analysis --> Governance + Review --> Governance + Dashboard --> Governance + StatusPanel --> Governance +``` + +## 2. 运行时与证据导出时序图 + +```mermaid +sequenceDiagram + participant U as 用户 + participant F as 前端工作台 + participant C as agent_runtime_submit_turn + participant R as Lime / Aster Runtime + participant T as Tools / Skills / Browser / Bash + participant W as Workspace / Artifact + participant E as agent_runtime_export_evidence_pack + participant P as HarnessStatusPanel + + U->>F: 发送任务 + F->>C: submit_turn(request_metadata + turn_config) + C->>R: 创建 / 恢复当前 turn + R->>T: 调用 tools / skills / browser / subagent + T-->>R: 返回输出 / metadata / offload / errors + R->>W: 写入 artifact / timeline / runtime state + R-->>F: 流式状态 / item / summary + + U->>F: 导出问题证据包 + F->>E: export_evidence_pack(session_id) + E->>R: 读取 session detail / thread read + E->>W: 汇总 runtime.json / timeline.json / artifacts.json / summary.md + E-->>F: 返回 evidence pack + observability summary + verification summary + F->>P: 渲染 known gaps / verification outcomes / focus lists + P-->>U: 展示证据事实与治理焦点 +``` + +## 3. Evidence 驱动治理闭环 + +```mermaid +flowchart LR + A[Runtime Thread / Session] --> B[Evidence Pack] + B --> C[Observability Summary] + C --> D[Verification Outcomes] + D --> E[HarnessStatusPanel] + D --> F[Replay Case] + D --> G[Analysis Handoff] + D --> H[Review Decision] + H --> I[修复实现] + I --> J[回归验证] + J --> B +``` + +## 4. 长时任务执行闭环 + +```mermaid +flowchart TD + Start[用户任务进入主会话] --> Plan[计划 / Todo / Scene Binding] + Plan --> Execute[主代理执行] + Execute --> Tools[Tools / Skills / Browser / Bash] + Tools --> Check{是否完成?} + + Check -- 否 --> Continue[Auto Continue / Provider Continuation / Queue Resume] + Continue --> Compact[必要时 Compact / Offload / Context Recovery] + Compact --> Execute + + Check -- 需要拆分 --> Subagent[Spawn Subagent / Team Runtime] + Subagent --> Execute + + Check -- 是 --> Verify[Verification / Replay / Review] + Verify --> Done[形成交付物与证据] +``` + +## 5. 事实源分层图 + +```mermaid +flowchart TB + RuntimeFact[Runtime Thread / Session / Timeline] + EvidencePack[Evidence Pack] + Derived[Replay / Analysis / Review / Dashboard] + View[UI / Prompt Copy / Status Cards] + + RuntimeFact --> EvidencePack + EvidencePack --> Derived + EvidencePack --> View + Derived --> View + + View -.禁止反向定义事实.-> EvidencePack + Derived -.禁止旁路重建真相.-> RuntimeFact +``` + +## 6. 当前最关键的治理关注点 + +### 6.1 已经成形的图上主链 + +- `User -> UI -> agent_runtime_* -> Runtime -> Tools / Workspace -> Evidence` +- `Evidence -> Replay / Analysis / Review / StatusPanel` +- `Continuation / Compact / Offload / Resume` + +### 6.2 仍需继续加强的图上闭环 + +- `Verification Outcomes -> Review / Cleanup / Dashboard` 还要更强一致 +- `是否完成 -> Continue / Compact / Resume` 还没完全约束化 +- `任务类型 -> JIT Tool / Context Assembly` 还没完全平台化 + +## 7. 后续补图原则 + +后续如果 Harness Engine 再新增图纸,遵守三条规则: + +1. 只画 current 主链,不为 compat / deprecated 画主图。 +2. 图中节点必须能对应到仓库真实模块、命令或文档,不画空概念。 +3. 如果实现已经改变事实源或时序,优先更新图,而不是只改 README 文案。 diff --git a/package-lock.json b/package-lock.json index d80d23aa4..b988cad10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.9.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.9.0", + "version": "1.10.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index a3e7c22cb..70cad9338 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.9.0", + "version": "1.10.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index c6201c3f5..0866ae848 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.9.0" \ + --version "1.10.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 658991a2c..4f7d751fe 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.9.0", + "version": "1.10.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/harness-eval-history-record.mjs b/scripts/harness-eval-history-record.mjs index 3eb1c08d3..07ecd68d5 100644 --- a/scripts/harness-eval-history-record.mjs +++ b/scripts/harness-eval-history-record.mjs @@ -7,16 +7,13 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { renderHarnessDashboardHtml } from "./lib/harness-dashboard-core.mjs"; +import { + deriveVerificationHistoryRecordFacts as deriveSharedVerificationHistoryRecordFacts, +} from "./lib/harness-verification-facts.mjs"; const RUNNER_PATH = "scripts/harness-eval-runner.mjs"; const TREND_PATH = "scripts/harness-eval-trend-report.mjs"; const CLEANUP_PATH = "scripts/report-generated-slop.mjs"; -const RECOVERED_VERIFICATION_OUTCOMES = new Set([ - "repaired", - "success", - "passed", - "clean", -]); function parseArgs(argv) { const result = { @@ -257,6 +254,14 @@ function writeUniqueHistorySummary(historyDir, payload) { throw new Error(`无法在历史目录中创建唯一 summary 文件: ${historyDir}`); } +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + function trimHistoryFiles(historyDir, retain) { const files = collectHistoryFiles(historyDir).sort((left, right) => right.localeCompare(left), @@ -285,145 +290,24 @@ function buildDefaultArtifactPaths(historyDir) { }; } -function toVerificationFailureOutcomeFocus(cleanupReport) { - const currentEntries = Array.isArray( - cleanupReport?.focus?.currentObservabilityVerificationOutcomes, - ) - ? cleanupReport.focus.currentObservabilityVerificationOutcomes - : []; - const fallbackEntries = Array.isArray( - cleanupReport?.focus?.observabilityVerificationOutcomes, - ) - ? cleanupReport.focus.observabilityVerificationOutcomes - : []; - const entries = - currentEntries.length > 0 ? currentEntries : fallbackEntries; - - return entries - .map((entry) => { - const signal = typeof entry?.signal === "string" ? entry.signal.trim() : ""; - const outcome = - typeof entry?.outcome === "string" ? entry.outcome.trim() : ""; - return signal && outcome ? `${signal}:${outcome}` : ""; - }) - .filter(Boolean); -} - -function toCurrentRecoveredBaselineFocus(cleanupReport) { - const explicitRecoveredEntries = Array.isArray( - cleanupReport?.focus?.currentRecoveredObservabilityVerificationOutcomes, - ) - ? cleanupReport.focus.currentRecoveredObservabilityVerificationOutcomes - : []; - const currentEntries = Array.isArray( - cleanupReport?.focus?.currentObservabilityVerificationOutcomes, - ) - ? cleanupReport.focus.currentObservabilityVerificationOutcomes - : []; - const fallbackEntries = Array.isArray( - cleanupReport?.focus?.observabilityVerificationOutcomes, - ) - ? cleanupReport.focus.observabilityVerificationOutcomes - : []; - const entries = - explicitRecoveredEntries.length > 0 - ? explicitRecoveredEntries - : currentEntries.length > 0 - ? currentEntries - : fallbackEntries; - - return entries - .filter((entry) => - RECOVERED_VERIFICATION_OUTCOMES.has( - typeof entry?.outcome === "string" ? entry.outcome.trim() : "", - ), - ) - .map((entry) => { - const signal = typeof entry?.signal === "string" ? entry.signal.trim() : ""; - const outcome = - typeof entry?.outcome === "string" ? entry.outcome.trim() : ""; - return signal && outcome ? `${signal}:${outcome}` : ""; - }) - .filter(Boolean); -} - -function toVerificationOutcomeCounts(cleanupReport) { - const summary = - cleanupReport && - typeof cleanupReport === "object" && - cleanupReport.summary && - cleanupReport.summary.verificationOutcomes && - typeof cleanupReport.summary.verificationOutcomes === "object" - ? cleanupReport.summary.verificationOutcomes - : {}; - const currentSummary = - summary && - typeof summary.current === "object" && - !Array.isArray(summary.current) - ? summary.current - : {}; - const degradedSummary = - summary && - typeof summary.degraded === "object" && - !Array.isArray(summary.degraded) - ? summary.degraded - : {}; - - return { - failureCaseCount: - typeof summary.failureCaseCount === "number" && - Number.isFinite(summary.failureCaseCount) - ? summary.failureCaseCount - : 0, - blockingFailureCaseCount: - typeof currentSummary.blockingFailureCaseCount === "number" && - Number.isFinite(currentSummary.blockingFailureCaseCount) - ? currentSummary.blockingFailureCaseCount - : 0, - advisoryFailureCaseCount: - typeof currentSummary.advisoryFailureCaseCount === "number" && - Number.isFinite(currentSummary.advisoryFailureCaseCount) - ? currentSummary.advisoryFailureCaseCount - : 0, - recoveredCaseCount: - typeof summary.recoveredCaseCount === "number" && - Number.isFinite(summary.recoveredCaseCount) - ? summary.recoveredCaseCount - : 0, - currentRecoveredCaseCount: - typeof currentSummary.recoveredCaseCount === "number" && - Number.isFinite(currentSummary.recoveredCaseCount) - ? currentSummary.recoveredCaseCount - : 0, - degradedBlockingFailureCaseCount: - typeof degradedSummary.blockingFailureCaseCount === "number" && - Number.isFinite(degradedSummary.blockingFailureCaseCount) - ? degradedSummary.blockingFailureCaseCount - : 0, - }; -} - function toTrendCurrentRecoveredBaselineFocus(trendReport) { - const entries = Array.isArray( - trendReport?.classificationDeltas?.currentRecoveredObservabilityVerificationOutcomes, - ) - ? trendReport.classificationDeltas.currentRecoveredObservabilityVerificationOutcomes - : []; + return deriveSharedVerificationHistoryRecordFacts({ + summary: null, + trendReport, + cleanupReport: null, + }).currentRecoveredBaselineFocus.slice(0, 3); +} - return entries - .filter((entry) => { - const latestCaseCount = - typeof entry?.latest?.caseCount === "number" && - Number.isFinite(entry.latest.caseCount) - ? entry.latest.caseCount - : 0; - return latestCaseCount > 0; - }) - .map((entry) => - typeof entry?.name === "string" ? entry.name.trim() : "", - ) - .filter(Boolean) - .slice(0, 3); +export function deriveHistoryRecordVerificationFacts({ + summary, + trendReport, + cleanupReport, +}) { + return deriveSharedVerificationHistoryRecordFacts({ + summary, + trendReport, + cleanupReport, + }); } function renderOutput(result, format) { @@ -660,32 +544,33 @@ function runHistoryRecordCli() { }, ); cleanupReport = JSON.parse(cleanupOutput); - const verificationFailureOutcomeFocus = - toVerificationFailureOutcomeFocus(cleanupReport); - const currentRecoveredBaselineFocus = - toCurrentRecoveredBaselineFocus(cleanupReport); - const verificationOutcomeCounts = - toVerificationOutcomeCounts(cleanupReport); + const verificationFacts = deriveHistoryRecordVerificationFacts({ + summary, + trendReport, + cleanupReport, + }); result.cleanup = { trendSampleCount: cleanupReport.summary?.trend?.sampleCount ?? 0, currentObservabilityGapCaseCount: cleanupReport.summary?.trend?.latestCurrentObservabilityGapCaseCount ?? 0, degradedObservabilityGapCaseCount: cleanupReport.summary?.trend?.latestDegradedObservabilityGapCaseCount ?? 0, - verificationFailureOutcomeFocus, + verificationFailureOutcomeFocus: + verificationFacts.verificationFailureOutcomeFocus, verificationFailureCaseCount: - verificationOutcomeCounts.failureCaseCount, + verificationFacts.verificationOutcomeCounts.failureCaseCount, verificationBlockingFailureCaseCount: - verificationOutcomeCounts.blockingFailureCaseCount, + verificationFacts.verificationOutcomeCounts.blockingFailureCaseCount, verificationAdvisoryFailureCaseCount: - verificationOutcomeCounts.advisoryFailureCaseCount, + verificationFacts.verificationOutcomeCounts.advisoryFailureCaseCount, verificationDegradedBlockingFailureCaseCount: - verificationOutcomeCounts.degradedBlockingFailureCaseCount, + verificationFacts.verificationOutcomeCounts.degradedBlockingFailureCaseCount, verificationRecoveredCaseCount: - verificationOutcomeCounts.recoveredCaseCount, + verificationFacts.verificationOutcomeCounts.recoveredCaseCount, currentVerificationRecoveredCaseCount: - verificationOutcomeCounts.currentRecoveredCaseCount, - currentRecoveredBaselineFocus, + verificationFacts.verificationOutcomeCounts.currentRecoveredCaseCount, + currentRecoveredBaselineFocus: + verificationFacts.currentRecoveredBaselineFocus, outputJsonPath: cleanupJsonPath, outputMarkdownPath: cleanupMarkdownPath, }; diff --git a/scripts/harness-eval-runner.mjs b/scripts/harness-eval-runner.mjs index 4b89cfb85..844af1e98 100644 --- a/scripts/harness-eval-runner.mjs +++ b/scripts/harness-eval-runner.mjs @@ -23,6 +23,12 @@ const REVIEW_DECISION_RISK_LEVEL_SET = new Set([ "unknown", ]); const OBSERVABILITY_GAP_SUITE_TAG = "observability-gap"; +const RECOVERED_VERIFICATION_OUTCOMES = new Set([ + "artifactValidator:repaired", + "browserVerification:success", + "guiSmoke:passed", + "guiSmoke:clean", +]); function parseArgs(argv) { const result = { @@ -193,6 +199,12 @@ function aggregateCaseBreakdown(cases, selector) { }); } +function collectRecoveredVerificationOutcomes(outcomes) { + return mergeUniqueStrings(outcomes).filter((entry) => + RECOVERED_VERIFICATION_OUTCOMES.has(entry), + ); +} + function isDegradedObservabilityGapCase(entry) { return ( entry.observabilityGapCount > 0 && @@ -891,6 +903,22 @@ function buildSummary(manifest, suites, options) { const degradedObservabilityGapCases = observabilityGapCases.filter((entry) => isDegradedObservabilityGapCase(entry), ); + const currentObservabilityDiagnosticCases = allCases.filter((entry) => + isCurrentObservabilityDiagnosticCase(entry), + ); + const currentRecoveredObservabilityVerificationOutcomes = + aggregateCaseBreakdown( + currentObservabilityDiagnosticCases, + (entry) => + collectRecoveredVerificationOutcomes( + entry.observabilityVerificationOutcomes, + ), + ); + const currentRecoveredVerificationCaseCount = + currentRecoveredObservabilityVerificationOutcomes.reduce( + (total, entry) => total + entry.caseCount, + 0, + ); return { manifestVersion: String(manifest.manifestVersion ?? "unknown"), @@ -910,6 +938,7 @@ function buildSummary(manifest, suites, options) { observabilityGapCaseCount: observabilityGapCases.length, currentObservabilityGapCaseCount: currentObservabilityGapCases.length, degradedObservabilityGapCaseCount: degradedObservabilityGapCases.length, + currentRecoveredVerificationCaseCount, }, breakdowns: { suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags), @@ -932,9 +961,10 @@ function buildSummary(manifest, suites, options) { (entry) => entry.observabilityVerificationOutcomes, ), currentObservabilityVerificationOutcomes: aggregateCaseBreakdown( - allCases.filter((entry) => isCurrentObservabilityDiagnosticCase(entry)), + currentObservabilityDiagnosticCases, (entry) => entry.observabilityVerificationOutcomes, ), + currentRecoveredObservabilityVerificationOutcomes, degradedObservabilityVerificationOutcomes: aggregateCaseBreakdown( allCases.filter((entry) => isDegradedObservabilityDiagnosticCase(entry)), (entry) => entry.observabilityVerificationOutcomes, @@ -958,6 +988,7 @@ function renderText(summary) { `[harness-eval] observability-gap cases: ${summary.totals.observabilityGapCaseCount}`, `[harness-eval] current observability-gap cases: ${summary.totals.currentObservabilityGapCaseCount}`, `[harness-eval] degraded observability-gap cases: ${summary.totals.degradedObservabilityGapCaseCount}`, + `[harness-eval] current recovered verification cases: ${summary.totals.currentRecoveredVerificationCaseCount}`, ]; const topFailureModes = summary.breakdowns.failureModes.slice(0, 5); @@ -1014,6 +1045,20 @@ function renderText(summary) { } } + const topCurrentRecoveredVerificationOutcomes = + summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes.slice( + 0, + 5, + ); + if (topCurrentRecoveredVerificationOutcomes.length > 0) { + lines.push("[harness-eval] current recovered verification outcomes:"); + for (const entry of topCurrentRecoveredVerificationOutcomes) { + lines.push( + ` - ${entry.name}: case=${entry.caseCount}, ready=${entry.readyCount}, invalid=${entry.invalidCount}`, + ); + } + } + for (const suite of summary.suites) { lines.push( `[harness-eval] suite ${suite.id}: ready ${suite.stats.readyCount} / ${suite.stats.caseCount}`, @@ -1069,6 +1114,7 @@ function renderMarkdown(summary) { `- observability gap case:${summary.totals.observabilityGapCaseCount}`, `- current observability gap case:${summary.totals.currentObservabilityGapCaseCount}`, `- degraded observability gap case:${summary.totals.degradedObservabilityGapCaseCount}`, + `- current recovered verification case:${summary.totals.currentRecoveredVerificationCaseCount}`, "", ]; @@ -1152,6 +1198,19 @@ function renderMarkdown(summary) { lines.push(""); } + if (summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes.length > 0) { + lines.push("## Current Recovered Verification Outcome 分布"); + lines.push(""); + lines.push("| Outcome | case | ready | invalid |"); + lines.push("| --- | --- | --- | --- |"); + for (const entry of summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes) { + lines.push( + `| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`, + ); + } + lines.push(""); + } + for (const suite of summary.suites) { lines.push(`## ${suite.title}`); lines.push(""); diff --git a/scripts/harness-eval-trend-report.mjs b/scripts/harness-eval-trend-report.mjs index ce26d406c..e1a528e22 100644 --- a/scripts/harness-eval-trend-report.mjs +++ b/scripts/harness-eval-trend-report.mjs @@ -256,10 +256,8 @@ function buildNormalizedTotals(summary) { const rawTotals = summary?.totals && typeof summary.totals === "object" ? summary.totals : {}; const gapTotals = buildObservabilityGapTotals(summary); - const currentVerificationOutcomeEntries = getBreakdownEntries( - summary, - "currentObservabilityVerificationOutcomes", - ); + const currentRecoveredVerificationEntries = + getCurrentRecoveredVerificationEntries(summary); return { suiteCount: normalizeNumber(rawTotals.suiteCount), caseCount: normalizeNumber(rawTotals.caseCount), @@ -274,11 +272,10 @@ function buildNormalizedTotals(summary) { currentObservabilityGapCaseCount: gapTotals.current, degradedObservabilityGapCaseCount: gapTotals.degraded, currentRecoveredVerificationCaseCount: - currentVerificationOutcomeEntries.length > 0 - ? getBreakdownCaseCount( - summary, - "currentObservabilityVerificationOutcomes", - RECOVERED_VERIFICATION_OUTCOMES, + currentRecoveredVerificationEntries.length > 0 + ? currentRecoveredVerificationEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.caseCount), + 0, ) : normalizeNumber(rawTotals.currentRecoveredVerificationCaseCount), }; @@ -326,6 +323,21 @@ function getBreakdownMap(summary, key) { ); } +function getCurrentRecoveredVerificationEntries(summary) { + const explicitEntries = getBreakdownEntries( + summary, + "currentRecoveredObservabilityVerificationOutcomes", + ); + if (explicitEntries.length > 0) { + return explicitEntries; + } + + return getBreakdownEntries( + summary, + "currentObservabilityVerificationOutcomes", + ).filter((entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name)); +} + function buildSuiteDeltas(baseline, latest) { const baselineSuites = getSuiteMap(baseline); const latestSuites = getSuiteMap(latest); @@ -428,6 +440,32 @@ function buildFilteredBreakdownDeltas(baseline, latest, key, predicate) { ); } +function buildCurrentRecoveredVerificationDeltas(baseline, latest) { + const baselineExplicitEntries = getBreakdownEntries( + baseline, + "currentRecoveredObservabilityVerificationOutcomes", + ); + const latestExplicitEntries = getBreakdownEntries( + latest, + "currentRecoveredObservabilityVerificationOutcomes", + ); + + if (baselineExplicitEntries.length > 0 || latestExplicitEntries.length > 0) { + return buildBreakdownDeltas( + baseline, + latest, + "currentRecoveredObservabilityVerificationOutcomes", + ); + } + + return buildFilteredBreakdownDeltas( + baseline, + latest, + "currentObservabilityVerificationOutcomes", + (entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name), + ); +} + function buildStatusSignals(baseline, latest, sampleCount) { const signals = []; @@ -507,12 +545,8 @@ function buildStatusSignals(baseline, latest, sampleCount) { ); } - const currentRecoveredVerificationDeltas = buildFilteredBreakdownDeltas( - baseline, - latest, - "currentObservabilityVerificationOutcomes", - (entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name), - ); + const currentRecoveredVerificationDeltas = + buildCurrentRecoveredVerificationDeltas(baseline, latest); for (const entry of currentRecoveredVerificationDeltas.filter( (candidate) => candidate.delta.caseCount < 0, )) { @@ -631,12 +665,7 @@ function buildTrendReport(samples, repoRoot) { "observabilityVerificationOutcomes", ), currentRecoveredObservabilityVerificationOutcomes: - buildFilteredBreakdownDeltas( - baseline, - latest, - "currentObservabilityVerificationOutcomes", - (entry) => RECOVERED_VERIFICATION_OUTCOMES.has(entry.name), - ), + buildCurrentRecoveredVerificationDeltas(baseline, latest), currentObservabilityVerificationOutcomes: buildBreakdownDeltas( baseline, latest, diff --git a/scripts/lib/generated-slop-report-core.mjs b/scripts/lib/generated-slop-report-core.mjs index 6eef81079..4a7c74f47 100644 --- a/scripts/lib/generated-slop-report-core.mjs +++ b/scripts/lib/generated-slop-report-core.mjs @@ -4,6 +4,22 @@ import { getTextCountStatus, getTextStatus, } from "./legacy-surface-report-summary.mjs"; +import { + buildAdvisoryVerificationRecommendationRationale, + buildBlockingVerificationRecommendationRationale, + buildObservabilityRecommendationBacklog, + buildObservabilityRecommendationRationale, + buildVerificationOutcomeSignalMessages, + buildRecoveredVerificationRecommendationRationale, + buildAdvisoryVerificationFollowUp, + buildBlockingVerificationFollowUp, + buildRecoveredVerificationFollowUp, + buildVerificationOutcomeSummary, + deriveVerificationOutcomePresentationFromTrend, + formatVerificationOutcomeCompactLabel, + formatVerificationOutcomeCompactLabels, + getVerificationOutcomeRole, +} from "./harness-verification-facts.mjs"; const PRIORITY_RANK = { P0: 0, @@ -544,277 +560,6 @@ function buildObservabilityFocusEntries(entries, sampleCount) { }); } -const OBSERVABILITY_VERIFICATION_FAILURE_OUTCOMES = new Set([ - "issues_present", - "fallback_used", - "failure", - "unknown", - "failed", -]); - -const OBSERVABILITY_VERIFICATION_RECOVERED_OUTCOMES = new Set([ - "repaired", - "success", - "passed", - "clean", -]); - -function isObservabilityVerificationFailureOutcome(outcome) { - return OBSERVABILITY_VERIFICATION_FAILURE_OUTCOMES.has( - normalizeString(outcome), - ); -} - -function isObservabilityVerificationRecoveredOutcome(outcome) { - return OBSERVABILITY_VERIFICATION_RECOVERED_OUTCOMES.has( - normalizeString(outcome), - ); -} - -const BLOCKING_VERIFICATION_FAILURES = new Set([ - "browserVerification:failure", - "guiSmoke:failed", -]); - -function getObservabilityVerificationOutcomeRole(signal, outcome) { - const normalizedSignal = normalizeString(signal); - const normalizedOutcome = normalizeString(outcome); - const fingerprint = `${normalizedSignal}:${normalizedOutcome}`; - - if (BLOCKING_VERIFICATION_FAILURES.has(fingerprint)) { - return "blocking_failure"; - } - - if (isObservabilityVerificationFailureOutcome(normalizedOutcome)) { - return "advisory_failure"; - } - - if (isObservabilityVerificationRecoveredOutcome(normalizedOutcome)) { - return "recovered"; - } - - return "other"; -} - -function getObservabilityVerificationOutcomeWeight(outcome) { - switch (normalizeString(outcome)) { - case "failed": - return 140; - case "failure": - return 130; - case "unknown": - return 115; - case "fallback_used": - return 110; - case "issues_present": - return 100; - case "repaired": - return 70; - default: - return 0; - } -} - -function buildObservabilityVerificationFocusEntries(entries, sampleCount) { - const normalizedEntries = Array.isArray(entries) ? entries : []; - - return normalizedEntries - .map((entry) => { - const latest = isObject(entry?.latest) ? entry.latest : {}; - const delta = isObject(entry?.delta) ? entry.delta : {}; - const baseline = isObject(entry?.baseline) ? entry.baseline : {}; - const parsed = splitObservabilitySignalName(entry?.name); - const positiveDeltaCase = Math.max(0, normalizeNumber(delta.caseCount)); - const latestCase = normalizeNumber(latest.caseCount); - const weight = getObservabilityVerificationOutcomeWeight(parsed.status); - const score = positiveDeltaCase * 140 + latestCase * weight; - - let state = "stable"; - if (sampleCount < 2 && latestCase > 0 && weight > 0) { - state = "seed-risk"; - } else if (positiveDeltaCase > 0 && weight > 0) { - state = "regressing"; - } else if (latestCase > 0 && weight > 0) { - state = "present"; - } - - return { - name: normalizeString(entry?.name, "(unknown)"), - signal: parsed.signal || "(unknown)", - outcome: parsed.status || "unknown", - baseline: { - caseCount: normalizeNumber(baseline.caseCount), - readyCount: normalizeNumber(baseline.readyCount), - invalidCount: normalizeNumber(baseline.invalidCount), - pendingRequestCaseCount: normalizeNumber( - baseline.pendingRequestCaseCount, - ), - needsHumanReviewCount: normalizeNumber( - baseline.needsHumanReviewCount, - ), - }, - latest: { - caseCount: latestCase, - readyCount: normalizeNumber(latest.readyCount), - invalidCount: normalizeNumber(latest.invalidCount), - pendingRequestCaseCount: normalizeNumber( - latest.pendingRequestCaseCount, - ), - needsHumanReviewCount: normalizeNumber( - latest.needsHumanReviewCount, - ), - }, - delta: { - caseCount: normalizeNumber(delta.caseCount), - readyCount: normalizeNumber(delta.readyCount), - invalidCount: normalizeNumber(delta.invalidCount), - pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), - needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), - }, - state, - score, - }; - }) - .filter( - (entry) => - entry.score > 0 || - (entry.latest.caseCount > 0 && - isObservabilityVerificationFailureOutcome(entry.outcome)), - ) - .sort((left, right) => { - if (right.score !== left.score) { - return right.score - left.score; - } - return left.name.localeCompare(right.name); - }); -} - -function buildVerificationOutcomeEntriesFromDeltas(entries) { - const normalizedEntries = Array.isArray(entries) ? entries : []; - - return normalizedEntries - .map((entry) => { - const latest = isObject(entry?.latest) ? entry.latest : {}; - const delta = isObject(entry?.delta) ? entry.delta : {}; - const baseline = isObject(entry?.baseline) ? entry.baseline : {}; - const parsed = splitObservabilitySignalName(entry?.name); - const latestCase = normalizeNumber(latest.caseCount); - const deltaCase = normalizeNumber(delta.caseCount); - - let state = "stable"; - if (deltaCase > 0) { - state = "expanding"; - } else if (deltaCase < 0) { - state = "shrinking"; - } else if (latestCase > 0) { - state = "present"; - } - - return { - name: normalizeString(entry?.name, "(unknown)"), - signal: parsed.signal || "(unknown)", - outcome: parsed.status || "unknown", - baseline: { - caseCount: normalizeNumber(baseline.caseCount), - readyCount: normalizeNumber(baseline.readyCount), - invalidCount: normalizeNumber(baseline.invalidCount), - pendingRequestCaseCount: normalizeNumber( - baseline.pendingRequestCaseCount, - ), - needsHumanReviewCount: normalizeNumber( - baseline.needsHumanReviewCount, - ), - }, - latest: { - caseCount: latestCase, - readyCount: normalizeNumber(latest.readyCount), - invalidCount: normalizeNumber(latest.invalidCount), - pendingRequestCaseCount: normalizeNumber( - latest.pendingRequestCaseCount, - ), - needsHumanReviewCount: normalizeNumber( - latest.needsHumanReviewCount, - ), - }, - delta: { - caseCount: deltaCase, - readyCount: normalizeNumber(delta.readyCount), - invalidCount: normalizeNumber(delta.invalidCount), - pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), - needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), - }, - state, - score: latestCase * 10 + Math.abs(deltaCase) * 5, - }; - }) - .filter((entry) => entry.latest.caseCount > 0 || entry.delta.caseCount !== 0) - .sort((left, right) => { - if (right.latest.caseCount !== left.latest.caseCount) { - return right.latest.caseCount - left.latest.caseCount; - } - if (Math.abs(right.delta.caseCount) !== Math.abs(left.delta.caseCount)) { - return Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount); - } - return left.name.localeCompare(right.name); - }); -} - -function buildVerificationOutcomeSummary(focusEntries) { - const entries = Array.isArray(focusEntries) ? focusEntries : []; - const blockingFailureEntries = entries.filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === - "blocking_failure", - ); - const advisoryFailureEntries = entries.filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === - "advisory_failure", - ); - const failureEntries = [...blockingFailureEntries, ...advisoryFailureEntries]; - const recoveredEntries = entries.filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === - "recovered", - ); - - return { - focusCount: entries.length, - failureFocusCount: failureEntries.length, - recoveredFocusCount: recoveredEntries.length, - blockingFailureFocusCount: blockingFailureEntries.length, - advisoryFailureFocusCount: advisoryFailureEntries.length, - failureCaseCount: failureEntries.reduce( - (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), - 0, - ), - blockingFailureCaseCount: blockingFailureEntries.reduce( - (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), - 0, - ), - advisoryFailureCaseCount: advisoryFailureEntries.reduce( - (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), - 0, - ), - recoveredCaseCount: recoveredEntries.reduce( - (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), - 0, - ), - topFailureOutcomes: failureEntries - .slice(0, 3) - .map((entry) => `${entry.signal}:${entry.outcome}`), - topBlockingFailureOutcomes: blockingFailureEntries - .slice(0, 3) - .map((entry) => `${entry.signal}:${entry.outcome}`), - topAdvisoryFailureOutcomes: advisoryFailureEntries - .slice(0, 3) - .map((entry) => `${entry.signal}:${entry.outcome}`), - topRecoveredOutcomes: recoveredEntries - .slice(0, 3) - .map((entry) => `${entry.signal}:${entry.outcome}`), - }; -} - function buildDocFreshnessSummary(docFreshnessReport) { const summary = isObject(docFreshnessReport?.summary) ? docFreshnessReport.summary @@ -993,8 +738,8 @@ export function assertGeneratedSlopReportContract(report) { if ( verificationFailureFocus.some( (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === - "recovered", + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", ) ) { throw new Error( @@ -1005,183 +750,6 @@ export function assertGeneratedSlopReportContract(report) { return report; } -function hasObservabilityVerificationOutcome(entries, signal, outcome) { - const normalizedEntries = Array.isArray(entries) ? entries : []; - const normalizedSignal = normalizeString(signal); - const normalizedOutcome = normalizeString(outcome); - - return normalizedEntries.some( - (entry) => - normalizeString(entry?.signal) === normalizedSignal && - normalizeString(entry?.outcome) === normalizedOutcome, - ); -} - -function buildCurrentBlockingVerificationFollowUp( - focusCurrentObservabilityVerificationOutcomes, -) { - const hasCurrentGuiSmokeFailure = hasObservabilityVerificationOutcome( - focusCurrentObservabilityVerificationOutcomes, - "guiSmoke", - "failed", - ); - const hasCurrentBrowserVerificationFailure = - hasObservabilityVerificationOutcome( - focusCurrentObservabilityVerificationOutcomes, - "browserVerification", - "failure", - ); - const commands = ["npm run harness:eval", "npm run harness:eval:trend"]; - const backlogTools = []; - const rationale = []; - - if (hasCurrentGuiSmokeFailure) { - commands.push("npm run verify:gui-smoke"); - rationale.push( - "current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。", - ); - backlogTools.push( - "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。", - ); - } - - if (hasCurrentBrowserVerificationFailure) { - rationale.push( - "current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。", - ); - backlogTools.push( - "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", - ); - } - - if (backlogTools.length === 0) { - backlogTools.push("按受影响主路径追加 `npm run verify:gui-smoke` 或专项 smoke"); - } - - return { - commands: dedupeNonEmptyStrings(commands), - backlogTools: dedupeNonEmptyStrings(backlogTools), - rationale: dedupeNonEmptyStrings(rationale), - }; -} - -function buildCurrentAdvisoryVerificationFollowUp( - focusCurrentObservabilityVerificationOutcomes, -) { - const hasArtifactValidatorIssuesPresent = hasObservabilityVerificationOutcome( - focusCurrentObservabilityVerificationOutcomes, - "artifactValidator", - "issues_present", - ); - const hasArtifactValidatorFallbackUsed = hasObservabilityVerificationOutcome( - focusCurrentObservabilityVerificationOutcomes, - "artifactValidator", - "fallback_used", - ); - const hasBrowserVerificationUnknown = hasObservabilityVerificationOutcome( - focusCurrentObservabilityVerificationOutcomes, - "browserVerification", - "unknown", - ); - const rationale = []; - const backlogTools = []; - - if (hasArtifactValidatorIssuesPresent) { - rationale.push( - "current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。", - ); - backlogTools.push( - "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", - ); - } - - if (hasArtifactValidatorFallbackUsed) { - rationale.push( - "current 样本已出现 artifactValidator:fallback_used,说明 artifact 主路径仍不稳定,不能继续依赖 fallback 充当事实。", - ); - backlogTools.push( - "补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。", - ); - } - - if (hasBrowserVerificationUnknown) { - rationale.push( - "current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。", - ); - backlogTools.push( - "回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。", - ); - } - - if (backlogTools.length === 0) { - backlogTools.push( - "先对齐 current verification outcome 到 artifact/browser/gui 主路径,再继续补 observability 证据。", - ); - } - - return { - rationale: dedupeNonEmptyStrings(rationale), - backlogTools: dedupeNonEmptyStrings(backlogTools), - }; -} - -function buildCurrentRecoveredVerificationFollowUp( - focusCurrentRecoveredObservabilityVerificationOutcomes, -) { - const hasArtifactValidatorRepaired = hasObservabilityVerificationOutcome( - focusCurrentRecoveredObservabilityVerificationOutcomes, - "artifactValidator", - "repaired", - ); - const hasBrowserVerificationSuccess = hasObservabilityVerificationOutcome( - focusCurrentRecoveredObservabilityVerificationOutcomes, - "browserVerification", - "success", - ); - const hasGuiSmokePassed = hasObservabilityVerificationOutcome( - focusCurrentRecoveredObservabilityVerificationOutcomes, - "guiSmoke", - "passed", - ); - const commands = ["npm run harness:eval", "npm run harness:eval:trend"]; - const rationale = []; - const backlogTools = []; - - if (hasArtifactValidatorRepaired) { - rationale.push( - "current 样本已出现 artifactValidator:repaired,说明 artifact 修复链已经回到可复用的主路径。", - ); - backlogTools.push( - "在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。", - ); - } - - if (hasBrowserVerificationSuccess) { - rationale.push( - "current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。", - ); - backlogTools.push( - "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。", - ); - } - - if (hasGuiSmokePassed) { - commands.push("npm run verify:gui-smoke"); - rationale.push( - "current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。", - ); - backlogTools.push( - "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。", - ); - } - - return { - commands: dedupeNonEmptyStrings(commands), - rationale: dedupeNonEmptyStrings(rationale), - backlogTools: dedupeNonEmptyStrings(backlogTools), - }; -} - function buildRecommendations({ trendSummary, verificationOutcomeSummary, @@ -1213,21 +781,25 @@ function buildRecommendations({ const topObservabilitySignals = focusObservabilitySignals .slice(0, 3) .map((entry) => `${entry.signal} (${entry.status})`); - const topVerificationFailureOutcomes = focusVerificationFailureOutcomes - .slice(0, 3) - .map((entry) => `${entry.signal} (${entry.outcome})`); + const topVerificationFailureOutcomes = formatVerificationOutcomeCompactLabels( + focusVerificationFailureOutcomes, + 3, + ); const topCurrentVerificationFailureOutcomes = - focusCurrentObservabilityVerificationOutcomes - .slice(0, 3) - .map((entry) => `${entry.signal} (${entry.outcome})`); + formatVerificationOutcomeCompactLabels( + focusCurrentObservabilityVerificationOutcomes, + 3, + ); const topCurrentRecoveredVerificationOutcomes = - focusCurrentRecoveredObservabilityVerificationOutcomes - .slice(0, 3) - .map((entry) => `${entry.signal} (${entry.outcome})`); + formatVerificationOutcomeCompactLabels( + focusCurrentRecoveredObservabilityVerificationOutcomes, + 3, + ); const topDegradedVerificationFailureOutcomes = - focusDegradedObservabilityVerificationOutcomes - .slice(0, 3) - .map((entry) => `${entry.signal} (${entry.outcome})`); + formatVerificationOutcomeCompactLabels( + focusDegradedObservabilityVerificationOutcomes, + 3, + ); const topRecommendedVerificationFailureOutcomes = topCurrentVerificationFailureOutcomes.length > 0 ? topCurrentVerificationFailureOutcomes @@ -1241,15 +813,15 @@ function buildRecommendations({ const degradedVerificationSummary = verificationOutcomeSummary?.degraded ?? buildVerificationOutcomeSummary([]); const currentBlockingVerificationFollowUp = - buildCurrentBlockingVerificationFollowUp( + buildBlockingVerificationFollowUp( focusCurrentObservabilityVerificationOutcomes, ); const currentAdvisoryVerificationFollowUp = - buildCurrentAdvisoryVerificationFollowUp( + buildAdvisoryVerificationFollowUp( focusCurrentObservabilityVerificationOutcomes, ); const currentRecoveredVerificationFollowUp = - buildCurrentRecoveredVerificationFollowUp( + buildRecoveredVerificationFollowUp( focusCurrentRecoveredObservabilityVerificationOutcomes, ); @@ -1314,16 +886,13 @@ function buildRecommendations({ rationale: [ `当前 failure mode 焦点:${topFailureModes.join("、") || "暂无"}。`, "先用 replay / eval 固化失败,再按受影响主路径补最小 smoke,而不是直接凭印象清理。", - topCurrentVerificationFailureOutcomes.length > 0 - ? `当前 current verification failure outcome 焦点:${topCurrentVerificationFailureOutcomes.join("、")}。` - : "当前没有额外的 verification failure outcome 焦点。", + ...buildBlockingVerificationRecommendationRationale({ + topCurrentVerificationFailureOutcomes: + focusCurrentObservabilityVerificationOutcomes, + currentVerificationSummary, + degradedVerificationSummary, + }), ...currentBlockingVerificationFollowUp.rationale, - currentVerificationSummary.blockingFailureCaseCount > 0 - ? `其中 current blocking verification failure 共 ${currentVerificationSummary.blockingFailureCaseCount} 个 case:${currentVerificationSummary.topBlockingFailureOutcomes.join("、") || "暂无"}。` - : "当前没有额外的 blocking verification failure。", - degradedVerificationSummary.blockingFailureCaseCount > 0 - ? `另有 ${degradedVerificationSummary.blockingFailureCaseCount} 个 degraded blocking verification failure 样本作为诊断基线,不直接抬高主线优先级。` - : "当前没有额外的 degraded blocking verification baseline。", ], commands: currentBlockingVerificationFollowUp.commands, backlogTools: currentBlockingVerificationFollowUp.backlogTools, @@ -1409,38 +978,27 @@ function buildRecommendations({ : "P2", title: "先补 observability 证据覆盖,再扩大外部分析与回归", rationale: [ - trendSummary.latestCurrentObservabilityGapCaseCount > 0 - ? `当前仍有 ${trendSummary.latestCurrentObservabilityGapCaseCount} 个 current case 带着 observability 证据缺口进入 replay/eval。` - : "当前 trend 已检测到 observability coverage 漂移,需先修证据而不是空谈根因分析。", - trendSummary.latestDegradedObservabilityGapCaseCount > 0 - ? `另有 ${trendSummary.latestDegradedObservabilityGapCaseCount} 个 degraded gap 样本作为诊断基线保留,它们不应直接被当成主线回归。` - : "当前没有额外保留的 degraded observability gap 样本。", - `当前缺口焦点:${topObservabilitySignals.join("、") || "暂无"}。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。`, - topCurrentVerificationFailureOutcomes.length > 0 - ? `当前 current verification failure outcome 焦点:${topCurrentVerificationFailureOutcomes.join("、")}。可用它们直接定位先补 artifact/browser/gui 哪一层。` - : "当前没有额外的 verification failure outcome 焦点。", + ...buildObservabilityRecommendationRationale({ + trendSummary, + topObservabilitySignals, + topCurrentVerificationFailureOutcomes: + focusCurrentObservabilityVerificationOutcomes, + topDegradedVerificationFailureOutcomes: + focusDegradedObservabilityVerificationOutcomes, + currentVerificationSummary, + }), ...currentAdvisoryVerificationFollowUp.rationale, - currentVerificationSummary.advisoryFailureCaseCount > 0 - ? `当前 current advisory verification failure 共 ${currentVerificationSummary.advisoryFailureCaseCount} 个 case:${currentVerificationSummary.topAdvisoryFailureOutcomes.join("、") || "暂无"}。` - : "当前没有额外的 advisory verification failure。", - topDegradedVerificationFailureOutcomes.length > 0 - ? `当前保留的 degraded verification baseline:${topDegradedVerificationFailureOutcomes.join("、")}。` - : "当前没有额外的 degraded verification baseline。", ], commands: [ "npm run harness:eval", "npm run harness:eval:trend", "npm run harness:cleanup-report", ], - backlogTools: [ - "优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。", - ...(topCurrentVerificationFailureOutcomes.length > 0 - ? [ - `先对齐 current verification failure outcome:${topCurrentVerificationFailureOutcomes.join("、")}。`, - ] - : []), - ...currentAdvisoryVerificationFollowUp.backlogTools, - ], + backlogTools: buildObservabilityRecommendationBacklog({ + topCurrentVerificationFailureOutcomes: + focusCurrentObservabilityVerificationOutcomes, + advisoryFollowUpBacklogTools: currentAdvisoryVerificationFollowUp.backlogTools, + }), focusFailureModes: topFailureModes, focusSuiteTags: topSuiteTags, focusReviewDecisionStatuses: topReviewDecisionStatuses, @@ -1493,9 +1051,11 @@ function buildRecommendations({ : "P3", title: "把 recovered verification outcome 固化成 current 正向基线", rationale: [ - topCurrentRecoveredVerificationOutcomes.length > 0 - ? `当前 current recovered outcome 焦点:${topCurrentRecoveredVerificationOutcomes.join("、")}。` - : `当前 current recovered outcome 共 ${currentVerificationSummary.recoveredCaseCount} 个 case。`, + ...buildRecoveredVerificationRecommendationRationale({ + topCurrentRecoveredVerificationOutcomes: + focusCurrentRecoveredObservabilityVerificationOutcomes, + currentVerificationSummary, + }), ...currentRecoveredVerificationFollowUp.rationale, "恢复成功的 outcome 不应只停留在统计卡里,还应继续回挂到 replay / smoke / evidence 主链,作为后续回退判断的正向对照。", ], @@ -1590,117 +1150,31 @@ export function buildGeneratedSlopReport({ trendReport?.classificationDeltas?.observabilitySignals, trendSummary.sampleCount, ); - const rawObservabilityVerificationOutcomes = - buildObservabilityVerificationFocusEntries( - trendReport?.classificationDeltas?.observabilityVerificationOutcomes, - trendSummary.sampleCount, - ); - const explicitRecoveredObservabilityVerificationOutcomes = - buildVerificationOutcomeEntriesFromDeltas( - trendReport?.classificationDeltas?.observabilityVerificationOutcomes, - ).filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) === - "recovered", - ); - const focusVerificationFailureOutcomes = - rawObservabilityVerificationOutcomes.filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !== - "recovered", - ); - const rawCurrentObservabilityVerificationOutcomes = - buildObservabilityVerificationFocusEntries( - trendReport?.classificationDeltas?.currentObservabilityVerificationOutcomes, - trendSummary.sampleCount, - ); - const explicitCurrentRecoveredObservabilityVerificationOutcomes = - buildVerificationOutcomeEntriesFromDeltas( - trendReport?.classificationDeltas?.currentRecoveredObservabilityVerificationOutcomes, - ); - const focusCurrentObservabilityVerificationOutcomes = - rawCurrentObservabilityVerificationOutcomes.filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !== - "recovered", - ); - const focusCurrentRecoveredObservabilityVerificationOutcomes = - explicitCurrentRecoveredObservabilityVerificationOutcomes.length > 0 - ? explicitCurrentRecoveredObservabilityVerificationOutcomes - : rawCurrentObservabilityVerificationOutcomes.filter( - (entry) => - getObservabilityVerificationOutcomeRole( - entry?.signal, - entry?.outcome, - ) === "recovered", - ); - const rawDegradedObservabilityVerificationOutcomes = - buildObservabilityVerificationFocusEntries( - trendReport?.classificationDeltas?.degradedObservabilityVerificationOutcomes, - trendSummary.sampleCount, - ); - const focusDegradedObservabilityVerificationOutcomes = - rawDegradedObservabilityVerificationOutcomes.filter( - (entry) => - getObservabilityVerificationOutcomeRole(entry?.signal, entry?.outcome) !== - "recovered", - ); + const verificationPresentation = deriveVerificationOutcomePresentationFromTrend({ + trendReport, + sampleCount: trendSummary.sampleCount, + }); const mergedVerificationFailureOutcomes = - focusVerificationFailureOutcomes.length > 0 - ? focusVerificationFailureOutcomes - : [ - ...focusCurrentObservabilityVerificationOutcomes, - ...focusDegradedObservabilityVerificationOutcomes, - ].sort((left, right) => { - if (right.score !== left.score) { - return right.score - left.score; - } - return left.name.localeCompare(right.name); - }); - const verificationFailureSummary = buildVerificationOutcomeSummary( - mergedVerificationFailureOutcomes, - ); - const recoveredVerificationSummary = buildVerificationOutcomeSummary( - explicitRecoveredObservabilityVerificationOutcomes.length > 0 - ? explicitRecoveredObservabilityVerificationOutcomes - : mergedVerificationFailureOutcomes.filter( - (entry) => - getObservabilityVerificationOutcomeRole( - entry?.signal, - entry?.outcome, - ) === "recovered", - ), - ); - const verificationOutcomeSummary = { - ...verificationFailureSummary, - recoveredFocusCount: recoveredVerificationSummary.recoveredFocusCount, - recoveredCaseCount: recoveredVerificationSummary.recoveredCaseCount, - topRecoveredOutcomes: recoveredVerificationSummary.topRecoveredOutcomes, - }; - const currentVerificationFailureSummary = buildVerificationOutcomeSummary( - focusCurrentObservabilityVerificationOutcomes, - ); - const currentRecoveredVerificationSummary = buildVerificationOutcomeSummary( - focusCurrentRecoveredObservabilityVerificationOutcomes, - ); - const currentVerificationOutcomeSummary = { - ...currentVerificationFailureSummary, - recoveredFocusCount: currentRecoveredVerificationSummary.recoveredFocusCount, - recoveredCaseCount: currentRecoveredVerificationSummary.recoveredCaseCount, - topRecoveredOutcomes: currentRecoveredVerificationSummary.topRecoveredOutcomes, - }; - const degradedVerificationOutcomeSummary = buildVerificationOutcomeSummary( - focusDegradedObservabilityVerificationOutcomes, - ); + verificationPresentation.mergedVerificationFailureOutcomes; + const focusVerificationFailureOutcomes = mergedVerificationFailureOutcomes; + const focusCurrentObservabilityVerificationOutcomes = + verificationPresentation.focusCurrentVerificationFailureOutcomes; + const focusCurrentRecoveredObservabilityVerificationOutcomes = + verificationPresentation.focusCurrentRecoveredVerificationOutcomes; + const focusDegradedObservabilityVerificationOutcomes = + verificationPresentation.focusDegradedVerificationFailureOutcomes; + const combinedVerificationOutcomeSummary = + verificationPresentation.verificationOutcomeSummary; + const verificationOutcomeSummary = combinedVerificationOutcomeSummary; + const currentVerificationOutcomeSummary = + combinedVerificationOutcomeSummary.current; + const degradedVerificationOutcomeSummary = + combinedVerificationOutcomeSummary.degraded; const currentRecoveredVerificationOutcomes = - focusCurrentRecoveredObservabilityVerificationOutcomes - .slice(0, 3) - .map((entry) => `${entry.signal} (${entry.outcome})`); - const combinedVerificationOutcomeSummary = { - ...verificationOutcomeSummary, - current: currentVerificationOutcomeSummary, - degraded: degradedVerificationOutcomeSummary, - }; + formatVerificationOutcomeCompactLabels( + focusCurrentRecoveredObservabilityVerificationOutcomes, + 3, + ); const governanceSurfaces = buildGovernanceSurfaceEntries(governanceReport); const governanceSummary = buildGovernanceSummary( governanceReport, @@ -1771,30 +1245,15 @@ export function buildGeneratedSlopReport({ trendSummary.latestDegradedObservabilityGapCaseCount > 0 ? `当前保留 ${trendSummary.latestDegradedObservabilityGapCaseCount} 个 degraded observability gap 样本作为诊断基线。` : "当前没有额外保留的 degraded observability gap 样本。", - focusVerificationFailureOutcomes.length > 0 - ? `当前 verification failure outcome 焦点:${focusVerificationFailureOutcomes - .slice(0, 3) - .map((entry) => `${entry.signal} (${entry.outcome})`) - .join("、")}。` - : "当前没有额外的 verification failure outcome 焦点。", - verificationOutcomeSummary.failureCaseCount > 0 - ? `当前 verification failure 聚焦 ${verificationOutcomeSummary.failureFocusCount} 类 outcome,共 ${verificationOutcomeSummary.failureCaseCount} 个 case。` - : "当前没有额外的 verification failure case。", - currentVerificationOutcomeSummary.blockingFailureCaseCount > 0 - ? `当前 current 样本里有 ${currentVerificationOutcomeSummary.blockingFailureCaseCount} 个 blocking verification failure。` - : "当前没有额外的 blocking verification failure。", - currentVerificationOutcomeSummary.advisoryFailureCaseCount > 0 - ? `当前 current 样本里有 ${currentVerificationOutcomeSummary.advisoryFailureCaseCount} 个 advisory verification failure。` - : "当前没有额外的 advisory verification failure。", - currentVerificationOutcomeSummary.recoveredCaseCount > 0 - ? `当前 current recovered verification baseline:${currentRecoveredVerificationOutcomes.join("、") || "暂无"}。` - : "当前没有额外的 current recovered verification baseline。", - degradedVerificationOutcomeSummary.blockingFailureCaseCount > 0 - ? `当前保留 ${degradedVerificationOutcomeSummary.blockingFailureCaseCount} 个 degraded blocking verification failure 样本作为诊断基线。` - : "当前没有额外的 degraded blocking verification baseline。", - verificationOutcomeSummary.recoveredCaseCount > 0 - ? `当前 verification recovered 聚焦 ${verificationOutcomeSummary.recoveredFocusCount} 类 outcome,共 ${verificationOutcomeSummary.recoveredCaseCount} 个 case。` - : "当前没有额外的 verification recovered case。", + ...buildVerificationOutcomeSignalMessages({ + focusVerificationFailureOutcomes, + verificationOutcomeSummary, + currentVerificationOutcomeSummary, + degradedVerificationOutcomeSummary, + currentRecoveredVerificationOutcomes: + focusCurrentRecoveredObservabilityVerificationOutcomes, + labelLimit: 3, + }), ], focus: { failureModes: focusFailureModes.slice(0, 5), @@ -1899,7 +1358,7 @@ export function renderGeneratedSlopText(report) { lines.push("[harness-cleanup] top observability verification outcomes:"); for (const entry of report.focus.observabilityVerificationOutcomes) { lines.push( - ` - ${entry.signal} (${entry.outcome}): state=${entry.state}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}, score=${entry.score}`, + ` - ${formatVerificationOutcomeCompactLabel(entry)}: state=${entry.state}, latest_case=${entry.latest.caseCount}, delta_case=${entry.delta.caseCount}, score=${entry.score}`, ); } } diff --git a/scripts/lib/harness-dashboard-core.mjs b/scripts/lib/harness-dashboard-core.mjs index 0142d3b33..d7e9e0d8a 100644 --- a/scripts/lib/harness-dashboard-core.mjs +++ b/scripts/lib/harness-dashboard-core.mjs @@ -1,3 +1,8 @@ +import { + describeVerificationOutcome, + deriveVerificationDashboardPresentation, +} from "./harness-verification-facts.mjs"; + function normalizeNumber(value) { return typeof value === "number" && Number.isFinite(value) ? value : 0; } @@ -141,49 +146,39 @@ function renderRecommendationList(recommendations) { .join(""); } -function describeVerificationOutcome(entry) { - const signal = normalizeString(entry?.signal, "unknown"); - const outcome = normalizeString(entry?.outcome, "unknown"); - - if (signal === "artifactValidator" && outcome === "issues_present") { - return "当前 evidence 已记录 artifact 校验问题,优先回看 validator issue 明细。"; - } - if (signal === "artifactValidator" && outcome === "fallback_used") { - return "当前 artifact 导出仍触发 fallback,说明产物结构或修复链未完全稳定。"; - } - if (signal === "browserVerification" && outcome === "failure") { - return "浏览器验证已有明确失败结果,优先回挂到 replay 或 smoke 断言。"; - } - if (signal === "browserVerification" && outcome === "unknown") { - return "浏览器验证结果仍不明确,需要先补 outcome 再继续扩分析。"; - } - if (signal === "guiSmoke" && outcome === "failed") { - return "GUI smoke 已明确失败,应优先收敛到受影响主路径。"; - } - if (signal === "guiSmoke" && outcome === "passed") { - return "GUI smoke 已通过,可继续把注意力放回 gap 与其它失败面。"; - } - if (signal === "artifactValidator" && outcome === "repaired") { - return "artifact validator 已执行修复,可结合 issues/fallback 判断是否还需继续治理。"; - } - if (signal === "browserVerification" && outcome === "success") { - return "浏览器验证已有成功样本,可作为 current 主线路径的正向基线。"; +function deriveTrendSummary(trendReport, cleanupReport) { + const cleanupTrendSummary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.trend + ? cleanupReport.summary.trend + : null; + if (cleanupTrendSummary) { + return cleanupTrendSummary; } - return "当前 verification outcome 已进入 cleanup 主线,可直接据此定位先修哪层。"; -} + const latestTotals = + trendReport && typeof trendReport === "object" && trendReport.latest?.totals + ? trendReport.latest.totals + : {}; + const delta = trendReport && typeof trendReport === "object" ? trendReport.delta : {}; -const RECOVERED_VERIFICATION_OUTCOMES = new Set([ - "repaired", - "success", - "passed", - "clean", -]); - -function isRecoveredVerificationOutcome(entry) { - return RECOVERED_VERIFICATION_OUTCOMES.has( - normalizeString(entry?.outcome, "unknown"), - ); + return { + sampleCount: normalizeNumber(trendReport?.sampleCount), + latestCurrentObservabilityGapCaseCount: normalizeNumber( + latestTotals.currentObservabilityGapCaseCount, + ), + latestDegradedObservabilityGapCaseCount: normalizeNumber( + latestTotals.degradedObservabilityGapCaseCount, + ), + currentObservabilityGapCaseDelta: normalizeNumber( + delta?.currentObservabilityGapCaseCount, + ), + degradedObservabilityGapCaseDelta: normalizeNumber( + delta?.degradedObservabilityGapCaseCount, + ), + }; } function renderFocusTable(title, entries, columns) { @@ -240,13 +235,7 @@ export function renderHarnessDashboardHtml({ summaryReport && typeof summaryReport === "object" && summaryReport.totals ? summaryReport.totals : {}; - const trendSummary = - cleanupReport && - typeof cleanupReport === "object" && - cleanupReport.summary && - cleanupReport.summary.trend - ? cleanupReport.summary.trend - : {}; + const trendSummary = deriveTrendSummary(trendReport, cleanupReport); const governanceSummary = cleanupReport && typeof cleanupReport === "object" && @@ -254,13 +243,12 @@ export function renderHarnessDashboardHtml({ cleanupReport.summary.governance ? cleanupReport.summary.governance : {}; - const verificationSummary = - cleanupReport && - typeof cleanupReport === "object" && - cleanupReport.summary && - cleanupReport.summary.verificationOutcomes - ? cleanupReport.summary.verificationOutcomes - : {}; + const verificationPresentation = deriveVerificationDashboardPresentation({ + summaryReport, + trendReport, + cleanupReport, + }); + const verificationSummary = verificationPresentation.verificationSummary; const currentVerificationSummary = verificationSummary && typeof verificationSummary.current === "object" && @@ -281,64 +269,11 @@ export function renderHarnessDashboardHtml({ ? cleanupReport.recommendations : []; const sampleRows = Array.isArray(trendReport?.samples) ? trendReport.samples : []; - const currentVerificationFocusRows = Array.isArray( - cleanupReport?.focus?.currentObservabilityVerificationOutcomes, - ) - ? cleanupReport.focus.currentObservabilityVerificationOutcomes.map((entry) => ({ - ...entry, - role: "current", - })) - : []; - const degradedVerificationFocusRows = Array.isArray( - cleanupReport?.focus?.degradedObservabilityVerificationOutcomes, - ) - ? cleanupReport.focus.degradedObservabilityVerificationOutcomes.map( - (entry) => ({ - ...entry, - role: "degraded", - }), - ) - : []; - const fallbackVerificationFocusRows = Array.isArray( - cleanupReport?.focus?.observabilityVerificationOutcomes, - ) - ? cleanupReport.focus.observabilityVerificationOutcomes.map((entry) => ({ - ...entry, - role: "mixed", - })) - : []; - const explicitCurrentRecoveredVerificationRows = Array.isArray( - cleanupReport?.focus?.currentRecoveredObservabilityVerificationOutcomes, - ) - ? cleanupReport.focus.currentRecoveredObservabilityVerificationOutcomes.map( - (entry) => ({ - ...entry, - role: "current", - }), - ) - : []; - const verificationFocusRows = - currentVerificationFocusRows.length > 0 || - degradedVerificationFocusRows.length > 0 - ? [...currentVerificationFocusRows, ...degradedVerificationFocusRows] - : fallbackVerificationFocusRows; + const verificationFocusRows = verificationPresentation.verificationFocusRows; const currentRecoveredVerificationRows = - explicitCurrentRecoveredVerificationRows.length > 0 - ? explicitCurrentRecoveredVerificationRows - : currentVerificationFocusRows.length > 0 - ? currentVerificationFocusRows.filter((entry) => - isRecoveredVerificationOutcome(entry), - ) - : fallbackVerificationFocusRows.filter((entry) => - isRecoveredVerificationOutcome(entry), - ); - const currentRecoveredVerificationSummary = currentRecoveredVerificationRows - .slice(0, 3) - .map( - (entry) => - `${normalizeString(entry?.signal, "-")} (${normalizeString(entry?.outcome, "-")})`, - ) - .join("、"); + verificationPresentation.currentRecoveredRows; + const currentRecoveredVerificationSummary = + verificationPresentation.currentRecoveredSummaryLabel; return ` diff --git a/scripts/lib/harness-dashboard-core.test.ts b/scripts/lib/harness-dashboard-core.test.ts index bef117c66..6ad8c82fd 100644 --- a/scripts/lib/harness-dashboard-core.test.ts +++ b/scripts/lib/harness-dashboard-core.test.ts @@ -3,6 +3,141 @@ import { describe, expect, it } from "vitest"; import { renderHarnessDashboardHtml } from "./harness-dashboard-core.mjs"; describe("harness-dashboard-core", () => { + it("应优先使用 trend 与 summary 的 verification facts,而不是 cleanup 渲染面", () => { + const html = renderHarnessDashboardHtml({ + title: "Harness Engine Dashboard", + summaryReport: { + generatedAt: "2026-04-12T08:00:00.000Z", + totals: { + readyCount: 1, + invalidCount: 0, + }, + breakdowns: { + observabilityVerificationOutcomes: [ + { name: "guiSmoke:failed", caseCount: 1 }, + { name: "browserVerification:success", caseCount: 1 }, + ], + currentObservabilityVerificationOutcomes: [ + { name: "guiSmoke:failed", caseCount: 1 }, + { name: "browserVerification:success", caseCount: 1 }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { name: "browserVerification:success", caseCount: 1 }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + }, + trendReport: { + generatedAt: "2026-04-12T08:01:00.000Z", + sampleCount: 2, + delta: { + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + }, + latest: { + totals: { + currentObservabilityGapCaseCount: 0, + degradedObservabilityGapCaseCount: 0, + currentRecoveredVerificationCaseCount: 1, + }, + }, + signals: ["current gap 保持为 0。"], + samples: [], + classificationDeltas: { + observabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + }, + cleanupReport: { + generatedAt: "2026-04-12T08:02:00.000Z", + signals: [], + recommendations: [], + focus: { + currentObservabilityVerificationOutcomes: [ + { + signal: "artifactValidator", + outcome: "fallback_used", + state: "regressing", + latest: { caseCount: 2 }, + delta: { caseCount: 2 }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + signal: "artifactValidator", + outcome: "repaired", + state: "expanding", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + summary: { + trend: { + sampleCount: 2, + latestCurrentObservabilityGapCaseCount: 0, + latestDegradedObservabilityGapCaseCount: 0, + currentObservabilityGapCaseDelta: 0, + degradedObservabilityGapCaseDelta: 0, + }, + verificationOutcomes: { + recoveredCaseCount: 0, + current: { + blockingFailureCaseCount: 0, + advisoryFailureCaseCount: 2, + recoveredCaseCount: 0, + }, + degraded: { + blockingFailureCaseCount: 0, + advisoryFailureCaseCount: 0, + }, + }, + governance: { + violationCount: 0, + }, + }, + }, + }); + + expect(html).toMatch(/Current Blocking<\/span>\s*1<\/strong>/); + expect(html).toMatch(/Current Recovered<\/span>\s*1<\/strong>/); + expect(html).toContain("browserVerification (success)"); + expect(html).toContain("GUI smoke 已明确失败"); + expect(html).not.toContain("artifactValidator (repaired)、"); + expect(html).not.toContain("fallback_used"); + }); + it("应把 summary、trend、cleanup 渲染成单一事实源 dashboard", () => { const html = renderHarnessDashboardHtml({ title: "Harness Engine Dashboard", diff --git a/scripts/lib/harness-eval-history-record.test.ts b/scripts/lib/harness-eval-history-record.test.ts index 8b9d0a191..37af9490a 100644 --- a/scripts/lib/harness-eval-history-record.test.ts +++ b/scripts/lib/harness-eval-history-record.test.ts @@ -4,6 +4,8 @@ import path from "node:path"; import { execFile, execFileSync } from "node:child_process"; import { afterEach, describe, expect, it } from "vitest"; +import { deriveHistoryRecordVerificationFacts } from "../harness-eval-history-record.mjs"; + const repoRoot = process.cwd(); const tempRoots: string[] = []; @@ -59,6 +61,140 @@ afterEach(() => { }); describe("harness-eval-history-record", () => { + it("应优先使用 summary 与 trend 的 verification facts,而不是 cleanup 反算结果", () => { + const summary = { + totals: { + currentRecoveredVerificationCaseCount: 2, + }, + breakdowns: { + observabilityVerificationOutcomes: [ + { name: "guiSmoke:failed", caseCount: 2 }, + { name: "artifactValidator:issues_present", caseCount: 1 }, + { name: "browserVerification:success", caseCount: 1 }, + { name: "guiSmoke:passed", caseCount: 1 }, + ], + currentObservabilityVerificationOutcomes: [ + { name: "guiSmoke:failed", caseCount: 2 }, + { name: "artifactValidator:issues_present", caseCount: 1 }, + { name: "browserVerification:success", caseCount: 1 }, + { name: "guiSmoke:passed", caseCount: 1 }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { name: "browserVerification:success", caseCount: 1 }, + { name: "guiSmoke:passed", caseCount: 1 }, + ], + degradedObservabilityVerificationOutcomes: [ + { name: "browserVerification:failure", caseCount: 1 }, + ], + }, + }; + const trendReport = { + latest: { + totals: { + currentRecoveredVerificationCaseCount: 2, + }, + }, + classificationDeltas: { + observabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 2 }, + delta: { caseCount: 2 }, + }, + { + name: "artifactValidator:issues_present", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 2 }, + delta: { caseCount: 2 }, + }, + { + name: "artifactValidator:issues_present", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "guiSmoke:passed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "guiSmoke:passed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + }, + }; + const cleanupReport = { + summary: { + verificationOutcomes: { + failureCaseCount: 99, + recoveredCaseCount: 0, + current: { + blockingFailureCaseCount: 88, + advisoryFailureCaseCount: 77, + recoveredCaseCount: 0, + }, + degraded: { + blockingFailureCaseCount: 66, + }, + }, + }, + focus: { + observabilityVerificationOutcomes: [ + { signal: "artifactValidator", outcome: "fallback_used" }, + ], + currentObservabilityVerificationOutcomes: [ + { signal: "artifactValidator", outcome: "fallback_used" }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { signal: "artifactValidator", outcome: "repaired" }, + ], + }, + }; + + const result = deriveHistoryRecordVerificationFacts({ + summary, + trendReport, + cleanupReport, + }); + + expect(result.verificationFailureOutcomeFocus).toEqual([ + "guiSmoke:failed", + "artifactValidator:issues_present", + ]); + expect(result.currentRecoveredBaselineFocus).toEqual([ + "browserVerification:success", + "guiSmoke:passed", + ]); + expect(result.verificationOutcomeCounts).toEqual({ + failureCaseCount: 3, + blockingFailureCaseCount: 2, + advisoryFailureCaseCount: 1, + recoveredCaseCount: 2, + currentRecoveredCaseCount: 2, + degradedBlockingFailureCaseCount: 1, + }); + }); + it("默认入口应产出完整 harness artifact 套件", () => { const tempRoot = createTempRoot(); const historyDir = path.join(tempRoot, ".lime", "harness", "history"); diff --git a/scripts/lib/harness-eval-repo-fixtures.test.ts b/scripts/lib/harness-eval-repo-fixtures.test.ts index 8420f0b9c..e68dfcf47 100644 --- a/scripts/lib/harness-eval-repo-fixtures.test.ts +++ b/scripts/lib/harness-eval-repo-fixtures.test.ts @@ -59,6 +59,7 @@ describe("Harness repo fixtures", () => { expect(summary.totals.observabilityGapCaseCount).toBe(1); expect(summary.totals.currentObservabilityGapCaseCount).toBe(0); expect(summary.totals.degradedObservabilityGapCaseCount).toBe(1); + expect(summary.totals.currentRecoveredVerificationCaseCount).toBe(3); const currentCase = repoFixtureSuite.cases.find( (entry: { caseId: string }) => @@ -148,6 +149,20 @@ describe("Harness repo fixtures", () => { "browserVerification:success", ); + const currentRecoveredVerificationOutcomeBreakdownNames = + summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes.map( + (entry: { name: string }) => entry.name, + ); + expect(currentRecoveredVerificationOutcomeBreakdownNames).toContain( + "artifactValidator:repaired", + ); + expect(currentRecoveredVerificationOutcomeBreakdownNames).toContain( + "browserVerification:success", + ); + expect(currentRecoveredVerificationOutcomeBreakdownNames).toContain( + "guiSmoke:passed", + ); + expect(summary.breakdowns.degradedObservabilityVerificationOutcomes).toEqual( [], ); diff --git a/scripts/lib/harness-review-decision-evals.test.ts b/scripts/lib/harness-review-decision-evals.test.ts index 64f60ce0b..94d252ead 100644 --- a/scripts/lib/harness-review-decision-evals.test.ts +++ b/scripts/lib/harness-review-decision-evals.test.ts @@ -350,6 +350,7 @@ describe("Harness review decision / eval integration", () => { expect(summary.totals.observabilityGapCaseCount).toBe(1); expect(summary.totals.currentObservabilityGapCaseCount).toBe(1); expect(summary.totals.degradedObservabilityGapCaseCount).toBe(0); + expect(summary.totals.currentRecoveredVerificationCaseCount).toBe(3); expect(summary.breakdowns.reviewDecisionStatuses).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -402,6 +403,24 @@ describe("Harness review decision / eval integration", () => { }), ]), ); + expect( + summary.breakdowns.currentRecoveredObservabilityVerificationOutcomes, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "artifactValidator:repaired", + caseCount: 1, + }), + expect.objectContaining({ + name: "browserVerification:success", + caseCount: 1, + }), + expect.objectContaining({ + name: "guiSmoke:passed", + caseCount: 1, + }), + ]), + ); expect(summary.breakdowns.degradedObservabilityVerificationOutcomes).toEqual( [], ); diff --git a/scripts/lib/harness-verification-facts.mjs b/scripts/lib/harness-verification-facts.mjs new file mode 100644 index 000000000..e8c2e51e6 --- /dev/null +++ b/scripts/lib/harness-verification-facts.mjs @@ -0,0 +1,1362 @@ +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +const VERIFICATION_FAILURE_OUTCOMES = new Set([ + "issues_present", + "fallback_used", + "failure", + "unknown", + "failed", +]); + +const VERIFICATION_RECOVERED_OUTCOMES = new Set([ + "repaired", + "success", + "passed", + "clean", +]); + +const BLOCKING_VERIFICATION_FAILURES = new Set([ + "browserVerification:failure", + "guiSmoke:failed", +]); + +export function splitVerificationOutcomeName(name) { + const normalizedName = normalizeString(name); + if (!normalizedName) { + return { name: "", signal: "", outcome: "" }; + } + + const separatorIndex = normalizedName.indexOf(":"); + if (separatorIndex < 0) { + return { + name: normalizedName, + signal: normalizedName, + outcome: "", + }; + } + + return { + name: normalizedName, + signal: normalizedName.slice(0, separatorIndex).trim(), + outcome: normalizedName.slice(separatorIndex + 1).trim(), + }; +} + +export function isVerificationFailureOutcome(outcome) { + return VERIFICATION_FAILURE_OUTCOMES.has(normalizeString(outcome)); +} + +export function isVerificationRecoveredOutcome(outcome) { + return VERIFICATION_RECOVERED_OUTCOMES.has(normalizeString(outcome)); +} + +export function getVerificationOutcomeRole(signal, outcome) { + const normalizedSignal = normalizeString(signal); + const normalizedOutcome = normalizeString(outcome); + const fingerprint = `${normalizedSignal}:${normalizedOutcome}`; + + if (BLOCKING_VERIFICATION_FAILURES.has(fingerprint)) { + return "blocking_failure"; + } + + if (isVerificationFailureOutcome(normalizedOutcome)) { + return "advisory_failure"; + } + + if (isVerificationRecoveredOutcome(normalizedOutcome)) { + return "recovered"; + } + + return "other"; +} + +export function getVerificationOutcomeWeight(outcome) { + switch (normalizeString(outcome)) { + case "failed": + return 140; + case "failure": + return 130; + case "unknown": + return 115; + case "fallback_used": + return 110; + case "issues_present": + return 100; + case "repaired": + return 70; + default: + return 0; + } +} + +function dedupeNonEmptyStrings(values) { + const normalizedValues = Array.isArray(values) ? values : []; + return [...new Set(normalizedValues.map((value) => normalizeString(value)).filter(Boolean))]; +} + +export function hasVerificationOutcome(entries, signal, outcome) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + const normalizedSignal = normalizeString(signal); + const normalizedOutcome = normalizeString(outcome); + + return normalizedEntries.some( + (entry) => + normalizeString(entry?.signal) === normalizedSignal && + normalizeString(entry?.outcome) === normalizedOutcome, + ); +} + +export function buildBlockingVerificationFollowUp(entries) { + const hasGuiSmokeFailure = hasVerificationOutcome(entries, "guiSmoke", "failed"); + const hasBrowserVerificationFailure = hasVerificationOutcome( + entries, + "browserVerification", + "failure", + ); + const commands = ["npm run harness:eval", "npm run harness:eval:trend"]; + const backlogTools = []; + const rationale = []; + + if (hasGuiSmokeFailure) { + commands.push("npm run verify:gui-smoke"); + rationale.push( + "current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。", + ); + backlogTools.push( + "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。", + ); + } + + if (hasBrowserVerificationFailure) { + rationale.push( + "current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。", + ); + backlogTools.push( + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ); + } + + if (backlogTools.length === 0) { + backlogTools.push("按受影响主路径追加 `npm run verify:gui-smoke` 或专项 smoke"); + } + + return { + commands: dedupeNonEmptyStrings(commands), + backlogTools: dedupeNonEmptyStrings(backlogTools), + rationale: dedupeNonEmptyStrings(rationale), + }; +} + +export function buildAdvisoryVerificationFollowUp(entries) { + const hasArtifactValidatorIssuesPresent = hasVerificationOutcome( + entries, + "artifactValidator", + "issues_present", + ); + const hasArtifactValidatorFallbackUsed = hasVerificationOutcome( + entries, + "artifactValidator", + "fallback_used", + ); + const hasBrowserVerificationUnknown = hasVerificationOutcome( + entries, + "browserVerification", + "unknown", + ); + const rationale = []; + const backlogTools = []; + + if (hasArtifactValidatorIssuesPresent) { + rationale.push( + "current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。", + ); + backlogTools.push( + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + ); + } + + if (hasArtifactValidatorFallbackUsed) { + rationale.push( + "current 样本已出现 artifactValidator:fallback_used,说明 artifact 主路径仍不稳定,不能继续依赖 fallback 充当事实。", + ); + backlogTools.push( + "补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。", + ); + } + + if (hasBrowserVerificationUnknown) { + rationale.push( + "current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。", + ); + backlogTools.push( + "回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。", + ); + } + + if (backlogTools.length === 0) { + backlogTools.push( + "先对齐 current verification outcome 到 artifact/browser/gui 主路径,再继续补 observability 证据。", + ); + } + + return { + rationale: dedupeNonEmptyStrings(rationale), + backlogTools: dedupeNonEmptyStrings(backlogTools), + }; +} + +export function buildRecoveredVerificationFollowUp(entries) { + const hasArtifactValidatorRepaired = hasVerificationOutcome( + entries, + "artifactValidator", + "repaired", + ); + const hasBrowserVerificationSuccess = hasVerificationOutcome( + entries, + "browserVerification", + "success", + ); + const hasGuiSmokePassed = hasVerificationOutcome(entries, "guiSmoke", "passed"); + const commands = ["npm run harness:eval", "npm run harness:eval:trend"]; + const rationale = []; + const backlogTools = []; + + if (hasArtifactValidatorRepaired) { + rationale.push( + "current 样本已出现 artifactValidator:repaired,说明 artifact 修复链已经回到可复用的主路径。", + ); + backlogTools.push( + "在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。", + ); + } + + if (hasBrowserVerificationSuccess) { + rationale.push( + "current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。", + ); + backlogTools.push( + "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。", + ); + } + + if (hasGuiSmokePassed) { + commands.push("npm run verify:gui-smoke"); + rationale.push( + "current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。", + ); + backlogTools.push( + "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。", + ); + } + + return { + commands: dedupeNonEmptyStrings(commands), + rationale: dedupeNonEmptyStrings(rationale), + backlogTools: dedupeNonEmptyStrings(backlogTools), + }; +} + +export function formatVerificationOutcomeName(entry) { + if (typeof entry === "string") { + return normalizeString(entry); + } + + const explicitName = normalizeString(entry?.name); + if (explicitName) { + return explicitName; + } + + const signal = normalizeString(entry?.signal); + const outcome = normalizeString(entry?.outcome); + return signal && outcome ? `${signal}:${outcome}` : ""; +} + +export function formatVerificationOutcomeCompactLabel(entry) { + if (typeof entry === "string") { + const parsed = splitVerificationOutcomeName(entry); + if (parsed.signal && parsed.outcome) { + return `${parsed.signal} (${parsed.outcome})`; + } + return normalizeString(entry); + } + + const signal = normalizeString(entry?.signal); + const outcome = normalizeString(entry?.outcome); + if (signal && outcome) { + return `${signal} (${outcome})`; + } + + const parsed = splitVerificationOutcomeName(entry?.name); + if (parsed.signal && parsed.outcome) { + return `${parsed.signal} (${parsed.outcome})`; + } + + return normalizeString(entry?.name); +} + +export function formatVerificationOutcomeCompactLabels(entries, limit = 0) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + const truncatedEntries = + typeof limit === "number" && limit > 0 + ? normalizedEntries.slice(0, limit) + : normalizedEntries; + return truncatedEntries + .map((entry) => formatVerificationOutcomeCompactLabel(entry)) + .filter(Boolean); +} + +export function describeVerificationOutcome(entry) { + const signal = normalizeString(entry?.signal, "unknown"); + const outcome = normalizeString(entry?.outcome, "unknown"); + + if (signal === "artifactValidator" && outcome === "issues_present") { + return "当前 evidence 已记录 artifact 校验问题,优先回看 validator issue 明细。"; + } + if (signal === "artifactValidator" && outcome === "fallback_used") { + return "当前 artifact 导出仍触发 fallback,说明产物结构或修复链未完全稳定。"; + } + if (signal === "browserVerification" && outcome === "failure") { + return "浏览器验证已有明确失败结果,优先回挂到 replay 或 smoke 断言。"; + } + if (signal === "browserVerification" && outcome === "unknown") { + return "浏览器验证结果仍不明确,需要先补 outcome 再继续扩分析。"; + } + if (signal === "guiSmoke" && outcome === "failed") { + return "GUI smoke 已明确失败,应优先收敛到受影响主路径。"; + } + if (signal === "guiSmoke" && outcome === "passed") { + return "GUI smoke 已通过,可继续把注意力放回 gap 与其它失败面。"; + } + if (signal === "artifactValidator" && outcome === "repaired") { + return "artifact validator 已执行修复,可结合 issues/fallback 判断是否还需继续治理。"; + } + if (signal === "browserVerification" && outcome === "success") { + return "浏览器验证已有成功样本,可作为 current 主线路径的正向基线。"; + } + + return "当前 verification outcome 已进入 cleanup 主线,可直接据此定位先修哪层。"; +} + +export function buildVerificationOutcomeEntriesFromDeltas(entries) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + + return normalizedEntries + .map((entry) => { + const latest = entry != null && typeof entry === "object" ? entry.latest ?? {} : {}; + const delta = entry != null && typeof entry === "object" ? entry.delta ?? {} : {}; + const baseline = + entry != null && typeof entry === "object" ? entry.baseline ?? {} : {}; + const parsed = splitVerificationOutcomeName(entry?.name); + const latestCase = normalizeNumber(latest.caseCount); + const deltaCase = normalizeNumber(delta.caseCount); + + let state = "stable"; + if (deltaCase > 0) { + state = "expanding"; + } else if (deltaCase < 0) { + state = "shrinking"; + } else if (latestCase > 0) { + state = "present"; + } + + return { + name: normalizeString(entry?.name) || "(unknown)", + signal: parsed.signal || "(unknown)", + outcome: parsed.outcome || "unknown", + baseline: { + caseCount: normalizeNumber(baseline.caseCount), + readyCount: normalizeNumber(baseline.readyCount), + invalidCount: normalizeNumber(baseline.invalidCount), + pendingRequestCaseCount: normalizeNumber( + baseline.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + baseline.needsHumanReviewCount, + ), + }, + latest: { + caseCount: latestCase, + readyCount: normalizeNumber(latest.readyCount), + invalidCount: normalizeNumber(latest.invalidCount), + pendingRequestCaseCount: normalizeNumber( + latest.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber(latest.needsHumanReviewCount), + }, + delta: { + caseCount: deltaCase, + readyCount: normalizeNumber(delta.readyCount), + invalidCount: normalizeNumber(delta.invalidCount), + pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), + }, + state, + score: latestCase * 10 + Math.abs(deltaCase) * 5, + }; + }) + .filter((entry) => entry.latest.caseCount > 0 || entry.delta.caseCount !== 0) + .sort((left, right) => { + if (right.latest.caseCount !== left.latest.caseCount) { + return right.latest.caseCount - left.latest.caseCount; + } + if (Math.abs(right.delta.caseCount) !== Math.abs(left.delta.caseCount)) { + return Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount); + } + return left.name.localeCompare(right.name); + }); +} + +export function buildVerificationOutcomeEntriesFromBreakdowns(entries) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + + return normalizedEntries + .map((entry) => { + const parsed = splitVerificationOutcomeName(entry?.name); + const latestCase = normalizeNumber(entry?.caseCount); + return { + name: normalizeString(entry?.name) || "(unknown)", + signal: parsed.signal || "(unknown)", + outcome: parsed.outcome || "unknown", + baseline: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + latest: { + caseCount: latestCase, + readyCount: normalizeNumber(entry?.readyCount), + invalidCount: normalizeNumber(entry?.invalidCount), + pendingRequestCaseCount: normalizeNumber(entry?.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(entry?.needsHumanReviewCount), + }, + delta: { + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }, + state: latestCase > 0 ? "present" : "stable", + score: latestCase * 10, + }; + }) + .filter((entry) => entry.latest.caseCount > 0) + .sort((left, right) => { + if (right.latest.caseCount !== left.latest.caseCount) { + return right.latest.caseCount - left.latest.caseCount; + } + return left.name.localeCompare(right.name); + }); +} + +export function buildVerificationOutcomeEntriesFromSummary(summary, key) { + return buildVerificationOutcomeEntriesFromBreakdowns(summary?.breakdowns?.[key]) + .map((entry) => ({ + ...entry, + latestCaseCount: normalizeNumber(entry?.latest?.caseCount), + })) + .filter((entry) => formatVerificationOutcomeName(entry) && entry.latestCaseCount > 0); +} + +export function buildVerificationOutcomeEntriesFromTrend(trendReport, key) { + return buildVerificationOutcomeEntriesFromDeltas( + trendReport?.classificationDeltas?.[key], + ) + .map((entry) => ({ + ...entry, + latestCaseCount: normalizeNumber(entry?.latest?.caseCount), + deltaCaseCount: normalizeNumber(entry?.delta?.caseCount), + })) + .filter((entry) => formatVerificationOutcomeName(entry) && entry.latestCaseCount > 0); +} + +export function filterVerificationOutcomeEntriesByRoles(entries, roles) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + const normalizedRoles = new Set( + (Array.isArray(roles) ? roles : [roles]).map((role) => normalizeString(role)).filter(Boolean), + ); + + return normalizedEntries.filter((entry) => + normalizedRoles.has(getVerificationOutcomeRole(entry?.signal, entry?.outcome)), + ); +} + +export function sumVerificationOutcomeCaseCountsByRoles(entries, roles) { + return filterVerificationOutcomeEntriesByRoles(entries, roles).reduce( + (total, entry) => total + normalizeNumber(entry?.latestCaseCount ?? entry?.latest?.caseCount), + 0, + ); +} + +function toVerificationOutcomeNames(entries) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + return normalizedEntries.map((entry) => formatVerificationOutcomeName(entry)).filter(Boolean); +} + +function pickFirstNonEmptyNames(groups) { + for (const group of groups) { + if (Array.isArray(group) && group.length > 0) { + return group; + } + } + return []; +} + +function getCleanupVerificationFocusEntries(cleanupReport, key) { + const entries = cleanupReport?.focus?.[key]; + return Array.isArray(entries) ? entries : []; +} + +function withVerificationRole(entries, role) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + return normalizedEntries.map((entry) => ({ ...entry, role })); +} + +function pickFirstNonEmptyEntries(groups) { + for (const group of groups) { + if (Array.isArray(group) && group.length > 0) { + return group; + } + } + return []; +} + +export function deriveVerificationHistoryRecordFacts({ + summary, + trendReport, + cleanupReport, +}) { + const trendCurrentEntries = filterVerificationOutcomeEntriesByRoles( + buildVerificationOutcomeEntriesFromTrend( + trendReport, + "currentObservabilityVerificationOutcomes", + ), + ["blocking_failure", "advisory_failure"], + ); + const trendFallbackEntries = filterVerificationOutcomeEntriesByRoles( + buildVerificationOutcomeEntriesFromTrend( + trendReport, + "observabilityVerificationOutcomes", + ), + ["blocking_failure", "advisory_failure"], + ); + const summaryCurrentEntries = filterVerificationOutcomeEntriesByRoles( + buildVerificationOutcomeEntriesFromSummary( + summary, + "currentObservabilityVerificationOutcomes", + ), + ["blocking_failure", "advisory_failure"], + ); + const summaryFallbackEntries = filterVerificationOutcomeEntriesByRoles( + buildVerificationOutcomeEntriesFromSummary( + summary, + "observabilityVerificationOutcomes", + ), + ["blocking_failure", "advisory_failure"], + ); + const cleanupCurrentEntries = getCleanupVerificationFocusEntries( + cleanupReport, + "currentObservabilityVerificationOutcomes", + ); + const cleanupFallbackEntries = getCleanupVerificationFocusEntries( + cleanupReport, + "observabilityVerificationOutcomes", + ); + + const verificationFailureOutcomeFocus = pickFirstNonEmptyNames([ + toVerificationOutcomeNames(trendCurrentEntries), + toVerificationOutcomeNames(trendFallbackEntries), + toVerificationOutcomeNames(summaryCurrentEntries), + toVerificationOutcomeNames(summaryFallbackEntries), + toVerificationOutcomeNames(cleanupCurrentEntries), + toVerificationOutcomeNames(cleanupFallbackEntries), + ]); + + const explicitTrendRecoveredEntries = buildVerificationOutcomeEntriesFromTrend( + trendReport, + "currentRecoveredObservabilityVerificationOutcomes", + ); + const fallbackTrendRecoveredEntries = filterVerificationOutcomeEntriesByRoles( + buildVerificationOutcomeEntriesFromTrend( + trendReport, + "currentObservabilityVerificationOutcomes", + ), + "recovered", + ); + const explicitSummaryRecoveredEntries = buildVerificationOutcomeEntriesFromSummary( + summary, + "currentRecoveredObservabilityVerificationOutcomes", + ); + const fallbackSummaryRecoveredEntries = filterVerificationOutcomeEntriesByRoles( + buildVerificationOutcomeEntriesFromSummary( + summary, + "currentObservabilityVerificationOutcomes", + ), + "recovered", + ); + const cleanupExplicitRecoveredEntries = getCleanupVerificationFocusEntries( + cleanupReport, + "currentRecoveredObservabilityVerificationOutcomes", + ); + + const currentRecoveredBaselineFocus = pickFirstNonEmptyNames([ + toVerificationOutcomeNames(explicitTrendRecoveredEntries), + toVerificationOutcomeNames(fallbackTrendRecoveredEntries), + toVerificationOutcomeNames(explicitSummaryRecoveredEntries), + toVerificationOutcomeNames(fallbackSummaryRecoveredEntries), + toVerificationOutcomeNames(cleanupExplicitRecoveredEntries), + toVerificationOutcomeNames( + filterVerificationOutcomeEntriesByRoles( + cleanupCurrentEntries.length > 0 ? cleanupCurrentEntries : cleanupFallbackEntries, + "recovered", + ), + ), + ]); + + const overallEntries = buildVerificationOutcomeEntriesFromSummary( + summary, + "observabilityVerificationOutcomes", + ); + const currentEntries = buildVerificationOutcomeEntriesFromSummary( + summary, + "currentObservabilityVerificationOutcomes", + ); + const degradedEntries = buildVerificationOutcomeEntriesFromSummary( + summary, + "degradedObservabilityVerificationOutcomes", + ); + const explicitCurrentRecoveredEntries = buildVerificationOutcomeEntriesFromSummary( + summary, + "currentRecoveredObservabilityVerificationOutcomes", + ); + + const verificationOutcomeCounts = + overallEntries.length > 0 || + currentEntries.length > 0 || + degradedEntries.length > 0 || + explicitCurrentRecoveredEntries.length > 0 + ? { + failureCaseCount: sumVerificationOutcomeCaseCountsByRoles( + overallEntries, + ["blocking_failure", "advisory_failure"], + ), + blockingFailureCaseCount: sumVerificationOutcomeCaseCountsByRoles( + currentEntries, + "blocking_failure", + ), + advisoryFailureCaseCount: sumVerificationOutcomeCaseCountsByRoles( + currentEntries, + "advisory_failure", + ), + recoveredCaseCount: sumVerificationOutcomeCaseCountsByRoles( + overallEntries, + "recovered", + ), + currentRecoveredCaseCount: + normalizeNumber( + trendReport?.latest?.totals?.currentRecoveredVerificationCaseCount, + ) || + normalizeNumber(summary?.totals?.currentRecoveredVerificationCaseCount) || + (explicitCurrentRecoveredEntries.length > 0 + ? explicitCurrentRecoveredEntries.reduce( + (total, entry) => total + normalizeNumber(entry.latestCaseCount), + 0, + ) + : sumVerificationOutcomeCaseCountsByRoles(currentEntries, "recovered")), + degradedBlockingFailureCaseCount: sumVerificationOutcomeCaseCountsByRoles( + degradedEntries, + "blocking_failure", + ), + } + : (() => { + const cleanupSummary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.verificationOutcomes && + typeof cleanupReport.summary.verificationOutcomes === "object" + ? cleanupReport.summary.verificationOutcomes + : {}; + const currentSummary = + cleanupSummary && + typeof cleanupSummary.current === "object" && + !Array.isArray(cleanupSummary.current) + ? cleanupSummary.current + : {}; + const degradedSummary = + cleanupSummary && + typeof cleanupSummary.degraded === "object" && + !Array.isArray(cleanupSummary.degraded) + ? cleanupSummary.degraded + : {}; + + return { + failureCaseCount: normalizeNumber(cleanupSummary.failureCaseCount), + blockingFailureCaseCount: normalizeNumber( + currentSummary.blockingFailureCaseCount, + ), + advisoryFailureCaseCount: normalizeNumber( + currentSummary.advisoryFailureCaseCount, + ), + recoveredCaseCount: normalizeNumber(cleanupSummary.recoveredCaseCount), + currentRecoveredCaseCount: normalizeNumber( + currentSummary.recoveredCaseCount, + ), + degradedBlockingFailureCaseCount: normalizeNumber( + degradedSummary.blockingFailureCaseCount, + ), + }; + })(); + + return { + verificationFailureOutcomeFocus, + currentRecoveredBaselineFocus, + verificationOutcomeCounts, + }; +} + +export function deriveVerificationDashboardPresentation({ + summaryReport, + trendReport, + cleanupReport, +}) { + const trendOverallRows = buildVerificationOutcomeEntriesFromTrend( + trendReport, + "observabilityVerificationOutcomes", + ); + const trendCurrentRows = buildVerificationOutcomeEntriesFromTrend( + trendReport, + "currentObservabilityVerificationOutcomes", + ); + const trendCurrentRecoveredRowsExplicit = buildVerificationOutcomeEntriesFromTrend( + trendReport, + "currentRecoveredObservabilityVerificationOutcomes", + ); + const trendDegradedRows = buildVerificationOutcomeEntriesFromTrend( + trendReport, + "degradedObservabilityVerificationOutcomes", + ); + + const summaryOverallRows = buildVerificationOutcomeEntriesFromSummary( + summaryReport, + "observabilityVerificationOutcomes", + ); + const summaryCurrentRows = buildVerificationOutcomeEntriesFromSummary( + summaryReport, + "currentObservabilityVerificationOutcomes", + ); + const summaryCurrentRecoveredRowsExplicit = + buildVerificationOutcomeEntriesFromSummary( + summaryReport, + "currentRecoveredObservabilityVerificationOutcomes", + ); + const summaryDegradedRows = buildVerificationOutcomeEntriesFromSummary( + summaryReport, + "degradedObservabilityVerificationOutcomes", + ); + + const cleanupCurrentRows = getCleanupVerificationFocusEntries( + cleanupReport, + "currentObservabilityVerificationOutcomes", + ); + const cleanupDegradedRows = getCleanupVerificationFocusEntries( + cleanupReport, + "degradedObservabilityVerificationOutcomes", + ); + const cleanupFallbackRows = getCleanupVerificationFocusEntries( + cleanupReport, + "observabilityVerificationOutcomes", + ); + const cleanupCurrentRecoveredRows = getCleanupVerificationFocusEntries( + cleanupReport, + "currentRecoveredObservabilityVerificationOutcomes", + ); + + const currentFailureRows = pickFirstNonEmptyEntries([ + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + trendCurrentRows, + ["blocking_failure", "advisory_failure"], + ), + "current", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + summaryCurrentRows, + ["blocking_failure", "advisory_failure"], + ), + "current", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + cleanupCurrentRows, + ["blocking_failure", "advisory_failure"], + ), + "current", + ), + ]); + const degradedFailureRows = pickFirstNonEmptyEntries([ + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + trendDegradedRows, + ["blocking_failure", "advisory_failure"], + ), + "degraded", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + summaryDegradedRows, + ["blocking_failure", "advisory_failure"], + ), + "degraded", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + cleanupDegradedRows, + ["blocking_failure", "advisory_failure"], + ), + "degraded", + ), + ]); + const fallbackFailureRows = pickFirstNonEmptyEntries([ + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + trendOverallRows, + ["blocking_failure", "advisory_failure"], + ), + "mixed", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + summaryOverallRows, + ["blocking_failure", "advisory_failure"], + ), + "mixed", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles( + cleanupFallbackRows, + ["blocking_failure", "advisory_failure"], + ), + "mixed", + ), + ]); + const currentRecoveredRows = pickFirstNonEmptyEntries([ + withVerificationRole(trendCurrentRecoveredRowsExplicit, "current"), + withVerificationRole(summaryCurrentRecoveredRowsExplicit, "current"), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles(trendCurrentRows, "recovered"), + "current", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles(summaryCurrentRows, "recovered"), + "current", + ), + withVerificationRole(cleanupCurrentRecoveredRows, "current"), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles(cleanupCurrentRows, "recovered"), + "current", + ), + withVerificationRole( + filterVerificationOutcomeEntriesByRoles(cleanupFallbackRows, "recovered"), + "mixed", + ), + ]); + + const verificationFocusRows = + currentFailureRows.length > 0 || degradedFailureRows.length > 0 + ? [...currentFailureRows, ...degradedFailureRows] + : fallbackFailureRows; + + const overallSummaryEntries = pickFirstNonEmptyEntries([ + trendOverallRows, + summaryOverallRows, + [...currentFailureRows, ...degradedFailureRows, ...currentRecoveredRows], + ]); + const currentSummaryEntries = pickFirstNonEmptyEntries([ + trendCurrentRecoveredRowsExplicit.length > 0 + ? [ + ...filterVerificationOutcomeEntriesByRoles( + trendCurrentRows, + ["blocking_failure", "advisory_failure"], + ), + ...trendCurrentRecoveredRowsExplicit, + ] + : trendCurrentRows, + summaryCurrentRecoveredRowsExplicit.length > 0 + ? [ + ...filterVerificationOutcomeEntriesByRoles( + summaryCurrentRows, + ["blocking_failure", "advisory_failure"], + ), + ...summaryCurrentRecoveredRowsExplicit, + ] + : summaryCurrentRows, + cleanupCurrentRecoveredRows.length > 0 + ? [ + ...filterVerificationOutcomeEntriesByRoles( + cleanupCurrentRows, + ["blocking_failure", "advisory_failure"], + ), + ...cleanupCurrentRecoveredRows, + ] + : cleanupCurrentRows, + [...currentFailureRows, ...currentRecoveredRows], + ]); + const degradedSummaryEntries = pickFirstNonEmptyEntries([ + trendDegradedRows, + summaryDegradedRows, + cleanupDegradedRows, + degradedFailureRows, + ]); + + const derivedVerificationSummary = + overallSummaryEntries.length > 0 || + currentSummaryEntries.length > 0 || + degradedSummaryEntries.length > 0 + ? { + ...buildVerificationOutcomeSummary(overallSummaryEntries), + current: buildVerificationOutcomeSummary(currentSummaryEntries), + degraded: buildVerificationOutcomeSummary(degradedSummaryEntries), + } + : null; + + const fallbackVerificationSummary = + cleanupReport && + typeof cleanupReport === "object" && + cleanupReport.summary && + cleanupReport.summary.verificationOutcomes + ? cleanupReport.summary.verificationOutcomes + : {}; + + return { + verificationSummary: derivedVerificationSummary ?? fallbackVerificationSummary, + verificationFocusRows, + currentRecoveredRows, + currentRecoveredSummaryLabel: currentRecoveredRows + .slice(0, 3) + .map((entry) => formatVerificationOutcomeCompactLabel(entry)) + .filter(Boolean) + .join("、"), + }; +} + +export function buildVerificationOutcomeSignalMessages({ + focusVerificationFailureOutcomes, + verificationOutcomeSummary, + currentVerificationOutcomeSummary, + degradedVerificationOutcomeSummary, + currentRecoveredVerificationOutcomes, + labelLimit = 3, +}) { + const failureLabels = formatVerificationOutcomeCompactLabels( + focusVerificationFailureOutcomes, + labelLimit, + ); + const recoveredLabels = formatVerificationOutcomeCompactLabels( + currentRecoveredVerificationOutcomes, + labelLimit, + ); + + return [ + failureLabels.length > 0 + ? `当前 verification failure outcome 焦点:${failureLabels.join("、")}。` + : "当前没有额外的 verification failure outcome 焦点。", + normalizeNumber(verificationOutcomeSummary?.failureCaseCount) > 0 + ? `当前 verification failure 聚焦 ${normalizeNumber(verificationOutcomeSummary?.failureFocusCount)} 类 outcome,共 ${normalizeNumber(verificationOutcomeSummary?.failureCaseCount)} 个 case。` + : "当前没有额外的 verification failure case。", + normalizeNumber(currentVerificationOutcomeSummary?.blockingFailureCaseCount) > 0 + ? `当前 current 样本里有 ${normalizeNumber(currentVerificationOutcomeSummary?.blockingFailureCaseCount)} 个 blocking verification failure。` + : "当前没有额外的 blocking verification failure。", + normalizeNumber(currentVerificationOutcomeSummary?.advisoryFailureCaseCount) > 0 + ? `当前 current 样本里有 ${normalizeNumber(currentVerificationOutcomeSummary?.advisoryFailureCaseCount)} 个 advisory verification failure。` + : "当前没有额外的 advisory verification failure。", + normalizeNumber(currentVerificationOutcomeSummary?.recoveredCaseCount) > 0 + ? `当前 current recovered verification baseline:${recoveredLabels.join("、") || "暂无"}。` + : "当前没有额外的 current recovered verification baseline。", + normalizeNumber(degradedVerificationOutcomeSummary?.blockingFailureCaseCount) > 0 + ? `当前保留 ${normalizeNumber(degradedVerificationOutcomeSummary?.blockingFailureCaseCount)} 个 degraded blocking verification failure 样本作为诊断基线。` + : "当前没有额外的 degraded blocking verification baseline。", + normalizeNumber(verificationOutcomeSummary?.recoveredCaseCount) > 0 + ? `当前 verification recovered 聚焦 ${normalizeNumber(verificationOutcomeSummary?.recoveredFocusCount)} 类 outcome,共 ${normalizeNumber(verificationOutcomeSummary?.recoveredCaseCount)} 个 case。` + : "当前没有额外的 verification recovered case。", + ]; +} + +export function buildBlockingVerificationRecommendationRationale({ + topCurrentVerificationFailureOutcomes, + currentVerificationSummary, + degradedVerificationSummary, +}) { + const currentFailureLabels = formatVerificationOutcomeCompactLabels( + topCurrentVerificationFailureOutcomes, + 3, + ); + + return [ + currentFailureLabels.length > 0 + ? `当前 current verification failure outcome 焦点:${currentFailureLabels.join("、")}。` + : "当前没有额外的 verification failure outcome 焦点。", + normalizeNumber(currentVerificationSummary?.blockingFailureCaseCount) > 0 + ? `其中 current blocking verification failure 共 ${normalizeNumber(currentVerificationSummary?.blockingFailureCaseCount)} 个 case:${(Array.isArray(currentVerificationSummary?.topBlockingFailureOutcomes) ? currentVerificationSummary.topBlockingFailureOutcomes : []).join("、") || "暂无"}。` + : "当前没有额外的 blocking verification failure。", + normalizeNumber(degradedVerificationSummary?.blockingFailureCaseCount) > 0 + ? `另有 ${normalizeNumber(degradedVerificationSummary?.blockingFailureCaseCount)} 个 degraded blocking verification failure 样本作为诊断基线,不直接抬高主线优先级。` + : "当前没有额外的 degraded blocking verification baseline。", + ]; +} + +export function buildAdvisoryVerificationRecommendationRationale({ + topCurrentVerificationFailureOutcomes, + topDegradedVerificationFailureOutcomes, + currentVerificationSummary, +}) { + const currentFailureLabels = formatVerificationOutcomeCompactLabels( + topCurrentVerificationFailureOutcomes, + 3, + ); + const degradedFailureLabels = formatVerificationOutcomeCompactLabels( + topDegradedVerificationFailureOutcomes, + 3, + ); + + return [ + currentFailureLabels.length > 0 + ? `当前 current verification failure outcome 焦点:${currentFailureLabels.join("、")}。可用它们直接定位先补 artifact/browser/gui 哪一层。` + : "当前没有额外的 verification failure outcome 焦点。", + normalizeNumber(currentVerificationSummary?.advisoryFailureCaseCount) > 0 + ? `当前 current advisory verification failure 共 ${normalizeNumber(currentVerificationSummary?.advisoryFailureCaseCount)} 个 case:${(Array.isArray(currentVerificationSummary?.topAdvisoryFailureOutcomes) ? currentVerificationSummary.topAdvisoryFailureOutcomes : []).join("、") || "暂无"}。` + : "当前没有额外的 advisory verification failure。", + degradedFailureLabels.length > 0 + ? `当前保留的 degraded verification baseline:${degradedFailureLabels.join("、")}。` + : "当前没有额外的 degraded verification baseline。", + ]; +} + +export function buildRecoveredVerificationRecommendationRationale({ + topCurrentRecoveredVerificationOutcomes, + currentVerificationSummary, +}) { + const recoveredLabels = formatVerificationOutcomeCompactLabels( + topCurrentRecoveredVerificationOutcomes, + 3, + ); + + return [ + recoveredLabels.length > 0 + ? `当前 current recovered outcome 焦点:${recoveredLabels.join("、")}。` + : `当前 current recovered outcome 共 ${normalizeNumber(currentVerificationSummary?.recoveredCaseCount)} 个 case。`, + ]; +} + +export function buildObservabilityRecommendationRationale({ + trendSummary, + topObservabilitySignals, + topCurrentVerificationFailureOutcomes, + topDegradedVerificationFailureOutcomes, + currentVerificationSummary, +}) { + const observabilitySignalLabels = Array.isArray(topObservabilitySignals) + ? topObservabilitySignals.map((entry) => normalizeString(entry)).filter(Boolean) + : []; + + return [ + normalizeNumber(trendSummary?.latestCurrentObservabilityGapCaseCount) > 0 + ? `当前仍有 ${normalizeNumber(trendSummary?.latestCurrentObservabilityGapCaseCount)} 个 current case 带着 observability 证据缺口进入 replay/eval。` + : "当前 trend 已检测到 observability coverage 漂移,需先修证据而不是空谈根因分析。", + normalizeNumber(trendSummary?.latestDegradedObservabilityGapCaseCount) > 0 + ? `另有 ${normalizeNumber(trendSummary?.latestDegradedObservabilityGapCaseCount)} 个 degraded gap 样本作为诊断基线保留,它们不应直接被当成主线回归。` + : "当前没有额外保留的 degraded observability gap 样本。", + `当前缺口焦点:${observabilitySignalLabels.join("、") || "暂无"}。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。`, + ...buildAdvisoryVerificationRecommendationRationale({ + topCurrentVerificationFailureOutcomes, + topDegradedVerificationFailureOutcomes, + currentVerificationSummary, + }), + ]; +} + +export function buildObservabilityRecommendationBacklog({ + topCurrentVerificationFailureOutcomes, + advisoryFollowUpBacklogTools, +}) { + const currentFailureLabels = formatVerificationOutcomeCompactLabels( + topCurrentVerificationFailureOutcomes, + 3, + ); + const followUpBacklogTools = Array.isArray(advisoryFollowUpBacklogTools) + ? advisoryFollowUpBacklogTools.map((entry) => normalizeString(entry)).filter(Boolean) + : []; + + return dedupeNonEmptyStrings([ + "优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。", + currentFailureLabels.length > 0 + ? `先对齐 current verification failure outcome:${currentFailureLabels.join("、")}。` + : "", + ...followUpBacklogTools, + ]); +} + +export function buildVerificationFocusEntriesFromDeltas(entries, sampleCount) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + + return normalizedEntries + .map((entry) => { + const latest = entry != null && typeof entry === "object" ? entry.latest ?? {} : {}; + const delta = entry != null && typeof entry === "object" ? entry.delta ?? {} : {}; + const baseline = + entry != null && typeof entry === "object" ? entry.baseline ?? {} : {}; + const parsed = splitVerificationOutcomeName(entry?.name); + const positiveDeltaCase = Math.max(0, normalizeNumber(delta.caseCount)); + const latestCase = normalizeNumber(latest.caseCount); + const weight = getVerificationOutcomeWeight(parsed.outcome); + const score = positiveDeltaCase * 140 + latestCase * weight; + + let state = "stable"; + if (sampleCount < 2 && latestCase > 0 && weight > 0) { + state = "seed-risk"; + } else if (positiveDeltaCase > 0 && weight > 0) { + state = "regressing"; + } else if (latestCase > 0 && weight > 0) { + state = "present"; + } + + return { + name: normalizeString(entry?.name) || "(unknown)", + signal: parsed.signal || "(unknown)", + outcome: parsed.outcome || "unknown", + baseline: { + caseCount: normalizeNumber(baseline.caseCount), + readyCount: normalizeNumber(baseline.readyCount), + invalidCount: normalizeNumber(baseline.invalidCount), + pendingRequestCaseCount: normalizeNumber( + baseline.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber( + baseline.needsHumanReviewCount, + ), + }, + latest: { + caseCount: latestCase, + readyCount: normalizeNumber(latest.readyCount), + invalidCount: normalizeNumber(latest.invalidCount), + pendingRequestCaseCount: normalizeNumber( + latest.pendingRequestCaseCount, + ), + needsHumanReviewCount: normalizeNumber(latest.needsHumanReviewCount), + }, + delta: { + caseCount: normalizeNumber(delta.caseCount), + readyCount: normalizeNumber(delta.readyCount), + invalidCount: normalizeNumber(delta.invalidCount), + pendingRequestCaseCount: normalizeNumber(delta.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(delta.needsHumanReviewCount), + }, + state, + score, + }; + }) + .filter( + (entry) => + entry.score > 0 || + (entry.latest.caseCount > 0 && + isVerificationFailureOutcome(entry.outcome)), + ) + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.name.localeCompare(right.name); + }); +} + +export function buildVerificationOutcomeSummary(entries) { + const normalizedEntries = Array.isArray(entries) ? entries : []; + const blockingFailureEntries = normalizedEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "blocking_failure", + ); + const advisoryFailureEntries = normalizedEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "advisory_failure", + ); + const failureEntries = [...blockingFailureEntries, ...advisoryFailureEntries]; + const recoveredEntries = normalizedEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === "recovered", + ); + + return { + focusCount: normalizedEntries.length, + failureFocusCount: failureEntries.length, + recoveredFocusCount: recoveredEntries.length, + blockingFailureFocusCount: blockingFailureEntries.length, + advisoryFailureFocusCount: advisoryFailureEntries.length, + failureCaseCount: failureEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + blockingFailureCaseCount: blockingFailureEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + advisoryFailureCaseCount: advisoryFailureEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + recoveredCaseCount: recoveredEntries.reduce( + (total, entry) => total + normalizeNumber(entry?.latest?.caseCount), + 0, + ), + topFailureOutcomes: failureEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + topBlockingFailureOutcomes: blockingFailureEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + topAdvisoryFailureOutcomes: advisoryFailureEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + topRecoveredOutcomes: recoveredEntries + .slice(0, 3) + .map((entry) => `${entry.signal}:${entry.outcome}`), + }; +} + +export function deriveVerificationOutcomePresentationFromTrend({ + trendReport, + sampleCount = 0, +}) { + const rawVerificationFocusEntries = buildVerificationFocusEntriesFromDeltas( + trendReport?.classificationDeltas?.observabilityVerificationOutcomes, + sampleCount, + ); + const explicitRecoveredVerificationEntries = + buildVerificationOutcomeEntriesFromDeltas( + trendReport?.classificationDeltas?.observabilityVerificationOutcomes, + ).filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", + ); + const focusVerificationFailureOutcomes = + rawVerificationFocusEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) !== + "recovered", + ); + + const rawCurrentVerificationFocusEntries = + buildVerificationFocusEntriesFromDeltas( + trendReport?.classificationDeltas?.currentObservabilityVerificationOutcomes, + sampleCount, + ); + const explicitCurrentRecoveredVerificationEntries = + buildVerificationOutcomeEntriesFromDeltas( + trendReport?.classificationDeltas + ?.currentRecoveredObservabilityVerificationOutcomes, + ); + const focusCurrentVerificationFailureOutcomes = + rawCurrentVerificationFocusEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) !== + "recovered", + ); + const focusCurrentRecoveredVerificationOutcomes = + explicitCurrentRecoveredVerificationEntries.length > 0 + ? explicitCurrentRecoveredVerificationEntries + : rawCurrentVerificationFocusEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", + ); + + const rawDegradedVerificationFocusEntries = + buildVerificationFocusEntriesFromDeltas( + trendReport?.classificationDeltas?.degradedObservabilityVerificationOutcomes, + sampleCount, + ); + const focusDegradedVerificationFailureOutcomes = + rawDegradedVerificationFocusEntries.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) !== + "recovered", + ); + + const mergedVerificationFailureOutcomes = + focusVerificationFailureOutcomes.length > 0 + ? focusVerificationFailureOutcomes + : [ + ...focusCurrentVerificationFailureOutcomes, + ...focusDegradedVerificationFailureOutcomes, + ].sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.name.localeCompare(right.name); + }); + + const verificationFailureSummary = buildVerificationOutcomeSummary( + mergedVerificationFailureOutcomes, + ); + const recoveredVerificationSummary = buildVerificationOutcomeSummary( + explicitRecoveredVerificationEntries.length > 0 + ? explicitRecoveredVerificationEntries + : mergedVerificationFailureOutcomes.filter( + (entry) => + getVerificationOutcomeRole(entry?.signal, entry?.outcome) === + "recovered", + ), + ); + const verificationOutcomeSummary = { + ...verificationFailureSummary, + recoveredFocusCount: recoveredVerificationSummary.recoveredFocusCount, + recoveredCaseCount: recoveredVerificationSummary.recoveredCaseCount, + topRecoveredOutcomes: recoveredVerificationSummary.topRecoveredOutcomes, + }; + + const currentVerificationFailureSummary = buildVerificationOutcomeSummary( + focusCurrentVerificationFailureOutcomes, + ); + const currentRecoveredVerificationSummary = buildVerificationOutcomeSummary( + focusCurrentRecoveredVerificationOutcomes, + ); + const currentVerificationOutcomeSummary = { + ...currentVerificationFailureSummary, + recoveredFocusCount: currentRecoveredVerificationSummary.recoveredFocusCount, + recoveredCaseCount: currentRecoveredVerificationSummary.recoveredCaseCount, + topRecoveredOutcomes: currentRecoveredVerificationSummary.topRecoveredOutcomes, + }; + + const degradedVerificationOutcomeSummary = buildVerificationOutcomeSummary( + focusDegradedVerificationFailureOutcomes, + ); + + return { + rawVerificationFocusEntries, + explicitRecoveredVerificationEntries, + focusVerificationFailureOutcomes, + rawCurrentVerificationFocusEntries, + explicitCurrentRecoveredVerificationEntries, + focusCurrentVerificationFailureOutcomes, + focusCurrentRecoveredVerificationOutcomes, + rawDegradedVerificationFocusEntries, + focusDegradedVerificationFailureOutcomes, + mergedVerificationFailureOutcomes, + verificationOutcomeSummary: { + ...verificationOutcomeSummary, + current: currentVerificationOutcomeSummary, + degraded: degradedVerificationOutcomeSummary, + }, + }; +} diff --git a/scripts/lib/harness-verification-facts.test.ts b/scripts/lib/harness-verification-facts.test.ts new file mode 100644 index 000000000..c1e7df4bf --- /dev/null +++ b/scripts/lib/harness-verification-facts.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAdvisoryVerificationFollowUp, + buildAdvisoryVerificationRecommendationRationale, + buildBlockingVerificationFollowUp, + buildBlockingVerificationRecommendationRationale, + buildObservabilityRecommendationBacklog, + buildObservabilityRecommendationRationale, + buildVerificationOutcomeSignalMessages, + buildRecoveredVerificationRecommendationRationale, + deriveVerificationDashboardPresentation, + describeVerificationOutcome, + formatVerificationOutcomeCompactLabels, + buildRecoveredVerificationFollowUp, + buildVerificationOutcomeEntriesFromBreakdowns, + buildVerificationOutcomeEntriesFromDeltas, + buildVerificationFocusEntriesFromDeltas, + buildVerificationOutcomeSummary, + deriveVerificationOutcomePresentationFromTrend, + formatVerificationOutcomeCompactLabel, + getVerificationOutcomeRole, + getVerificationOutcomeWeight, + hasVerificationOutcome, + isVerificationFailureOutcome, + isVerificationRecoveredOutcome, + splitVerificationOutcomeName, +} from "./harness-verification-facts.mjs"; + +describe("harness-verification-facts", () => { + it("应统一解析 outcome 名称、角色与权重", () => { + expect(splitVerificationOutcomeName("browserVerification:failure")).toEqual({ + name: "browserVerification:failure", + signal: "browserVerification", + outcome: "failure", + }); + expect(splitVerificationOutcomeName("guiSmoke")).toEqual({ + name: "guiSmoke", + signal: "guiSmoke", + outcome: "", + }); + + expect(getVerificationOutcomeRole("browserVerification", "failure")).toBe( + "blocking_failure", + ); + expect(getVerificationOutcomeRole("artifactValidator", "issues_present")).toBe( + "advisory_failure", + ); + expect(getVerificationOutcomeRole("browserVerification", "success")).toBe( + "recovered", + ); + expect(getVerificationOutcomeRole("other", "noop")).toBe("other"); + + expect(isVerificationFailureOutcome("fallback_used")).toBe(true); + expect(isVerificationRecoveredOutcome("repaired")).toBe(true); + expect(getVerificationOutcomeWeight("failed")).toBe(140); + expect(getVerificationOutcomeWeight("repaired")).toBe(70); + expect(getVerificationOutcomeWeight("noop")).toBe(0); + }); + + it("应统一从 delta 与 breakdown 派生 verification entries 与 summary", () => { + const deltaEntries = buildVerificationOutcomeEntriesFromDeltas([ + { + name: "guiSmoke:failed", + latest: { caseCount: 2 }, + delta: { caseCount: 1 }, + }, + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 0 }, + }, + ]); + const breakdownEntries = buildVerificationOutcomeEntriesFromBreakdowns([ + { + name: "artifactValidator:issues_present", + caseCount: 1, + }, + ]); + + expect(deltaEntries.map((entry) => entry.name)).toEqual([ + "guiSmoke:failed", + "browserVerification:success", + ]); + expect(breakdownEntries[0]).toMatchObject({ + signal: "artifactValidator", + outcome: "issues_present", + latest: { caseCount: 1 }, + delta: { caseCount: 0 }, + }); + + expect( + buildVerificationOutcomeSummary([...deltaEntries, ...breakdownEntries]), + ).toMatchObject({ + failureCaseCount: 3, + blockingFailureCaseCount: 2, + advisoryFailureCaseCount: 1, + recoveredCaseCount: 1, + topBlockingFailureOutcomes: ["guiSmoke:failed"], + topRecoveredOutcomes: ["browserVerification:success"], + }); + }); + + it("应从 trend classification deltas 派生 cleanup 可复用的 verification presentation", () => { + const presentation = deriveVerificationOutcomePresentationFromTrend({ + sampleCount: 2, + trendReport: { + classificationDeltas: { + observabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "artifactValidator:repaired", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "artifactValidator:repaired", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + name: "artifactValidator:repaired", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + degradedObservabilityVerificationOutcomes: [ + { + name: "browserVerification:failure", + latest: { caseCount: 1 }, + delta: { caseCount: 0 }, + }, + ], + }, + }, + }); + + expect( + buildVerificationFocusEntriesFromDeltas([ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], 2)[0], + ).toMatchObject({ + signal: "guiSmoke", + outcome: "failed", + state: "regressing", + }); + expect( + presentation.focusCurrentVerificationFailureOutcomes.map( + (entry) => entry.name, + ), + ).toEqual(["guiSmoke:failed"]); + expect( + presentation.focusCurrentRecoveredVerificationOutcomes.map( + (entry) => entry.name, + ), + ).toEqual(["artifactValidator:repaired"]); + expect( + presentation.focusDegradedVerificationFailureOutcomes.map( + (entry) => entry.name, + ), + ).toEqual(["browserVerification:failure"]); + expect(presentation.verificationOutcomeSummary).toMatchObject({ + failureCaseCount: 1, + recoveredCaseCount: 1, + current: { + blockingFailureCaseCount: 1, + recoveredCaseCount: 1, + }, + degraded: { + blockingFailureCaseCount: 1, + }, + }); + }); + + it("应统一生成 blocking/advisory/recovered follow-up 建议", () => { + const blockingEntries = [ + { + signal: "guiSmoke", + outcome: "failed", + }, + { + signal: "browserVerification", + outcome: "failure", + }, + ]; + const advisoryEntries = [ + { + signal: "artifactValidator", + outcome: "issues_present", + }, + { + signal: "browserVerification", + outcome: "unknown", + }, + ]; + const recoveredEntries = [ + { + signal: "artifactValidator", + outcome: "repaired", + }, + { + signal: "browserVerification", + outcome: "success", + }, + { + signal: "guiSmoke", + outcome: "passed", + }, + ]; + + expect( + hasVerificationOutcome(blockingEntries, "guiSmoke", "failed"), + ).toBe(true); + expect( + hasVerificationOutcome(blockingEntries, "guiSmoke", "passed"), + ).toBe(false); + + expect(buildBlockingVerificationFollowUp(blockingEntries)).toMatchObject({ + commands: [ + "npm run harness:eval", + "npm run harness:eval:trend", + "npm run verify:gui-smoke", + ], + backlogTools: expect.arrayContaining([ + "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。", + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ]), + rationale: expect.arrayContaining([ + "current 样本已出现 guiSmoke:failed,先恢复 GUI 壳 / DevBridge / Workspace 主路径的最小可启动性。", + "current 样本已出现 browserVerification:failure,应先回看 browser replay / verification 失败样本,把失败断言回挂到受影响主路径。", + ]), + }); + + expect(buildAdvisoryVerificationFollowUp(advisoryEntries)).toMatchObject({ + backlogTools: expect.arrayContaining([ + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + "回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。", + ]), + rationale: expect.arrayContaining([ + "current 样本已出现 artifactValidator:issues_present,应先回看 validator issue 明细,再收敛 artifact 导出字段。", + "current 样本已出现 browserVerification:unknown,需要先把浏览器验证结果收敛成明确 outcome,再继续扩大分析。", + ]), + }); + + expect(buildRecoveredVerificationFollowUp(recoveredEntries)).toMatchObject({ + commands: [ + "npm run harness:eval", + "npm run harness:eval:trend", + "npm run verify:gui-smoke", + ], + backlogTools: expect.arrayContaining([ + "在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。", + "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。", + "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。", + ]), + rationale: expect.arrayContaining([ + "current 样本已出现 artifactValidator:repaired,说明 artifact 修复链已经回到可复用的主路径。", + "current 样本已出现 browserVerification:success,可把浏览器验证成功样本固化成主路径正向基线。", + "current 样本已出现 guiSmoke:passed,可继续把 GUI smoke 通过链路当成桌面主路径的正向守卫。", + ]), + }); + }); + + it("应统一为 dashboard 派生 verification presentation 与说明文案", () => { + const presentation = deriveVerificationDashboardPresentation({ + summaryReport: { + breakdowns: { + observabilityVerificationOutcomes: [ + { name: "guiSmoke:failed", caseCount: 1 }, + { name: "browserVerification:success", caseCount: 1 }, + ], + currentObservabilityVerificationOutcomes: [ + { name: "guiSmoke:failed", caseCount: 1 }, + { name: "browserVerification:success", caseCount: 1 }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { name: "browserVerification:success", caseCount: 1 }, + ], + }, + }, + trendReport: { + classificationDeltas: { + observabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentObservabilityVerificationOutcomes: [ + { + name: "guiSmoke:failed", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + currentRecoveredObservabilityVerificationOutcomes: [ + { + name: "browserVerification:success", + latest: { caseCount: 1 }, + delta: { caseCount: 1 }, + }, + ], + degradedObservabilityVerificationOutcomes: [], + }, + }, + cleanupReport: { + focus: { + currentObservabilityVerificationOutcomes: [ + { + signal: "artifactValidator", + outcome: "fallback_used", + latest: { caseCount: 2 }, + delta: { caseCount: 2 }, + }, + ], + }, + summary: { + verificationOutcomes: { + current: { + advisoryFailureCaseCount: 2, + }, + }, + }, + }, + }); + + expect(presentation.verificationSummary).toMatchObject({ + current: { + blockingFailureCaseCount: 1, + recoveredCaseCount: 1, + }, + }); + expect( + presentation.verificationFocusRows.map((entry) => entry.name), + ).toEqual(["guiSmoke:failed"]); + expect( + presentation.currentRecoveredRows.map((entry) => entry.name), + ).toEqual(["browserVerification:success"]); + expect(presentation.currentRecoveredSummaryLabel).toBe( + "browserVerification (success)", + ); + expect( + formatVerificationOutcomeCompactLabel("artifactValidator:repaired"), + ).toBe("artifactValidator (repaired)"); + expect( + formatVerificationOutcomeCompactLabels( + [ + { signal: "artifactValidator", outcome: "repaired" }, + "browserVerification:success", + ], + 2, + ), + ).toEqual([ + "artifactValidator (repaired)", + "browserVerification (success)", + ]); + expect( + describeVerificationOutcome({ + signal: "browserVerification", + outcome: "success", + }), + ).toContain("浏览器验证已有成功样本"); + }); + + it("应统一生成 verification summary signal 文案", () => { + expect( + buildVerificationOutcomeSignalMessages({ + focusVerificationFailureOutcomes: [ + { signal: "browserVerification", outcome: "failure" }, + ], + verificationOutcomeSummary: { + failureFocusCount: 1, + failureCaseCount: 1, + recoveredFocusCount: 1, + recoveredCaseCount: 1, + }, + currentVerificationOutcomeSummary: { + blockingFailureCaseCount: 1, + advisoryFailureCaseCount: 0, + recoveredCaseCount: 1, + }, + degradedVerificationOutcomeSummary: { + blockingFailureCaseCount: 0, + }, + currentRecoveredVerificationOutcomes: [ + { signal: "artifactValidator", outcome: "repaired" }, + ], + }), + ).toEqual([ + "当前 verification failure outcome 焦点:browserVerification (failure)。", + "当前 verification failure 聚焦 1 类 outcome,共 1 个 case。", + "当前 current 样本里有 1 个 blocking verification failure。", + "当前没有额外的 advisory verification failure。", + "当前 current recovered verification baseline:artifactValidator (repaired)。", + "当前没有额外的 degraded blocking verification baseline。", + "当前 verification recovered 聚焦 1 类 outcome,共 1 个 case。", + ]); + }); + + it("应统一生成 recommendation 用的 verification rationale 片段", () => { + expect( + buildBlockingVerificationRecommendationRationale({ + topCurrentVerificationFailureOutcomes: [ + { signal: "browserVerification", outcome: "failure" }, + ], + currentVerificationSummary: { + blockingFailureCaseCount: 1, + topBlockingFailureOutcomes: ["browserVerification:failure"], + }, + degradedVerificationSummary: { + blockingFailureCaseCount: 0, + }, + }), + ).toEqual([ + "当前 current verification failure outcome 焦点:browserVerification (failure)。", + "其中 current blocking verification failure 共 1 个 case:browserVerification:failure。", + "当前没有额外的 degraded blocking verification baseline。", + ]); + + expect( + buildAdvisoryVerificationRecommendationRationale({ + topCurrentVerificationFailureOutcomes: [ + { signal: "artifactValidator", outcome: "issues_present" }, + ], + topDegradedVerificationFailureOutcomes: [ + { signal: "guiSmoke", outcome: "failed" }, + ], + currentVerificationSummary: { + advisoryFailureCaseCount: 1, + topAdvisoryFailureOutcomes: ["artifactValidator:issues_present"], + }, + }), + ).toEqual([ + "当前 current verification failure outcome 焦点:artifactValidator (issues_present)。可用它们直接定位先补 artifact/browser/gui 哪一层。", + "当前 current advisory verification failure 共 1 个 case:artifactValidator:issues_present。", + "当前保留的 degraded verification baseline:guiSmoke (failed)。", + ]); + + expect( + buildRecoveredVerificationRecommendationRationale({ + topCurrentRecoveredVerificationOutcomes: [ + { signal: "artifactValidator", outcome: "repaired" }, + ], + currentVerificationSummary: { + recoveredCaseCount: 1, + }, + }), + ).toEqual([ + "当前 current recovered outcome 焦点:artifactValidator (repaired)。", + ]); + }); + + it("应统一生成 observability recommendation 的混合文案与待办", () => { + expect( + buildObservabilityRecommendationRationale({ + trendSummary: { + latestCurrentObservabilityGapCaseCount: 1, + latestDegradedObservabilityGapCaseCount: 0, + }, + topObservabilitySignals: ["requestTelemetry (known_gap)"], + topCurrentVerificationFailureOutcomes: [ + { signal: "artifactValidator", outcome: "issues_present" }, + ], + topDegradedVerificationFailureOutcomes: [ + { signal: "guiSmoke", outcome: "failed" }, + ], + currentVerificationSummary: { + advisoryFailureCaseCount: 1, + topAdvisoryFailureOutcomes: ["artifactValidator:issues_present"], + }, + }), + ).toEqual([ + "当前仍有 1 个 current case 带着 observability 证据缺口进入 replay/eval。", + "当前没有额外保留的 degraded observability gap 样本。", + "当前缺口焦点:requestTelemetry (known_gap)。这些缺口会直接降低 analysis handoff、人工审核和 cleanup report 的判断质量。", + "当前 current verification failure outcome 焦点:artifactValidator (issues_present)。可用它们直接定位先补 artifact/browser/gui 哪一层。", + "当前 current advisory verification failure 共 1 个 case:artifactValidator:issues_present。", + "当前保留的 degraded verification baseline:guiSmoke (failed)。", + ]); + + expect( + buildObservabilityRecommendationBacklog({ + topCurrentVerificationFailureOutcomes: [ + { signal: "artifactValidator", outcome: "issues_present" }, + ], + advisoryFollowUpBacklogTools: [ + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + ], + }), + ).toEqual([ + "优先补 request telemetry 关联键、artifact validator outcome、browser/gui smoke 结果到 evidence pack / analysis handoff / replay。", + "先对齐 current verification failure outcome:artifactValidator (issues_present)。", + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + ]); + }); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9cfcf260d..e952b160d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.9.0" +version = "1.10.0" dependencies = [ "anyhow", "arboard", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.9.0" +version = "1.10.0" dependencies = [ "anyhow", "aster-core", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.9.0" +version = "1.10.0" dependencies = [ "chrono", "futures", @@ -5252,7 +5252,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.9.0" +version = "1.10.0" dependencies = [ "clap", "lime-core", @@ -5264,7 +5264,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.9.0" +version = "1.10.0" dependencies = [ "async-trait", "lime-core", @@ -5280,7 +5280,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.9.0" +version = "1.10.0" dependencies = [ "aster-models", "async-trait", @@ -5320,7 +5320,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.9.0" +version = "1.10.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.9.0" +version = "1.10.0" dependencies = [ "aes", "axum 0.7.9", @@ -5385,7 +5385,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.9.0" +version = "1.10.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5405,7 +5405,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.9.0" +version = "1.10.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5421,7 +5421,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.9.0" +version = "1.10.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5452,7 +5452,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.9.0" +version = "1.10.0" dependencies = [ "async-trait", "lime-core", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.9.0" +version = "1.10.0" dependencies = [ "anyhow", "async-stream", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.9.0" +version = "1.10.0" dependencies = [ "aster-core", "async-stream", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.9.0" +version = "1.10.0" dependencies = [ "axum 0.7.9", "futures", @@ -5586,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.9.0" +version = "1.10.0" dependencies = [ "anyhow", "aster-core", @@ -5628,7 +5628,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.9.0" +version = "1.10.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5646,7 +5646,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.9.0" +version = "1.10.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5673,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.9.0" +version = "1.10.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b3c63b8ab..60c76d3ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.9.0" +version = "1.10.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -189,7 +189,7 @@ version = "2.4" [package] name = "lime" -version = "1.9.0" +version = "1.10.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs index 20dc8ae0a..e88f71adb 100644 --- a/src-tauri/crates/agent/src/protocol.rs +++ b/src-tauri/crates/agent/src/protocol.rs @@ -44,6 +44,8 @@ pub struct AgentTokenUsage { pub output_tokens: u32, #[serde(default, skip_serializing_if = "Option::is_none")] pub cached_input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation_input_tokens: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 9508f4d83..e24b7e592 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -1001,6 +1001,10 @@ fn resolve_runtime_usage_from_aster_session( .cached_input_tokens .filter(|value| *value >= 0) .map(|value| value as u32), + cache_creation_input_tokens: session + .cache_creation_input_tokens + .filter(|value| *value >= 0) + .map(|value| value as u32), }) } _ => None, @@ -1066,6 +1070,7 @@ pub async fn get_runtime_session_detail( usage.input_tokens, usage.output_tokens, usage.cached_input_tokens, + usage.cache_creation_input_tokens, ) { tracing::warn!( @@ -1379,6 +1384,7 @@ fn convert_agent_message( input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cached_input_tokens: usage.cached_input_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens, }), }; @@ -1958,6 +1964,7 @@ mod tests { id: "session-usage-fallback".to_string(), input_tokens: Some(3_833), output_tokens: Some(615), + cache_creation_input_tokens: Some(144), ..AsterSession::default() }; @@ -1965,15 +1972,20 @@ mod tests { apply_runtime_usage_fallback_to_latest_assistant_message(&mut messages, &session); assert_eq!( - applied.map(|usage| (usage.input_tokens, usage.output_tokens)), - Some((3_833, 615)) + applied.map(|usage| ( + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + )), + Some((3_833, 615, Some(144))) ); assert_eq!( - messages[1] - .usage - .as_ref() - .map(|usage| (usage.input_tokens, usage.output_tokens)), - Some((3_833, 615)) + messages[1].usage.as_ref().map(|usage| ( + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + )), + Some((3_833, 615, Some(144))) ); } @@ -1990,6 +2002,7 @@ mod tests { input_tokens: 20_480, output_tokens: 10_240, cached_input_tokens: Some(8_192), + cache_creation_input_tokens: Some(1_024), }), }]; let session = AsterSession { @@ -2008,8 +2021,9 @@ mod tests { usage.input_tokens, usage.output_tokens, usage.cached_input_tokens, + usage.cache_creation_input_tokens, )), - Some((20_480, 10_240, Some(8_192))) + Some((20_480, 10_240, Some(8_192), Some(1_024))) ); } diff --git a/src-tauri/crates/agent/src/session_update.rs b/src-tauri/crates/agent/src/session_update.rs index 8beb51a16..41ddce9af 100644 --- a/src-tauri/crates/agent/src/session_update.rs +++ b/src-tauri/crates/agent/src/session_update.rs @@ -8,6 +8,7 @@ pub struct CompactionSessionMetricsUpdate { pub schedule_id: Option, pub current_window_tokens: i32, pub cached_input_tokens: Option, + pub cache_creation_input_tokens: Option, pub accumulated_total_tokens: Option, pub accumulated_input_tokens: Option, pub accumulated_output_tokens: Option, @@ -59,6 +60,7 @@ pub async fn persist_compaction_session_metrics_update( .input_tokens(Some(update.current_window_tokens)) .output_tokens(Some(0)) .cached_input_tokens(update.cached_input_tokens) + .cache_creation_input_tokens(update.cache_creation_input_tokens) .accumulated_total_tokens(update.accumulated_total_tokens) .accumulated_input_tokens(update.accumulated_input_tokens) .accumulated_output_tokens(update.accumulated_output_tokens) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs index cfcdde095..39509f751 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/agent.rs @@ -4630,6 +4630,7 @@ mod tests { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4686,6 +4687,7 @@ mod tests { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4739,6 +4741,7 @@ mod tests { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4801,6 +4804,7 @@ mod tests { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -4870,6 +4874,7 @@ mod tests { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs index 67565bd08..4f8741113 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/execute_commands.rs @@ -132,6 +132,7 @@ impl Agent { input_tokens: Some(0), output_tokens: Some(0), cached_input_tokens: Some(0), + cache_creation_input_tokens: Some(0), accumulated_total: None, accumulated_input: None, accumulated_output: None, @@ -143,6 +144,7 @@ impl Agent { .total_tokens(Some(0)) .input_tokens(Some(0)) .output_tokens(Some(0)) + .cache_creation_input_tokens(Some(0)) .apply() .await?; } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs index 29ae94bcf..47a471187 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/agents/reply_parts.rs @@ -443,6 +443,11 @@ impl Agent { } else { usage.usage.cached_input_tokens }; + let current_cache_creation_input = if is_compaction_usage { + Some(0) + } else { + usage.usage.cache_creation_input_tokens + }; if let Some(store) = session_store { store @@ -454,6 +459,7 @@ impl Agent { input_tokens: current_input, output_tokens: current_output, cached_input_tokens: current_cached_input, + cache_creation_input_tokens: current_cache_creation_input, accumulated_total, accumulated_input, accumulated_output, @@ -467,6 +473,7 @@ impl Agent { .input_tokens(current_input) .output_tokens(current_output) .cached_input_tokens(current_cached_input) + .cache_creation_input_tokens(current_cache_creation_input) .accumulated_total_tokens(accumulated_total) .accumulated_input_tokens(accumulated_input) .accumulated_output_tokens(accumulated_output) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs index 1c3da8191..dd306c296 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/base.rs @@ -279,6 +279,7 @@ pub struct Usage { pub output_tokens: Option, pub total_tokens: Option, pub cached_input_tokens: Option, + pub cache_creation_input_tokens: Option, } fn sum_optionals(a: Option, b: Option) -> Option @@ -306,6 +307,10 @@ impl Add for Usage { self.cached_input_tokens, other.cached_input_tokens, )) + .with_cache_creation_input_tokens(sum_optionals( + self.cache_creation_input_tokens, + other.cache_creation_input_tokens, + )) } } @@ -337,6 +342,7 @@ impl Usage { output_tokens, total_tokens: calculated_total, cached_input_tokens: None, + cache_creation_input_tokens: None, } } @@ -344,6 +350,14 @@ impl Usage { self.cached_input_tokens = cached_input_tokens; self } + + pub fn with_cache_creation_input_tokens( + mut self, + cache_creation_input_tokens: Option, + ) -> Self { + self.cache_creation_input_tokens = cache_creation_input_tokens; + self + } } use async_trait::async_trait; diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs index 72f378d44..468274bf2 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/formats/anthropic.rs @@ -331,7 +331,8 @@ pub fn get_usage(data: &Value) -> Result { Some(output_tokens_i32), Some(total_tokens_i32), ) - .with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32))) + .with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32)) + .with_cache_creation_input_tokens(Some(cache_creation_tokens.min(i32::MAX as u64) as i32))) } else if data.as_object().is_some() { // Check if the data itself is the usage object (for message_delta events that might have usage at top level) let input_tokens = data @@ -375,7 +376,10 @@ pub fn get_usage(data: &Value) -> Result { Some(output_tokens_i32), Some(total_tokens_i32), ) - .with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32))) + .with_cached_input_tokens(Some(cache_read_tokens.min(i32::MAX as u64) as i32)) + .with_cache_creation_input_tokens(Some( + cache_creation_tokens.min(i32::MAX as u64) as i32, + ))) } else { tracing::debug!("🔍 Anthropic no token data found in object"); Ok(Usage::new(None, None, None)) @@ -661,16 +665,21 @@ where .usage .cached_input_tokens .or(delta_usage.cached_input_tokens); + let merged_cache_creation = existing_usage + .usage + .cache_creation_input_tokens + .or(delta_usage.cache_creation_input_tokens); let merged_usage = crate::providers::base::Usage::new( merged_input, merged_output, merged_total, ) - .with_cached_input_tokens(merged_cached); + .with_cached_input_tokens(merged_cached) + .with_cache_creation_input_tokens(merged_cache_creation); final_usage = Some(crate::providers::base::ProviderUsage::new(existing_usage.model.clone(), merged_usage)); - tracing::debug!("🔍 Anthropic MERGED usage: input_tokens={:?}, output_tokens={:?}, total_tokens={:?}, cached_input_tokens={:?}", - merged_input, merged_output, merged_total, merged_cached); + tracing::debug!("🔍 Anthropic MERGED usage: input_tokens={:?}, output_tokens={:?}, total_tokens={:?}, cached_input_tokens={:?}, cache_creation_input_tokens={:?}", + merged_input, merged_output, merged_total, merged_cached, merged_cache_creation); } else { // No existing usage, just use delta usage let model = event.data.get("model") @@ -761,6 +770,7 @@ mod tests { assert_eq!(usage.output_tokens, Some(15)); assert_eq!(usage.total_tokens, Some(39)); // 24 + 15 assert_eq!(usage.cached_input_tokens, Some(0)); + assert_eq!(usage.cache_creation_input_tokens, Some(12)); Ok(()) } @@ -805,6 +815,7 @@ mod tests { assert_eq!(usage.output_tokens, Some(20)); assert_eq!(usage.total_tokens, Some(50)); // 30 + 20 assert_eq!(usage.cached_input_tokens, Some(0)); + assert_eq!(usage.cache_creation_input_tokens, Some(15)); Ok(()) } @@ -880,6 +891,7 @@ mod tests { assert_eq!(usage.output_tokens, Some(45)); assert_eq!(usage.total_tokens, Some(55)); assert_eq!(usage.cached_input_tokens, Some(0)); + assert_eq!(usage.cache_creation_input_tokens, Some(0)); Ok(()) } @@ -1022,6 +1034,7 @@ mod tests { assert_eq!(usage.output_tokens, Some(50)); assert_eq!(usage.total_tokens, Some(15057)); // 15007 + 50 assert_eq!(usage.cached_input_tokens, Some(5000)); + assert_eq!(usage.cache_creation_input_tokens, Some(10000)); Ok(()) } diff --git a/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs b/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs index 3c1718977..8661ac5e3 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/session/session_manager.rs @@ -27,7 +27,7 @@ use tokio::sync::OnceCell; use tracing::{info, warn}; use utoipa::ToSchema; -pub const CURRENT_SCHEMA_VERSION: i32 = 8; +pub const CURRENT_SCHEMA_VERSION: i32 = 9; pub const SESSIONS_FOLDER: &str = "sessions"; pub const DB_NAME: &str = "sessions.db"; const AUTO_SESSION_NAME_PLACEHOLDERS: &[&str] = &[ @@ -97,6 +97,7 @@ pub struct Session { pub input_tokens: Option, pub output_tokens: Option, pub cached_input_tokens: Option, + pub cache_creation_input_tokens: Option, pub accumulated_total_tokens: Option, pub accumulated_input_tokens: Option, pub accumulated_output_tokens: Option, @@ -120,6 +121,7 @@ pub struct SessionUpdateBuilder { input_tokens: Option>, output_tokens: Option>, cached_input_tokens: Option>, + cache_creation_input_tokens: Option>, accumulated_total_tokens: Option>, accumulated_input_tokens: Option>, accumulated_output_tokens: Option>, @@ -150,6 +152,7 @@ impl SessionUpdateBuilder { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -214,6 +217,11 @@ impl SessionUpdateBuilder { self } + pub fn cache_creation_input_tokens(mut self, tokens: Option) -> Self { + self.cache_creation_input_tokens = Some(tokens); + self + } + pub fn accumulated_total_tokens(mut self, tokens: Option) -> Self { self.accumulated_total_tokens = Some(tokens); self @@ -487,6 +495,7 @@ impl SessionManager { input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -513,6 +522,7 @@ impl SessionManager { || input_tokens.is_some() || output_tokens.is_some() || cached_input_tokens.is_some() + || cache_creation_input_tokens.is_some() || accumulated_total_tokens.is_some() || accumulated_input_tokens.is_some() || accumulated_output_tokens.is_some() @@ -527,6 +537,7 @@ impl SessionManager { input_tokens: input_tokens.flatten(), output_tokens: output_tokens.flatten(), cached_input_tokens: cached_input_tokens.flatten(), + cache_creation_input_tokens: cache_creation_input_tokens.flatten(), accumulated_total: accumulated_total_tokens.flatten(), accumulated_input: accumulated_input_tokens.flatten(), accumulated_output: accumulated_output_tokens.flatten(), @@ -595,6 +606,7 @@ impl Default for Session { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -691,6 +703,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session { input_tokens: row.try_get("input_tokens")?, output_tokens: row.try_get("output_tokens")?, cached_input_tokens: row.try_get("cached_input_tokens").ok().flatten(), + cache_creation_input_tokens: row.try_get("cache_creation_input_tokens").ok().flatten(), accumulated_total_tokens: row.try_get("accumulated_total_tokens")?, accumulated_input_tokens: row.try_get("accumulated_input_tokens")?, accumulated_output_tokens: row.try_get("accumulated_output_tokens")?, @@ -784,6 +797,7 @@ impl SessionStorage { input_tokens INTEGER, output_tokens INTEGER, cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER, accumulated_total_tokens INTEGER, accumulated_input_tokens INTEGER, accumulated_output_tokens INTEGER, @@ -1020,11 +1034,11 @@ impl SessionStorage { r#" INSERT INTO sessions ( id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data, - total_tokens, input_tokens, output_tokens, cached_input_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(&session.id) @@ -1039,6 +1053,7 @@ impl SessionStorage { .bind(session.input_tokens) .bind(session.output_tokens) .bind(session.cached_input_tokens) + .bind(session.cache_creation_input_tokens) .bind(session.accumulated_total_tokens) .bind(session.accumulated_input_tokens) .bind(session.accumulated_output_tokens) @@ -1343,6 +1358,15 @@ impl SessionStorage { .execute(&self.pool) .await?; } + 9 => { + sqlx::query( + r#" + ALTER TABLE sessions ADD COLUMN cache_creation_input_tokens INTEGER + "#, + ) + .execute(&self.pool) + .await?; + } _ => { anyhow::bail!("Unknown migration version: {}", version); } @@ -1395,7 +1419,7 @@ impl SessionStorage { let mut session = sqlx::query_as::<_, Session>( r#" SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, - total_tokens, input_tokens, output_tokens, cached_input_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json @@ -1451,6 +1475,10 @@ impl SessionStorage { add_update!(builder.input_tokens, "input_tokens"); add_update!(builder.output_tokens, "output_tokens"); add_update!(builder.cached_input_tokens, "cached_input_tokens"); + add_update!( + builder.cache_creation_input_tokens, + "cache_creation_input_tokens" + ); add_update!(builder.accumulated_total_tokens, "accumulated_total_tokens"); add_update!(builder.accumulated_input_tokens, "accumulated_input_tokens"); add_update!( @@ -1499,6 +1527,9 @@ impl SessionStorage { if let Some(cit) = builder.cached_input_tokens { q = q.bind(cit); } + if let Some(cache_creation_input_tokens) = builder.cache_creation_input_tokens { + q = q.bind(cache_creation_input_tokens); + } if let Some(att) = builder.accumulated_total_tokens { q = q.bind(att); } @@ -1642,7 +1673,7 @@ impl SessionStorage { let query = format!( r#" SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, - s.total_tokens, s.input_tokens, s.output_tokens, s.cached_input_tokens, + s.total_tokens, s.input_tokens, s.output_tokens, s.cached_input_tokens, s.cache_creation_input_tokens, s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, s.schedule_id, s.recipe_json, s.user_recipe_values_json, s.provider_name, s.model_config_json, @@ -1743,6 +1774,7 @@ impl SessionStorage { .input_tokens(import.input_tokens) .output_tokens(import.output_tokens) .cached_input_tokens(import.cached_input_tokens) + .cache_creation_input_tokens(import.cache_creation_input_tokens) .accumulated_total_tokens(import.accumulated_total_tokens) .accumulated_input_tokens(import.accumulated_input_tokens) .accumulated_output_tokens(import.accumulated_output_tokens) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs b/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs index 09ab0f0e4..9d090e7eb 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/session/store.rs @@ -160,6 +160,7 @@ pub struct TokenStatsUpdate { pub input_tokens: Option, pub output_tokens: Option, pub cached_input_tokens: Option, + pub cache_creation_input_tokens: Option, pub accumulated_total: Option, pub accumulated_input: Option, pub accumulated_output: Option, @@ -191,6 +192,7 @@ impl SessionStore for NoopSessionStore { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, diff --git a/src-tauri/crates/core/src/agent/types.rs b/src-tauri/crates/core/src/agent/types.rs index bfee5ee64..6bc14db5b 100644 --- a/src-tauri/crates/core/src/agent/types.rs +++ b/src-tauri/crates/core/src/agent/types.rs @@ -449,6 +449,9 @@ pub struct TokenUsage { /// 命中的缓存输入 token 数 #[serde(default, skip_serializing_if = "Option::is_none")] pub cached_input_tokens: Option, + /// 写入缓存的输入 token 数 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_creation_input_tokens: Option, } impl TokenUsage { @@ -458,6 +461,7 @@ impl TokenUsage { input_tokens, output_tokens, cached_input_tokens: None, + cache_creation_input_tokens: None, } } @@ -466,6 +470,14 @@ impl TokenUsage { self } + pub fn with_cache_creation_input_tokens( + mut self, + cache_creation_input_tokens: Option, + ) -> Self { + self.cache_creation_input_tokens = cache_creation_input_tokens; + self + } + /// 计算总 token 数 pub fn total(&self) -> u32 { self.input_tokens + self.output_tokens diff --git a/src-tauri/crates/core/src/database/agent_session_repository.rs b/src-tauri/crates/core/src/database/agent_session_repository.rs index 3f075b14d..714c479ec 100644 --- a/src-tauri/crates/core/src/database/agent_session_repository.rs +++ b/src-tauri/crates/core/src/database/agent_session_repository.rs @@ -220,6 +220,7 @@ pub fn update_latest_assistant_message_usage( input_tokens: u32, output_tokens: u32, cached_input_tokens: Option, + cache_creation_input_tokens: Option, ) -> Result { AgentDao::update_latest_assistant_message_usage( conn, @@ -227,6 +228,7 @@ pub fn update_latest_assistant_message_usage( input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, ) .map_err(|error| format!("更新最新 assistant 消息 usage 失败: {error}")) } diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index e83a1f5bf..08f502803 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -967,9 +967,10 @@ impl AgentDao { reasoning_content, input_tokens, output_tokens, - cached_input_tokens + cached_input_tokens, + cache_creation_input_tokens ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ session_id, message.role, @@ -984,6 +985,10 @@ impl AgentDao { .usage .as_ref() .and_then(|usage| usage.cached_input_tokens), + message + .usage + .as_ref() + .and_then(|usage| usage.cache_creation_input_tokens), ], )?; @@ -1003,7 +1008,7 @@ impl AgentDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content, - input_tokens, output_tokens, cached_input_tokens + input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens FROM agent_messages WHERE session_id = ? ORDER BY id ASC", )?; @@ -1017,6 +1022,7 @@ impl AgentDao { let input_tokens: Option = row.get(6)?; let output_tokens: Option = row.get(7)?; let cached_input_tokens: Option = row.get(8)?; + let cache_creation_input_tokens: Option = row.get(9)?; // 解析 JSON - 支持多种格式 // 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}] @@ -1036,7 +1042,8 @@ impl AgentDao { usage: match (input_tokens, output_tokens) { (Some(input_tokens), Some(output_tokens)) => Some( crate::agent::types::TokenUsage::new(input_tokens, output_tokens) - .with_cached_input_tokens(cached_input_tokens), + .with_cached_input_tokens(cached_input_tokens) + .with_cache_creation_input_tokens(cache_creation_input_tokens), ), _ => None, }, @@ -1052,17 +1059,24 @@ impl AgentDao { input_tokens: u32, output_tokens: u32, cached_input_tokens: Option, + cache_creation_input_tokens: Option, ) -> Result { let rows = conn.execute( "UPDATE agent_messages - SET input_tokens = ?1, output_tokens = ?2, cached_input_tokens = ?3 + SET input_tokens = ?1, output_tokens = ?2, cached_input_tokens = ?3, cache_creation_input_tokens = ?4 WHERE id = ( SELECT id FROM agent_messages - WHERE session_id = ?4 AND role = 'assistant' + WHERE session_id = ?5 AND role = 'assistant' ORDER BY id DESC LIMIT 1 )", - params![input_tokens, output_tokens, cached_input_tokens, session_id], + params![ + input_tokens, + output_tokens, + cached_input_tokens, + cache_creation_input_tokens, + session_id + ], )?; Ok(rows > 0) @@ -1217,7 +1231,8 @@ mod tests { reasoning_content TEXT, input_tokens INTEGER, output_tokens INTEGER, - cached_input_tokens INTEGER + cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER ); ", ) @@ -1561,7 +1576,8 @@ mod tests { reasoning_content: Some("先分析参数,再继续请求".to_string()), usage: Some( crate::agent::types::TokenUsage::new(1200, 300) - .with_cached_input_tokens(Some(900)), + .with_cached_input_tokens(Some(900)) + .with_cache_creation_input_tokens(Some(300)), ), }, ) @@ -1576,7 +1592,9 @@ mod tests { assert_eq!( messages[0].usage, Some( - crate::agent::types::TokenUsage::new(1200, 300).with_cached_input_tokens(Some(900)), + crate::agent::types::TokenUsage::new(1200, 300) + .with_cached_input_tokens(Some(900)) + .with_cache_creation_input_tokens(Some(300)), ) ); } @@ -1621,6 +1639,7 @@ mod tests { 2048, 512, Some(1536), + Some(256), ) .unwrap(); assert!(updated); @@ -1632,7 +1651,8 @@ mod tests { messages[2].usage, Some( crate::agent::types::TokenUsage::new(2048, 512) - .with_cached_input_tokens(Some(1536)), + .with_cached_input_tokens(Some(1536)) + .with_cache_creation_input_tokens(Some(256)), ) ); } diff --git a/src-tauri/crates/core/src/database/dao/api_key_provider.rs b/src-tauri/crates/core/src/database/dao/api_key_provider.rs index 4f8d7561a..7d02aa6ed 100644 --- a/src-tauri/crates/core/src/database/dao/api_key_provider.rs +++ b/src-tauri/crates/core/src/database/dao/api_key_provider.rs @@ -5,6 +5,7 @@ //! **Feature: provider-ui-refactor** //! **Validates: Requirements 9.1** +use crate::provider_prompt_cache_support::is_known_automatic_anthropic_compatible_host; use chrono::{DateTime, Utc}; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; @@ -34,6 +35,14 @@ pub enum ApiProviderType { Gateway, } +/// API Key Provider 声明的 Prompt Cache 模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApiProviderPromptCacheMode { + Automatic, + ExplicitOnly, +} + /// Provider 协议族 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderProtocolFamily { @@ -159,6 +168,35 @@ impl ApiProviderType { pub const fn supports_anthropic_prompt_cache(&self) -> bool { matches!(self, ApiProviderType::Anthropic) } + + pub const fn default_prompt_cache_mode(&self) -> Option { + match self { + ApiProviderType::Anthropic => Some(ApiProviderPromptCacheMode::Automatic), + ApiProviderType::AnthropicCompatible => Some(ApiProviderPromptCacheMode::ExplicitOnly), + _ => None, + } + } +} + +impl std::fmt::Display for ApiProviderPromptCacheMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ApiProviderPromptCacheMode::Automatic => write!(f, "automatic"), + ApiProviderPromptCacheMode::ExplicitOnly => write!(f, "explicit_only"), + } + } +} + +impl std::str::FromStr for ApiProviderPromptCacheMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "automatic" => Ok(Self::Automatic), + "explicit_only" | "explicit-only" => Ok(Self::ExplicitOnly), + _ => Err(format!("Unknown prompt cache mode: {s}")), + } + } } impl std::fmt::Display for ApiProviderType { @@ -183,7 +221,11 @@ impl std::fmt::Display for ApiProviderType { #[cfg(test)] mod tests { - use super::{ApiProviderType, ProviderProtocolFamily}; + use super::{ + infer_managed_prompt_cache_mode, ApiKeyProvider, ApiProviderPromptCacheMode, + ApiProviderType, ProviderGroup, ProviderProtocolFamily, + }; + use chrono::Utc; #[test] fn test_runtime_spec_anthropic_compatible() { @@ -214,6 +256,64 @@ mod tests { assert!(!ApiProviderType::Openai.supports_anthropic_prompt_cache()); } + #[test] + fn test_default_prompt_cache_mode() { + assert_eq!( + ApiProviderType::Anthropic.default_prompt_cache_mode(), + Some(ApiProviderPromptCacheMode::Automatic) + ); + assert_eq!( + ApiProviderType::AnthropicCompatible.default_prompt_cache_mode(), + Some(ApiProviderPromptCacheMode::ExplicitOnly) + ); + assert_eq!(ApiProviderType::Openai.default_prompt_cache_mode(), None); + } + + #[test] + fn test_known_official_anthropic_compatible_hosts_default_to_automatic() { + let hosts = [ + "https://open.bigmodel.cn/api/anthropic", + "https://api.moonshot.cn/anthropic", + "https://api.minimaxi.com/anthropic", + "https://token-plan-cn.xiaomimimo.com/anthropic", + ]; + + for host in hosts { + assert_eq!( + infer_managed_prompt_cache_mode(ApiProviderType::AnthropicCompatible, host), + Some(ApiProviderPromptCacheMode::Automatic), + "expected host to resolve automatic prompt cache: {host}" + ); + } + } + + #[test] + fn test_effective_prompt_cache_mode_prefers_known_host_inference() { + let provider = ApiKeyProvider { + id: "custom-provider".to_string(), + name: "Official Anthropic-Compatible".to_string(), + provider_type: ApiProviderType::AnthropicCompatible, + api_host: "https://api.minimaxi.com/anthropic".to_string(), + is_system: false, + group: ProviderGroup::Custom, + enabled: true, + sort_order: 9999, + api_version: None, + project: None, + location: None, + region: None, + custom_models: Vec::new(), + prompt_cache_mode: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + assert_eq!( + provider.effective_prompt_cache_mode(), + Some(ApiProviderPromptCacheMode::Automatic) + ); + } + #[test] fn test_runtime_spec_contract_matrix() { let cases = [ @@ -459,10 +559,50 @@ pub struct ApiKeyProvider { /// 用于不支持 /models 接口的 Provider(如智谱) #[serde(default)] pub custom_models: Vec, + /// Provider 显式声明的 Prompt Cache 模式(仅在需要覆盖类型默认值时设置) + #[serde(default)] + pub prompt_cache_mode: Option, pub created_at: DateTime, pub updated_at: DateTime, } +fn infer_managed_prompt_cache_mode( + provider_type: ApiProviderType, + api_host: &str, +) -> Option { + if provider_type == ApiProviderType::AnthropicCompatible + && is_known_automatic_anthropic_compatible_host(Some(api_host)) + { + return Some(ApiProviderPromptCacheMode::Automatic); + } + + None +} + +impl ApiKeyProvider { + pub fn effective_prompt_cache_mode(&self) -> Option { + if let Some(managed_mode) = + infer_managed_prompt_cache_mode(self.provider_type, &self.api_host) + { + return Some(managed_mode); + } + + match self.provider_type { + ApiProviderType::AnthropicCompatible => self + .prompt_cache_mode + .or_else(|| self.provider_type.default_prompt_cache_mode()), + _ => self.provider_type.default_prompt_cache_mode(), + } + } + + pub fn supports_automatic_prompt_cache(&self) -> bool { + matches!( + self.effective_prompt_cache_mode(), + Some(ApiProviderPromptCacheMode::Automatic) + ) + } +} + /// API Key 条目 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiKeyEntry { @@ -499,7 +639,8 @@ impl ApiKeyProviderDao { pub fn get_all_providers(conn: &Connection) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, custom_models, created_at, updated_at + api_version, project, location, region, custom_models, prompt_cache_mode, + created_at, updated_at FROM api_key_providers ORDER BY sort_order ASC, created_at ASC", )?; @@ -519,7 +660,8 @@ impl ApiKeyProviderDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, custom_models, created_at, updated_at + api_version, project, location, region, custom_models, prompt_cache_mode, + created_at, updated_at FROM api_key_providers WHERE id = ?1", )?; @@ -539,7 +681,8 @@ impl ApiKeyProviderDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, custom_models, created_at, updated_at + api_version, project, location, region, custom_models, prompt_cache_mode, + created_at, updated_at FROM api_key_providers WHERE group_name = ?1 ORDER BY sort_order ASC, created_at ASC", @@ -563,12 +706,13 @@ impl ApiKeyProviderDao { } else { Some(serde_json::to_string(&provider.custom_models).unwrap_or_default()) }; + let prompt_cache_mode = provider.prompt_cache_mode.map(|value| value.to_string()); conn.execute( "INSERT INTO api_key_providers (id, name, type, api_host, is_system, group_name, enabled, sort_order, - api_version, project, location, region, custom_models, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + api_version, project, location, region, custom_models, prompt_cache_mode, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", params![ provider.id, provider.name, @@ -583,6 +727,7 @@ impl ApiKeyProviderDao { provider.location, provider.region, custom_models_json, + prompt_cache_mode, provider.created_at.to_rfc3339(), provider.updated_at.to_rfc3339(), ], @@ -600,12 +745,13 @@ impl ApiKeyProviderDao { } else { Some(serde_json::to_string(&provider.custom_models).unwrap_or_default()) }; + let prompt_cache_mode = provider.prompt_cache_mode.map(|value| value.to_string()); conn.execute( "UPDATE api_key_providers SET name = ?2, type = ?3, api_host = ?4, is_system = ?5, group_name = ?6, enabled = ?7, sort_order = ?8, api_version = ?9, project = ?10, - location = ?11, region = ?12, custom_models = ?13, updated_at = ?14 + location = ?11, region = ?12, custom_models = ?13, prompt_cache_mode = ?14, updated_at = ?15 WHERE id = ?1", params![ provider.id, @@ -621,6 +767,7 @@ impl ApiKeyProviderDao { provider.location, provider.region, custom_models_json, + prompt_cache_mode, provider.updated_at.to_rfc3339(), ], )?; @@ -659,8 +806,9 @@ impl ApiKeyProviderDao { let location: Option = row.get(10)?; let region: Option = row.get(11)?; let custom_models_json: Option = row.get(12)?; - let created_at_str: String = row.get(13)?; - let updated_at_str: String = row.get(14)?; + let prompt_cache_mode_str: Option = row.get(13)?; + let created_at_str: String = row.get(14)?; + let updated_at_str: String = row.get(15)?; let provider_type: ApiProviderType = type_str.parse().unwrap_or(ApiProviderType::Openai); let group: ProviderGroup = group_str.parse().unwrap_or(ProviderGroup::Custom); @@ -676,6 +824,8 @@ impl ApiKeyProviderDao { let custom_models: Vec = custom_models_json .and_then(|json| serde_json::from_str(&json).ok()) .unwrap_or_default(); + let prompt_cache_mode = prompt_cache_mode_str + .and_then(|value| value.parse::().ok()); Ok(ApiKeyProvider { id, @@ -691,6 +841,7 @@ impl ApiKeyProviderDao { location, region, custom_models, + prompt_cache_mode, created_at, updated_at, }) @@ -752,7 +903,7 @@ impl ApiKeyProviderDao { k.usage_count, k.error_count, k.last_used_at, k.created_at, p.id, p.name, p.type, p.api_host, p.is_system, p.group_name, p.enabled, p.sort_order, p.api_version, p.project, p.location, p.region, - p.custom_models, p.created_at, p.updated_at + p.custom_models, p.prompt_cache_mode, p.created_at, p.updated_at FROM api_keys k JOIN api_key_providers p ON k.provider_id = p.id WHERE p.type = ?1 AND k.enabled = 1 AND p.enabled = 1 @@ -786,8 +937,9 @@ impl ApiKeyProviderDao { // 解析 Provider let custom_models_json: Option = row.get(21)?; - let provider_created_at_str: String = row.get(22)?; - let provider_updated_at_str: String = row.get(23)?; + let prompt_cache_mode_str: Option = row.get(22)?; + let provider_created_at_str: String = row.get(23)?; + let provider_updated_at_str: String = row.get(24)?; let provider_created_at = DateTime::parse_from_rfc3339(&provider_created_at_str) .map(|dt| dt.with_timezone(&Utc)) .unwrap_or_else(|_| Utc::now()); @@ -799,6 +951,8 @@ impl ApiKeyProviderDao { let custom_models: Vec = custom_models_json .and_then(|json| serde_json::from_str(&json).ok()) .unwrap_or_default(); + let prompt_cache_mode = prompt_cache_mode_str + .and_then(|value| value.parse::().ok()); let provider = ApiKeyProvider { id: row.get(9)?, @@ -820,6 +974,7 @@ impl ApiKeyProviderDao { location: row.get(19)?, region: row.get(20)?, custom_models, + prompt_cache_mode, created_at: provider_created_at, updated_at: provider_updated_at, }; diff --git a/src-tauri/crates/core/src/database/dao/provider_pool.rs b/src-tauri/crates/core/src/database/dao/provider_pool.rs index f2a8eb27d..f0a820e58 100644 --- a/src-tauri/crates/core/src/database/dao/provider_pool.rs +++ b/src-tauri/crates/core/src/database/dao/provider_pool.rs @@ -368,6 +368,7 @@ impl ProviderPoolDao { cached_token: None, // 从 get_token_cache 单独获取 source, proxy_url, + prompt_cache_mode_override: None, }) } diff --git a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs index 5257e0479..b8aa0d796 100644 --- a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs +++ b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs @@ -387,7 +387,8 @@ mod tests { updated_at TEXT NOT NULL, working_dir TEXT, execution_strategy TEXT NOT NULL DEFAULT 'react', - cached_input_tokens INTEGER + cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER ); CREATE TABLE agent_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -400,7 +401,8 @@ mod tests { reasoning_content TEXT, input_tokens INTEGER, output_tokens INTEGER, - cached_input_tokens INTEGER + cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER ); ", ) @@ -425,7 +427,8 @@ mod tests { updated_at TEXT NOT NULL, working_dir TEXT, execution_strategy TEXT NOT NULL DEFAULT 'react', - cached_input_tokens INTEGER + cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER ); CREATE TABLE agent_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -438,7 +441,8 @@ mod tests { reasoning_content TEXT, input_tokens INTEGER, output_tokens INTEGER, - cached_input_tokens INTEGER + cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER ); ", ) diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index be042b113..76acdfea2 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -76,6 +76,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { location TEXT, region TEXT, custom_models TEXT, + prompt_cache_mode TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", @@ -87,6 +88,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE api_key_providers ADD COLUMN custom_models TEXT", [], ); + let _ = conn.execute( + "ALTER TABLE api_key_providers ADD COLUMN prompt_cache_mode TEXT", + [], + ); // 创建 api_key_providers 索引 conn.execute( @@ -539,6 +544,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { input_tokens INTEGER, output_tokens INTEGER, cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER, accumulated_total_tokens INTEGER, accumulated_input_tokens INTEGER, accumulated_output_tokens INTEGER, @@ -590,6 +596,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE agent_sessions ADD COLUMN cached_input_tokens INTEGER", [], ); + let _ = conn.execute( + "ALTER TABLE agent_sessions ADD COLUMN cache_creation_input_tokens INTEGER", + [], + ); let _ = conn.execute( "ALTER TABLE agent_sessions ADD COLUMN accumulated_total_tokens INTEGER", [], @@ -632,6 +642,7 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { input_tokens INTEGER, output_tokens INTEGER, cached_input_tokens INTEGER, + cache_creation_input_tokens INTEGER, FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE )", [], @@ -653,6 +664,10 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE agent_messages ADD COLUMN cached_input_tokens INTEGER", [], ); + let _ = conn.execute( + "ALTER TABLE agent_messages ADD COLUMN cache_creation_input_tokens INTEGER", + [], + ); // 创建 agent_messages 索引 conn.execute( diff --git a/src-tauri/crates/core/src/database/system_providers.rs b/src-tauri/crates/core/src/database/system_providers.rs index 5913fac76..00781d68e 100644 --- a/src-tauri/crates/core/src/database/system_providers.rs +++ b/src-tauri/crates/core/src/database/system_providers.rs @@ -888,6 +888,7 @@ pub fn to_api_key_provider(def: &SystemProviderDef) -> ApiKeyProvider { location: None, region: None, custom_models: Vec::new(), + prompt_cache_mode: None, created_at: now, updated_at: now, } diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index fa9f7e6c5..ee5cb6bcf 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -52,6 +52,7 @@ pub mod credential; // 请求处理器核心类型(context, error) pub mod processor; +pub mod provider_prompt_cache_support; // WebSocket 核心类型 pub mod websocket; diff --git a/src-tauri/crates/core/src/models/provider_pool_model.rs b/src-tauri/crates/core/src/models/provider_pool_model.rs index 2326a4827..c9de7c50d 100644 --- a/src-tauri/crates/core/src/models/provider_pool_model.rs +++ b/src-tauri/crates/core/src/models/provider_pool_model.rs @@ -2,6 +2,7 @@ //! //! 支持多凭证池管理,包括健康检测、负载均衡、故障转移等功能。 +use crate::provider_prompt_cache_support::is_known_automatic_anthropic_compatible_host; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -29,6 +30,19 @@ pub enum CredentialSource { /// 所有 Provider 类型定义已统一到 lib.rs 中的 ProviderType。 pub type PoolProviderType = super::provider_type::ProviderType; +/// Provider 声明的 Prompt Cache 模式。 +/// +/// 说明: +/// - 这是“上游已声明的缓存能力”,不是模型目录或协议族映射; +/// - 对普通 Provider 可为空,运行时会按 ProviderType 走默认语义; +/// - 对自定义 `anthropic-compatible` Provider,可用来覆盖默认的 `explicit_only`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderPromptCacheMode { + Automatic, + ExplicitOnly, +} + /// 凭证数据,根据 Provider 类型不同而不同 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -232,6 +246,9 @@ pub struct ProviderCredential { pub source: CredentialSource, /// 代理 URL(可覆盖全局代理设置) pub proxy_url: Option, + /// Prompt Cache 模式覆盖(仅在上游显式声明时设置) + #[serde(default)] + pub prompt_cache_mode_override: Option, } fn default_true() -> bool { @@ -265,9 +282,29 @@ impl ProviderCredential { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, } } + /// 解析当前凭证应采用的 Prompt Cache 模式。 + pub fn effective_prompt_cache_mode(&self) -> Option { + self.prompt_cache_mode_override.or_else(|| { + if self.provider_type.supports_anthropic_prompt_cache() { + Some(ProviderPromptCacheMode::Automatic) + } else if matches!(self.provider_type, PoolProviderType::AnthropicCompatible) + && is_known_automatic_anthropic_compatible_host( + get_base_url(&self.credential).as_deref(), + ) + { + Some(ProviderPromptCacheMode::Automatic) + } else if matches!(self.provider_type, PoolProviderType::AnthropicCompatible) { + Some(ProviderPromptCacheMode::ExplicitOnly) + } else { + None + } + }) + } + /// 创建带来源的新凭证 pub fn new_with_source( provider_type: PoolProviderType, @@ -743,6 +780,43 @@ mod tests { assert!(!pattern_matches("gemini-*-pro", "gemini-2.5-flash")); } + #[test] + fn test_effective_prompt_cache_mode_uses_known_official_host() { + let cred = ProviderCredential { + uuid: "test-uuid".to_string(), + provider_type: PoolProviderType::AnthropicCompatible, + credential: CredentialData::ClaudeKey { + api_key: "test-key".to_string(), + base_url: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + }, + name: None, + is_healthy: true, + is_disabled: false, + check_health: true, + check_model_name: None, + not_supported_models: vec![], + supported_models: vec![], + usage_count: 0, + error_count: 0, + last_used: None, + last_error_time: None, + last_error_message: None, + last_health_check_time: None, + last_health_check_model: None, + created_at: Utc::now(), + updated_at: Utc::now(), + cached_token: None, + source: CredentialSource::Manual, + proxy_url: None, + prompt_cache_mode_override: None, + }; + + assert_eq!( + cred.effective_prompt_cache_mode(), + Some(ProviderPromptCacheMode::Automatic) + ); + } + #[test] fn test_supports_model_not_supported_models() { let cred = ProviderCredential { @@ -770,6 +844,7 @@ mod tests { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, }; assert!(!cred.supports_model("claude-opus")); @@ -805,6 +880,7 @@ mod tests { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, }; // Exact match exclusion @@ -842,6 +918,7 @@ mod tests { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, }; // Prefix wildcard exclusion @@ -883,6 +960,7 @@ mod tests { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, }; // Contains wildcard exclusion @@ -921,6 +999,7 @@ mod tests { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, }; // Excluded by not_supported_models (exact match) @@ -960,6 +1039,7 @@ mod tests { cached_token: None, source: CredentialSource::Manual, proxy_url: None, + prompt_cache_mode_override: None, }; // All models should be supported since not_supported_models is empty diff --git a/src-tauri/crates/core/src/provider_prompt_cache_support.rs b/src-tauri/crates/core/src/provider_prompt_cache_support.rs new file mode 100644 index 000000000..0f00559dd --- /dev/null +++ b/src-tauri/crates/core/src/provider_prompt_cache_support.rs @@ -0,0 +1,75 @@ +use serde::Deserialize; +use std::sync::OnceLock; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PromptCacheCatalog { + #[serde(default)] + automatic_anthropic_compatible_hosts: Vec, +} + +#[derive(Debug, Deserialize)] +struct PromptCacheHostRule { + contains: String, +} + +fn normalize_api_host(value: &str) -> String { + value + .trim() + .to_lowercase() + .trim_end_matches('/') + .to_string() +} + +fn load_prompt_cache_catalog() -> &'static PromptCacheCatalog { + static CATALOG: OnceLock = OnceLock::new(); + + CATALOG.get_or_init(|| { + serde_json::from_str(include_str!( + "../../../../src/lib/model/anthropicCompatiblePromptCacheCatalog.json" + )) + .expect("prompt cache catalog should be valid json") + }) +} + +pub fn is_known_automatic_anthropic_compatible_host(api_host: Option<&str>) -> bool { + let normalized_api_host = normalize_api_host(api_host.unwrap_or_default()); + if normalized_api_host.is_empty() { + return false; + } + + load_prompt_cache_catalog() + .automatic_anthropic_compatible_hosts + .iter() + .map(|rule| rule.contains.trim().to_lowercase()) + .any(|needle| normalized_api_host.contains(&needle)) +} + +#[cfg(test)] +mod tests { + use super::is_known_automatic_anthropic_compatible_host; + + #[test] + fn known_official_anthropic_compatible_hosts_should_match() { + let hosts = [ + "https://open.bigmodel.cn/api/anthropic", + "https://api.moonshot.cn/anthropic", + "https://api.minimaxi.com/anthropic", + "https://token-plan-cn.xiaomimimo.com/anthropic", + ]; + + for host in hosts { + assert!( + is_known_automatic_anthropic_compatible_host(Some(host)), + "expected host to be treated as automatic prompt cache: {host}" + ); + } + } + + #[test] + fn unknown_host_should_not_match() { + assert!(!is_known_automatic_anthropic_compatible_host(Some( + "https://example.com/anthropic" + ))); + } +} diff --git a/src-tauri/crates/server/src/handlers/provider_calls.rs b/src-tauri/crates/server/src/handlers/provider_calls.rs index 758581173..6872b6f52 100644 --- a/src-tauri/crates/server/src/handlers/provider_calls.rs +++ b/src-tauri/crates/server/src/handlers/provider_calls.rs @@ -548,7 +548,10 @@ pub async fn call_provider_anthropic( CredentialData::ClaudeKey { api_key, base_url } => { // 打印 Claude 代理 URL 用于调试 let actual_base_url = base_url.as_deref().unwrap_or("https://api.anthropic.com"); - let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + let prompt_cache_mode = if matches!( + credential.effective_prompt_cache_mode(), + Some(lime_core::models::ProviderPromptCacheMode::Automatic) + ) { PromptCacheMode::Automatic } else { PromptCacheMode::ExplicitOnly @@ -1680,7 +1683,10 @@ pub async fn call_provider_openai( &credential.uuid[..8], request.stream ); - let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + let prompt_cache_mode = if matches!( + credential.effective_prompt_cache_mode(), + Some(lime_core::models::ProviderPromptCacheMode::Automatic) + ) { PromptCacheMode::Automatic } else { PromptCacheMode::ExplicitOnly diff --git a/src-tauri/crates/server/src/handlers/websocket.rs b/src-tauri/crates/server/src/handlers/websocket.rs index b79e5dd43..c95acc139 100644 --- a/src-tauri/crates/server/src/handlers/websocket.rs +++ b/src-tauri/crates/server/src/handlers/websocket.rs @@ -671,7 +671,10 @@ pub async fn call_provider_openai_for_ws( actual_base_url, &credential.uuid[..8] ); - let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + let prompt_cache_mode = if matches!( + credential.effective_prompt_cache_mode(), + Some(lime_core::models::ProviderPromptCacheMode::Automatic) + ) { PromptCacheMode::Automatic } else { PromptCacheMode::ExplicitOnly @@ -821,7 +824,10 @@ pub async fn call_provider_anthropic_for_ws( actual_base_url, &credential.uuid[..8] ); - let prompt_cache_mode = if credential.provider_type.supports_anthropic_prompt_cache() { + let prompt_cache_mode = if matches!( + credential.effective_prompt_cache_mode(), + Some(lime_core::models::ProviderPromptCacheMode::Automatic) + ) { PromptCacheMode::Automatic } else { PromptCacheMode::ExplicitOnly diff --git a/src-tauri/crates/services/src/api_key_provider_service.rs b/src-tauri/crates/services/src/api_key_provider_service.rs index c2848f7ff..57f954a92 100644 --- a/src-tauri/crates/services/src/api_key_provider_service.rs +++ b/src-tauri/crates/services/src/api_key_provider_service.rs @@ -9,12 +9,15 @@ use crate::provider_type_mapping::pool_provider_type_to_api_type; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use chrono::Utc; use lime_core::database::dao::api_key_provider::{ - ApiKeyEntry, ApiKeyProvider, ApiKeyProviderDao, ApiProviderType, ProviderGroup, - ProviderWithKeys, + ApiKeyEntry, ApiKeyProvider, ApiKeyProviderDao, ApiProviderPromptCacheMode, ApiProviderType, + ProviderGroup, ProviderWithKeys, }; use lime_core::database::system_providers::{get_system_providers, to_api_key_provider}; use lime_core::database::DbConnection; -use lime_core::models::{CredentialData, CredentialSource, PoolProviderType, ProviderCredential}; +use lime_core::models::{ + CredentialData, CredentialSource, PoolProviderType, ProviderCredential, ProviderPromptCacheMode, +}; +use lime_core::provider_prompt_cache_support::is_known_automatic_anthropic_compatible_host; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -44,7 +47,9 @@ mod tests { use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use chrono::Utc; use lime_core::database::dao::api_key_provider::ApiProviderType; - use lime_core::database::dao::api_key_provider::{ApiKeyEntry, ApiKeyProviderDao}; + use lime_core::database::dao::api_key_provider::{ + ApiKeyEntry, ApiKeyProviderDao, ApiProviderPromptCacheMode, + }; use lime_core::database::{init_database, migration, schema, DbConnection}; use rusqlite::Connection; use rusqlite::OptionalExtension; @@ -225,6 +230,7 @@ data: [DONE]\n"; None, None, None, + None, ) .expect("更新系统 Provider 类型失败"); @@ -238,6 +244,88 @@ data: [DONE]\n"; assert_eq!(persisted.provider.provider_type, ApiProviderType::Openai); } + #[test] + fn test_add_custom_provider_should_force_known_anthropic_compatible_host_to_automatic() { + let db = init_test_database(); + let service = ApiKeyProviderService::new(); + + let provider = service + .add_custom_provider( + &db, + "MiMo Anthropic".to_string(), + ApiProviderType::AnthropicCompatible, + "https://token-plan-cn.xiaomimimo.com/anthropic".to_string(), + None, + None, + None, + None, + Some(ApiProviderPromptCacheMode::ExplicitOnly), + ) + .expect("创建自定义 Provider 失败"); + + assert_eq!( + provider.prompt_cache_mode, + Some(ApiProviderPromptCacheMode::Automatic) + ); + + let conn = db.lock().expect("获取数据库锁失败"); + let persisted = ApiKeyProviderDao::get_provider_by_id(&conn, &provider.id) + .expect("读取 Provider 失败") + .expect("Provider 应存在"); + assert_eq!( + persisted.prompt_cache_mode, + Some(ApiProviderPromptCacheMode::Automatic) + ); + } + + #[test] + fn test_update_provider_should_force_known_anthropic_compatible_host_to_automatic() { + let db = init_test_database(); + let service = ApiKeyProviderService::new(); + + let provider = service + .add_custom_provider( + &db, + "Unknown Anthropic".to_string(), + ApiProviderType::AnthropicCompatible, + "https://example.com/anthropic".to_string(), + None, + None, + None, + None, + Some(ApiProviderPromptCacheMode::ExplicitOnly), + ) + .expect("创建初始 Provider 失败"); + + assert_eq!( + provider.prompt_cache_mode, + Some(ApiProviderPromptCacheMode::ExplicitOnly) + ); + + let updated = service + .update_provider( + &db, + &provider.id, + None, + None, + Some("https://api.minimaxi.com/anthropic".to_string()), + None, + None, + None, + None, + None, + None, + Some(ApiProviderPromptCacheMode::ExplicitOnly), + None, + ) + .expect("更新 Provider 失败"); + + assert_eq!( + updated.prompt_cache_mode, + Some(ApiProviderPromptCacheMode::Automatic) + ); + } + #[test] fn test_parse_openai_responses_content_prefers_output_text() { let body = serde_json::json!({ @@ -409,6 +497,7 @@ data: [DONE]\n"; None, None, None, + None, ) .expect("创建 Provider 失败"); service @@ -462,6 +551,7 @@ data: [DONE]\n"; None, None, None, + None, ) .expect("创建 Provider 失败"); service @@ -717,6 +807,32 @@ impl ApiKeyProviderService { } } + fn normalize_custom_prompt_cache_mode( + provider_type: ApiProviderType, + api_host: &str, + prompt_cache_mode: Option, + ) -> Option { + match provider_type { + ApiProviderType::AnthropicCompatible => { + if is_known_automatic_anthropic_compatible_host(Some(api_host)) { + Some(ApiProviderPromptCacheMode::Automatic) + } else { + Some(prompt_cache_mode.unwrap_or(ApiProviderPromptCacheMode::ExplicitOnly)) + } + } + _ => None, + } + } + + fn to_credential_prompt_cache_mode( + mode: ApiProviderPromptCacheMode, + ) -> ProviderPromptCacheMode { + match mode { + ApiProviderPromptCacheMode::Automatic => ProviderPromptCacheMode::Automatic, + ApiProviderPromptCacheMode::ExplicitOnly => ProviderPromptCacheMode::ExplicitOnly, + } + } + fn decrypt_api_key_entry_with_migration( &self, conn: &rusqlite::Connection, @@ -842,7 +958,7 @@ impl ApiKeyProviderService { &provider.api_host, &test_model, &prompt, - provider.provider_type.supports_anthropic_prompt_cache(), + provider.supports_automatic_prompt_cache(), ) .await } @@ -1374,9 +1490,12 @@ impl ApiKeyProviderService { project: Option, location: Option, region: Option, + prompt_cache_mode: Option, ) -> Result { let now = Utc::now(); let id = format!("custom-{}", uuid::Uuid::new_v4()); + let normalized_prompt_cache_mode = + Self::normalize_custom_prompt_cache_mode(provider_type, &api_host, prompt_cache_mode); let provider = ApiKeyProvider { id: id.clone(), @@ -1392,6 +1511,7 @@ impl ApiKeyProviderService { location, region, custom_models: Vec::new(), + prompt_cache_mode: normalized_prompt_cache_mode, created_at: now, updated_at: now, }; @@ -1416,6 +1536,7 @@ impl ApiKeyProviderService { project: Option, location: Option, region: Option, + prompt_cache_mode: Option, custom_models: Option>, ) -> Result { let conn = lime_core::database::lock_db(db)?; @@ -1454,6 +1575,11 @@ impl ApiKeyProviderService { if let Some(models) = custom_models { provider.custom_models = models; } + provider.prompt_cache_mode = Self::normalize_custom_prompt_cache_mode( + provider.provider_type, + &provider.api_host, + prompt_cache_mode.or(provider.prompt_cache_mode), + ); provider.updated_at = Utc::now(); ApiKeyProviderDao::update_provider(&conn, &provider).map_err(|e| e.to_string())?; @@ -2173,7 +2299,7 @@ impl ApiKeyProviderService { .test_claude_key_compatibility( &api_key, &provider.api_host, - provider.provider_type.supports_anthropic_prompt_cache(), + provider.supports_automatic_prompt_cache(), ) .await { @@ -2292,6 +2418,9 @@ impl ApiKeyProviderService { cached_token: None, source: CredentialSource::Imported, proxy_url: None, + prompt_cache_mode_override: provider + .effective_prompt_cache_mode() + .map(Self::to_credential_prompt_cache_mode), }) } @@ -2350,6 +2479,9 @@ impl ApiKeyProviderService { cached_token: None, source: CredentialSource::Imported, // 标记为导入来源 proxy_url: None, + prompt_cache_mode_override: provider + .effective_prompt_cache_mode() + .map(Self::to_credential_prompt_cache_mode), }) } @@ -2418,7 +2550,7 @@ impl ApiKeyProviderService { &api_key, &provider.api_host, &test_model, - provider.provider_type.supports_anthropic_prompt_cache(), + provider.supports_automatic_prompt_cache(), ) .await { diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index 51836923e..7b53dc120 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -265,6 +265,7 @@ impl SessionStore for LimeSessionStore { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, @@ -307,7 +308,7 @@ impl SessionStore for LimeSessionStore { .prepare( "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir, session_type, user_set_name, extension_data_json, - total_tokens, input_tokens, output_tokens, cached_input_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json @@ -335,11 +336,12 @@ impl SessionStore for LimeSessionStore { row.get::<_, Option>(14)?, row.get::<_, Option>(15)?, row.get::<_, Option>(16)?, - row.get::<_, Option>(17)?, + row.get::<_, Option>(17)?, row.get::<_, Option>(18)?, row.get::<_, Option>(19)?, row.get::<_, Option>(20)?, row.get::<_, Option>(21)?, + row.get::<_, Option>(22)?, )) }) .map_err(|e| anyhow!("会话不存在: {e}"))?; @@ -359,6 +361,7 @@ impl SessionStore for LimeSessionStore { input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -396,6 +399,7 @@ impl SessionStore for LimeSessionStore { input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -553,7 +557,7 @@ impl SessionStore for LimeSessionStore { let mut stmt = conn.prepare( "SELECT id, model, system_prompt, title, created_at, updated_at, working_dir, session_type, user_set_name, extension_data_json, - total_tokens, input_tokens, output_tokens, cached_input_tokens, + total_tokens, input_tokens, output_tokens, cached_input_tokens, cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, schedule_id, recipe_json, user_recipe_values_json, provider_name, model_config_json @@ -575,14 +579,15 @@ impl SessionStore for LimeSessionStore { let input_tokens: Option = row.get(11)?; let output_tokens: Option = row.get(12)?; let cached_input_tokens: Option = row.get(13)?; - let accumulated_total_tokens: Option = row.get(14)?; - let accumulated_input_tokens: Option = row.get(15)?; - let accumulated_output_tokens: Option = row.get(16)?; - let schedule_id: Option = row.get(17)?; - let recipe_json: Option = row.get(18)?; - let user_recipe_values_json: Option = row.get(19)?; - let provider_name: Option = row.get(20)?; - let model_config_json: Option = row.get(21)?; + let cache_creation_input_tokens: Option = row.get(14)?; + let accumulated_total_tokens: Option = row.get(15)?; + let accumulated_input_tokens: Option = row.get(16)?; + let accumulated_output_tokens: Option = row.get(17)?; + let schedule_id: Option = row.get(18)?; + let recipe_json: Option = row.get(19)?; + let user_recipe_values_json: Option = row.get(20)?; + let provider_name: Option = row.get(21)?; + let model_config_json: Option = row.get(22)?; Ok(( id, @@ -598,6 +603,7 @@ impl SessionStore for LimeSessionStore { input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -624,6 +630,7 @@ impl SessionStore for LimeSessionStore { input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -657,6 +664,7 @@ impl SessionStore for LimeSessionStore { input_tokens, output_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, @@ -745,6 +753,7 @@ impl SessionStore for LimeSessionStore { input_tokens: session.input_tokens, output_tokens: session.output_tokens, cached_input_tokens: session.cached_input_tokens, + cache_creation_input_tokens: session.cache_creation_input_tokens, accumulated_total: session.accumulated_total_tokens, accumulated_input: session.accumulated_input_tokens, accumulated_output: session.accumulated_output_tokens, @@ -797,6 +806,7 @@ impl SessionStore for LimeSessionStore { input_tokens: original.input_tokens, output_tokens: original.output_tokens, cached_input_tokens: original.cached_input_tokens, + cache_creation_input_tokens: original.cache_creation_input_tokens, accumulated_total: original.accumulated_total_tokens, accumulated_input: original.accumulated_input_tokens, accumulated_output: original.accumulated_output_tokens, @@ -895,17 +905,19 @@ impl SessionStore for LimeSessionStore { input_tokens = COALESCE(?2, input_tokens), output_tokens = COALESCE(?3, output_tokens), cached_input_tokens = COALESCE(?4, cached_input_tokens), - accumulated_total_tokens = COALESCE(?5, accumulated_total_tokens), - accumulated_input_tokens = COALESCE(?6, accumulated_input_tokens), - accumulated_output_tokens = COALESCE(?7, accumulated_output_tokens), - schedule_id = COALESCE(?8, schedule_id), - updated_at = ?9 - WHERE id = ?10", + cache_creation_input_tokens = COALESCE(?5, cache_creation_input_tokens), + accumulated_total_tokens = COALESCE(?6, accumulated_total_tokens), + accumulated_input_tokens = COALESCE(?7, accumulated_input_tokens), + accumulated_output_tokens = COALESCE(?8, accumulated_output_tokens), + schedule_id = COALESCE(?9, schedule_id), + updated_at = ?10 + WHERE id = ?11", rusqlite::params![ stats.total_tokens, stats.input_tokens, stats.output_tokens, stats.cached_input_tokens, + stats.cache_creation_input_tokens, stats.accumulated_total, stats.accumulated_input, stats.accumulated_output, @@ -928,6 +940,9 @@ impl SessionStore for LimeSessionStore { if let Some(cached_input_tokens) = stats.cached_input_tokens { session.cached_input_tokens = Some(cached_input_tokens); } + if let Some(cache_creation_input_tokens) = stats.cache_creation_input_tokens { + session.cache_creation_input_tokens = Some(cache_creation_input_tokens); + } if let Some(accumulated_total) = stats.accumulated_total { session.accumulated_total_tokens = Some(accumulated_total); } @@ -1366,6 +1381,7 @@ mod tests { input_tokens: Some(60), output_tokens: Some(40), cached_input_tokens: Some(24), + cache_creation_input_tokens: Some(12), accumulated_total: Some(300), accumulated_input: Some(180), accumulated_output: Some(120), @@ -1417,6 +1433,7 @@ mod tests { assert_eq!(loaded.session_type, SessionType::SubAgent); assert_eq!(loaded.total_tokens, Some(100)); assert_eq!(loaded.cached_input_tokens, Some(24)); + assert_eq!(loaded.cache_creation_input_tokens, Some(12)); assert_eq!(loaded.accumulated_total_tokens, Some(300)); assert_eq!(loaded.schedule_id.as_deref(), Some("job-1")); assert_eq!(loaded.provider_name.as_deref(), Some("openai")); @@ -1632,6 +1649,7 @@ mod tests { input_tokens: Some(60), output_tokens: Some(40), cached_input_tokens: Some(24), + cache_creation_input_tokens: Some(12), accumulated_total: Some(300), accumulated_input: Some(180), accumulated_output: Some(120), @@ -1649,6 +1667,7 @@ mod tests { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total: None, accumulated_input: None, accumulated_output: None, @@ -1667,6 +1686,7 @@ mod tests { assert_eq!(loaded.input_tokens, Some(60)); assert_eq!(loaded.output_tokens, Some(40)); assert_eq!(loaded.cached_input_tokens, Some(24)); + assert_eq!(loaded.cache_creation_input_tokens, Some(12)); assert_eq!(loaded.accumulated_total_tokens, Some(300)); assert_eq!(loaded.accumulated_input_tokens, Some(180)); assert_eq!(loaded.accumulated_output_tokens, Some(120)); @@ -1693,6 +1713,7 @@ mod tests { input_tokens: Some(60), output_tokens: Some(40), cached_input_tokens: Some(24), + cache_creation_input_tokens: Some(12), accumulated_total: Some(300), accumulated_input: Some(180), accumulated_output: Some(120), @@ -1710,6 +1731,7 @@ mod tests { input_tokens: Some(0), output_tokens: Some(0), cached_input_tokens: Some(0), + cache_creation_input_tokens: Some(0), accumulated_total: None, accumulated_input: None, accumulated_output: None, @@ -1728,6 +1750,7 @@ mod tests { assert_eq!(loaded.input_tokens, Some(0)); assert_eq!(loaded.output_tokens, Some(0)); assert_eq!(loaded.cached_input_tokens, Some(0)); + assert_eq!(loaded.cache_creation_input_tokens, Some(0)); assert_eq!(loaded.accumulated_total_tokens, Some(300)); assert_eq!(loaded.accumulated_input_tokens, Some(180)); assert_eq!(loaded.accumulated_output_tokens, Some(120)); diff --git a/src-tauri/crates/services/src/live_sync.rs b/src-tauri/crates/services/src/live_sync.rs index 7312f0e88..7af1653e6 100644 --- a/src-tauri/crates/services/src/live_sync.rs +++ b/src-tauri/crates/services/src/live_sync.rs @@ -163,6 +163,35 @@ fn format_shell_env_line(key: &str, value: &str, syntax: ShellConfigSyntax) -> S } } +#[cfg(test)] +fn parse_shell_env_line(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + if let Some(rest) = trimmed.strip_prefix("export ") { + let (key, value) = rest.split_once('=')?; + let unquoted = value + .trim() + .strip_prefix('"')? + .strip_suffix('"')? + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + return Some((key.trim().to_string(), unquoted)); + } + + if let Some(rest) = trimmed.strip_prefix("$env:") { + let (key, value) = rest.split_once('=')?; + let unquoted = value + .trim() + .strip_prefix('"')? + .strip_suffix('"')? + .replace("`\"", "\"") + .replace("``", "`"); + return Some((key.trim().to_string(), unquoted)); + } + + None +} + /// 将环境变量写入 shell 配置文件 /// 使用标记块管理,避免重复添加 /// diff --git a/src-tauri/crates/skills/src/lime_llm_provider.rs b/src-tauri/crates/skills/src/lime_llm_provider.rs index 896644718..4b6bf40c0 100644 --- a/src-tauri/crates/skills/src/lime_llm_provider.rs +++ b/src-tauri/crates/skills/src/lime_llm_provider.rs @@ -132,7 +132,10 @@ impl LimeLlmProvider { self.call_claude_api( api_key, base_url.as_deref(), - if credential.provider_type.supports_anthropic_prompt_cache() { + if matches!( + credential.effective_prompt_cache_mode(), + Some(lime_core::models::ProviderPromptCacheMode::Automatic) + ) { PromptCacheMode::Automatic } else { PromptCacheMode::ExplicitOnly @@ -158,7 +161,10 @@ impl LimeLlmProvider { self.call_claude_api( api_key, base_url.as_deref(), - if credential.provider_type.supports_anthropic_prompt_cache() { + if matches!( + credential.effective_prompt_cache_mode(), + Some(lime_core::models::ProviderPromptCacheMode::Automatic) + ) { PromptCacheMode::Automatic } else { PromptCacheMode::ExplicitOnly diff --git a/src-tauri/src/commands/api_key_provider_cmd.rs b/src-tauri/src/commands/api_key_provider_cmd.rs index aa970a224..8e9260112 100644 --- a/src-tauri/src/commands/api_key_provider_cmd.rs +++ b/src-tauri/src/commands/api_key_provider_cmd.rs @@ -6,7 +6,7 @@ //! **Validates: Requirements 9.1** use crate::database::dao::api_key_provider::{ - ApiKeyEntry, ApiKeyProvider, ApiProviderType, ProviderWithKeys, + ApiKeyEntry, ApiKeyProvider, ApiProviderPromptCacheMode, ApiProviderType, ProviderWithKeys, }; use crate::database::system_providers::get_system_providers; use crate::database::DbConnection; @@ -35,6 +35,7 @@ pub struct AddCustomProviderRequest { pub project: Option, pub location: Option, pub region: Option, + pub prompt_cache_mode: Option, } /// 更新 Provider 请求 @@ -51,6 +52,7 @@ pub struct UpdateProviderRequest { pub project: Option, pub location: Option, pub region: Option, + pub prompt_cache_mode: Option, /// 自定义模型列表 pub custom_models: Option>, } @@ -81,6 +83,8 @@ pub struct ProviderDisplay { pub region: Option, /// 自定义模型列表 pub custom_models: Vec, + /// 当前 Provider 声明的 Prompt Cache 模式(前端优先使用该值,不再只按 type 猜) + pub prompt_cache_mode: Option, pub api_key_count: usize, pub created_at: String, pub updated_at: String, @@ -156,6 +160,9 @@ fn provider_to_display(provider: &ApiKeyProvider, api_key_count: usize) -> Provi location: provider.location.clone(), region: provider.region.clone(), custom_models: provider.custom_models.clone(), + prompt_cache_mode: provider + .effective_prompt_cache_mode() + .map(|mode| mode.to_string()), api_key_count, created_at: provider.created_at.to_rfc3339(), updated_at: provider.updated_at.to_rfc3339(), @@ -306,6 +313,11 @@ pub fn add_custom_api_key_provider( request.project, request.location, request.region, + request + .prompt_cache_mode + .map(|mode| mode.parse::()) + .transpose() + .map_err(|e: String| format!("无效的 Prompt Cache 模式: {e}"))?, )?; Ok(provider_to_display(&provider, 0)) @@ -338,6 +350,11 @@ pub fn update_api_key_provider( request.project, request.location, request.region, + request + .prompt_cache_mode + .map(|mode| mode.parse::()) + .transpose() + .map_err(|e: String| format!("无效的 Prompt Cache 模式: {e}"))?, request.custom_models, )?; diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 4caafa070..65501ae2b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -2049,6 +2049,10 @@ fn resolve_runtime_message_usage_from_session( .cached_input_tokens .filter(|value| *value >= 0) .map(|value| value as u32), + cache_creation_input_tokens: session + .cache_creation_input_tokens + .filter(|value| *value >= 0) + .map(|value| value as u32), }) } _ => None, @@ -2082,6 +2086,7 @@ fn persist_latest_assistant_message_usage( usage.input_tokens, usage.output_tokens, usage.cached_input_tokens, + usage.cache_creation_input_tokens, )?; Ok(()) } @@ -2110,6 +2115,11 @@ fn build_compaction_session_metrics_update( } else { Some(0) }; + let cache_creation_input_tokens = if usage.usage.output_tokens.is_some() { + usage.usage.cache_creation_input_tokens + } else { + Some(0) + }; let current_window_tokens = usage .usage @@ -2121,6 +2131,7 @@ fn build_compaction_session_metrics_update( schedule_id, current_window_tokens, cached_input_tokens, + cache_creation_input_tokens, accumulated_total_tokens: accumulated_total, accumulated_input_tokens: accumulated_input, accumulated_output_tokens: accumulated_output, @@ -3482,6 +3493,7 @@ mod tests { .input_tokens(Some(60)) .output_tokens(Some(30)) .cached_input_tokens(Some(12)) + .cache_creation_input_tokens(Some(4)) .accumulated_total_tokens(Some(300)) .accumulated_input_tokens(Some(200)) .accumulated_output_tokens(Some(100)) @@ -3494,7 +3506,9 @@ mod tests { let usage = ProviderUsage::new( "gpt-4.1".to_string(), - Usage::new(Some(120), Some(45), Some(165)).with_cached_input_tokens(Some(90)), + Usage::new(Some(120), Some(45), Some(165)) + .with_cached_input_tokens(Some(90)) + .with_cache_creation_input_tokens(Some(30)), ); update_compaction_session_metrics(&session_config, &usage) @@ -3510,6 +3524,7 @@ mod tests { assert_eq!(updated.input_tokens, Some(45)); assert_eq!(updated.output_tokens, Some(0)); assert_eq!(updated.cached_input_tokens, Some(90)); + assert_eq!(updated.cache_creation_input_tokens, Some(30)); assert_eq!(updated.accumulated_total_tokens, Some(465)); assert_eq!(updated.accumulated_input_tokens, Some(320)); assert_eq!(updated.accumulated_output_tokens, Some(145)); @@ -3538,6 +3553,7 @@ mod tests { .input_tokens(Some(120)) .output_tokens(Some(60)) .cached_input_tokens(Some(24)) + .cache_creation_input_tokens(Some(8)) .accumulated_total_tokens(Some(700)) .accumulated_input_tokens(Some(500)) .accumulated_output_tokens(Some(200)) @@ -3563,6 +3579,7 @@ mod tests { assert_eq!(updated.input_tokens, Some(0)); assert_eq!(updated.output_tokens, Some(0)); assert_eq!(updated.cached_input_tokens, Some(0)); + assert_eq!(updated.cache_creation_input_tokens, Some(0)); assert_eq!(updated.accumulated_total_tokens, Some(700)); assert_eq!(updated.accumulated_input_tokens, Some(500)); assert_eq!(updated.accumulated_output_tokens, Some(200)); @@ -3591,6 +3608,7 @@ mod tests { .input_tokens(Some(10)) .output_tokens(Some(10)) .cached_input_tokens(Some(6)) + .cache_creation_input_tokens(Some(2)) .accumulated_total_tokens(Some(200)) .accumulated_input_tokens(Some(120)) .accumulated_output_tokens(Some(80)) @@ -3601,7 +3619,9 @@ mod tests { let session_config = SessionConfigBuilder::new(&session.id).build(); let usage = ProviderUsage::new( "gpt-4.1".to_string(), - Usage::new(Some(30), Some(15), Some(45)).with_cached_input_tokens(Some(18)), + Usage::new(Some(30), Some(15), Some(45)) + .with_cached_input_tokens(Some(18)) + .with_cache_creation_input_tokens(Some(6)), ); update_compaction_session_metrics(&session_config, &usage) @@ -3617,6 +3637,7 @@ mod tests { assert_eq!(updated.input_tokens, Some(15)); assert_eq!(updated.output_tokens, Some(0)); assert_eq!(updated.cached_input_tokens, Some(18)); + assert_eq!(updated.cache_creation_input_tokens, Some(6)); assert_eq!(updated.accumulated_total_tokens, Some(245)); assert_eq!(updated.accumulated_input_tokens, Some(150)); assert_eq!(updated.accumulated_output_tokens, Some(95)); @@ -3642,6 +3663,7 @@ mod tests { .input_tokens(Some(204)) .output_tokens(Some(88)) .cached_input_tokens(Some(160)) + .cache_creation_input_tokens(Some(48)) .apply() .await .expect("写入 usage 失败"); @@ -3654,8 +3676,9 @@ mod tests { value.input_tokens, value.output_tokens, value.cached_input_tokens, + value.cache_creation_input_tokens, )), - Some((204, 88, Some(160))) + Some((204, 88, Some(160), Some(48))) ); } other => panic!("收到意外事件: {:?}", other), diff --git a/src-tauri/src/commands/connect_cmd.rs b/src-tauri/src/commands/connect_cmd.rs index 4b314f50a..b90a48ad1 100644 --- a/src-tauri/src/commands/connect_cmd.rs +++ b/src-tauri/src/commands/connect_cmd.rs @@ -253,6 +253,7 @@ pub async fn save_relay_api_key( None, // project None, // location None, // region + None, // prompt_cache_mode ) .map_err(|e| ConnectError { code: "CREATE_PROVIDER_FAILED".to_string(), diff --git a/src-tauri/src/dev_bridge/dispatcher/providers.rs b/src-tauri/src/dev_bridge/dispatcher/providers.rs index 2edf4d5cc..f3f567332 100644 --- a/src-tauri/src/dev_bridge/dispatcher/providers.rs +++ b/src-tauri/src/dev_bridge/dispatcher/providers.rs @@ -60,6 +60,10 @@ fn api_key_provider_with_keys_to_display( location: provider_with_keys.provider.location.clone(), region: provider_with_keys.provider.region.clone(), custom_models: provider_with_keys.provider.custom_models.clone(), + prompt_cache_mode: provider_with_keys + .provider + .effective_prompt_cache_mode() + .map(|mode| mode.to_string()), api_key_count: provider_with_keys.api_keys.len(), created_at: provider_with_keys.provider.created_at.to_rfc3339(), updated_at: provider_with_keys.provider.updated_at.to_rfc3339(), diff --git a/src-tauri/src/services/openclaw_service/tests.rs b/src-tauri/src/services/openclaw_service/tests.rs index 673a25249..644db8adb 100644 --- a/src-tauri/src/services/openclaw_service/tests.rs +++ b/src-tauri/src/services/openclaw_service/tests.rs @@ -42,6 +42,7 @@ fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvi project: None, location: None, region: None, + prompt_cache_mode: None, custom_models: Vec::new(), created_at: Utc::now(), updated_at: Utc::now(), diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index a4f75ac4f..ac5d11a97 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -153,6 +153,8 @@ struct AnalysisObservabilitySection { summary: Value, correlation_keys: Vec, gap_signals: Vec, + verification_failure_outcomes: Vec, + verification_recovered_outcomes: Vec, } #[derive(Debug, Clone, Serialize)] @@ -232,6 +234,14 @@ pub fn export_runtime_analysis_handoff( let observability_correlation_keys = collect_observability_correlation_keys(&observability_summary); let observability_gap_signals = collect_observability_gap_signals(&observability_summary); + let observability_verification_failure_outcomes = collect_observability_verification_outcomes( + &observability_summary, + "/verificationSummary/focusVerificationFailureOutcomes", + ); + let observability_verification_recovered_outcomes = collect_observability_verification_outcomes( + &observability_summary, + "/verificationSummary/focusVerificationRecoveredOutcomes", + ); let title = derive_title(&input_payload, session_id); let failure_modes = value_string_list( @@ -406,6 +416,8 @@ pub fn export_runtime_analysis_handoff( summary: sanitize_value(observability_summary, workspace_root.as_path()), correlation_keys: observability_correlation_keys.clone(), gap_signals: observability_gap_signals.clone(), + verification_failure_outcomes: observability_verification_failure_outcomes.clone(), + verification_recovered_outcomes: observability_verification_recovered_outcomes.clone(), }, reading_order: reading_order.clone(), external_analysis_contract: external_contract.clone(), @@ -416,6 +428,7 @@ pub fn export_runtime_analysis_handoff( &title, &exported_at, &summary, + &analysis_context.observability.summary, &replay_refs, &handoff_refs, &evidence_refs, @@ -426,6 +439,10 @@ pub fn export_runtime_analysis_handoff( &analysis_context.evidence.summary_excerpt, &analysis_context.observability.correlation_keys, &analysis_context.observability.gap_signals, + &analysis_context.observability.verification_failure_outcomes, + &analysis_context + .observability + .verification_recovered_outcomes, ); let artifacts = vec![ @@ -489,6 +506,7 @@ fn build_analysis_brief( title: &str, exported_at: &str, summary: &AnalysisContextSummary, + observability_summary: &Value, replay_refs: &[AnalysisArtifactReference], handoff_refs: &[AnalysisArtifactReference], evidence_refs: &[AnalysisArtifactReference], @@ -499,6 +517,8 @@ fn build_analysis_brief( evidence_excerpt: &str, observability_correlation_keys: &[String], observability_gap_signals: &[String], + verification_failure_outcomes: &[String], + verification_recovered_outcomes: &[String], ) -> String { let mut lines = vec![ "# 外部分析交接简报".to_string(), @@ -557,10 +577,26 @@ fn build_analysis_brief( "- 当前缺口:{}", join_or_fallback(observability_gap_signals, "无") ), + "- 结构化验证摘要:".to_string(), + ]; + lines.extend( + render_observability_verification_summary_lines(observability_summary) + .into_iter() + .map(|line| format!(" {line}")), + ); + lines.extend([ + format!( + "- 验证失败焦点:{}", + join_or_fallback(verification_failure_outcomes, "无") + ), + format!( + "- 已恢复结果:{}", + join_or_fallback(verification_recovered_outcomes, "无") + ), String::new(), "## 推荐读取顺序".to_string(), String::new(), - ]; + ]); for (index, item) in reading_order.iter().enumerate() { lines.push(format!("{}. {}", index + 1, item)); @@ -974,6 +1010,159 @@ fn collect_observability_gap_signals(summary: &Value) -> Vec { .collect() } +fn collect_observability_verification_outcomes(summary: &Value, pointer: &str) -> Vec { + summary + .pointer(pointer) + .map(value_string_list) + .unwrap_or_default() +} + +fn render_observability_verification_summary_lines(summary: &Value) -> Vec { + let verification_summary = summary + .get("verificationSummary") + .or_else(|| summary.get("verification_summary")); + let Some(verification_summary) = verification_summary else { + return vec!["- 当前没有结构化验证摘要。".to_string()]; + }; + + let mut lines = Vec::new(); + + if let Some(artifact_validator) = summary_object_field( + verification_summary, + "artifactValidator", + "artifact_validator", + ) { + lines.push(format!( + "- Artifact 校验:`{}`|{}", + format_verification_outcome_label(summary_string_field( + artifact_validator, + "outcome", + "outcome", + )), + describe_artifact_validator_summary(artifact_validator), + )); + } + + if let Some(browser_verification) = summary_object_field( + verification_summary, + "browserVerification", + "browser_verification", + ) { + lines.push(format!( + "- 浏览器验证:`{}`|{}", + format_verification_outcome_label(summary_string_field( + browser_verification, + "outcome", + "outcome", + )), + describe_browser_verification_summary(browser_verification), + )); + } + + if let Some(gui_smoke) = summary_object_field(verification_summary, "guiSmoke", "gui_smoke") { + lines.push(format!( + "- GUI Smoke:`{}`|{}", + format_verification_outcome_label(summary_string_field( + gui_smoke, "outcome", "outcome", + )), + describe_gui_smoke_summary(gui_smoke), + )); + } + + if lines.is_empty() { + vec!["- 当前没有结构化验证摘要。".to_string()] + } else { + lines + } +} + +fn summary_object_field<'a>( + summary: &'a Value, + camel_case: &str, + snake_case: &str, +) -> Option<&'a Value> { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .filter(|value| value.is_object()) +} + +fn summary_string_field<'a>( + summary: &'a Value, + camel_case: &str, + snake_case: &str, +) -> Option<&'a str> { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn summary_u64_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .and_then(Value::as_u64) +} + +fn summary_bool_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .and_then(Value::as_bool) +} + +fn format_verification_outcome_label(value: Option<&str>) -> &'static str { + match value { + Some("success") => "通过", + Some("blocking_failure") => "阻塞失败", + Some("advisory_failure") => "提示失败", + Some("recovered") => "已恢复", + _ => "未定", + } +} + +fn describe_artifact_validator_summary(summary: &Value) -> String { + if summary_bool_field(summary, "applicable", "applicable") == Some(false) { + return "当前没有适用的 Artifact 校验。".to_string(); + } + + format!( + "记录 {} · issues {} · repaired {} · fallback {}", + summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0), + summary_u64_field(summary, "issueCount", "issue_count").unwrap_or(0), + summary_u64_field(summary, "repairedCount", "repaired_count").unwrap_or(0), + summary_u64_field(summary, "fallbackUsedCount", "fallback_used_count").unwrap_or(0), + ) +} + +fn describe_browser_verification_summary(summary: &Value) -> String { + format!( + "记录 {} · 成功 {} · 失败 {} · 未判定 {}", + summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0), + summary_u64_field(summary, "successCount", "success_count").unwrap_or(0), + summary_u64_field(summary, "failureCount", "failure_count").unwrap_or(0), + summary_u64_field(summary, "unknownCount", "unknown_count").unwrap_or(0), + ) +} + +fn describe_gui_smoke_summary(summary: &Value) -> String { + let status = summary_string_field(summary, "status", "status").unwrap_or("未知"); + let exit_code = summary_u64_field(summary, "exitCode", "exit_code") + .map(|value| value.to_string()) + .unwrap_or_else(|| "未知".to_string()); + let passed = summary_bool_field(summary, "passed", "passed").unwrap_or(false); + + format!( + "状态 {} · exit {} · {}", + status, + exit_code, + if passed { "已通过" } else { "未通过" } + ) +} + fn join_or_fallback(values: &[String], fallback: &str) -> String { if values.is_empty() { fallback.to_string() @@ -1183,6 +1372,94 @@ mod tests { .expect("write request log"); } + fn seed_recovered_verification(detail: &mut SessionDetail, root: &Path) { + let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json"; + let artifact_absolute_path = + root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + + fs::create_dir_all( + artifact_absolute_path + .parent() + .expect("artifact path should have parent"), + ) + .expect("create artifact dir"); + fs::write( + &artifact_absolute_path, + serde_json::to_string_pretty(&json!({ + "schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "title": "Harness Evidence", + "kind": "analysis", + "status": "ready", + "blocks": [ + { + "id": "block-1", + "type": "rich_text", + "content": "test" + } + ], + "metadata": { + "artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"], + "artifactValidationRepaired": true, + "artifactFallbackUsed": false + } + })) + .expect("serialize artifact document"), + ) + .expect("write artifact document"); + + detail.items.push(AgentThreadItem { + id: "artifact-verification-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:30Z".to_string(), + completed_at: Some("2026-03-27T10:00:30Z".to_string()), + updated_at: "2026-03-27T10:00:30Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: artifact_relative_path.to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "browser-tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "browser_snapshot".to_string(), + arguments: None, + output: None, + success: Some(true), + error: None, + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "gui-smoke-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 6, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:50Z".to_string(), + completed_at: Some("2026-03-27T10:00:50Z".to_string()), + updated_at: "2026-03-27T10:00:50Z".to_string(), + payload: AgentThreadItemPayload::CommandExecution { + command: "npm run verify:gui-smoke".to_string(), + cwd: root.to_string_lossy().to_string(), + aggregated_output: Some("GUI smoke finished successfully".to_string()), + exit_code: Some(0), + error: None, + }, + }); + } + #[test] fn should_export_runtime_analysis_handoff_to_workspace() { let temp_dir = TempDir::new().expect("temp dir"); @@ -1218,6 +1495,10 @@ mod tests { assert!(brief.contains("pending request:1")); assert!(brief.contains("证据关联与可观测覆盖")); assert!(brief.contains("requestTelemetry")); + assert!(brief.contains("结构化验证摘要")); + assert!(brief.contains("当前没有结构化验证摘要")); + assert!(brief.contains("验证失败焦点:无")); + assert!(brief.contains("已恢复结果:无")); assert!(!brief.contains("requestTelemetry (unlinked)")); assert!(brief.contains("/workspace/lime")); @@ -1228,8 +1509,41 @@ mod tests { assert!(context.contains("\"observability\"")); assert!(context.contains("\"correlationKeys\"")); assert!(context.contains("\"gapSignals\"")); + assert!(context.contains("\"verificationFailureOutcomes\": []")); + assert!(context.contains("\"verificationRecoveredOutcomes\": []")); assert!(context.contains("\"matchedRequestCount\": 1")); assert!(context.contains("/workspace/lime")); assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref())); } + + #[test] + fn should_include_structured_verification_summary_in_analysis_brief_when_available() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + write_request_telemetry_fixture(temp_dir.path()); + seed_recovered_verification(&mut detail, temp_dir.path()); + + export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()).expect("export"); + + let brief_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/analysis/analysis-brief.md"); + let context_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/analysis/analysis-context.json"); + + let brief = fs::read_to_string(brief_path).expect("brief"); + assert!(brief.contains("结构化验证摘要")); + assert!(brief.contains("Artifact 校验:`已恢复`")); + assert!(brief.contains("记录 1 · issues 1 · repaired 1 · fallback 0")); + assert!(brief.contains("浏览器验证:`通过`")); + assert!(brief.contains("GUI Smoke:`通过`")); + assert!(brief.contains("已恢复结果:Artifact 校验已恢复 1 个产物,fallback 0 次。")); + + let context = fs::read_to_string(context_path).expect("context"); + assert!(context.contains("\"verificationSummary\": {")); + assert!(context.contains("\"verificationRecoveredOutcomes\": [")); + assert!(context.contains("\"outcome\": \"recovered\"")); + } } diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index 99b6b9b5d..44dbb8c78 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -46,7 +46,7 @@ pub struct RuntimeEvidenceArtifact { pub bytes: usize, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RuntimeEvidencePackExportResult { pub session_id: String, @@ -64,9 +64,29 @@ pub struct RuntimeEvidencePackExportResult { pub queued_turn_count: usize, pub recent_artifact_count: usize, pub known_gaps: Vec, + pub observability_summary: Value, pub artifacts: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeVerificationOutcome { + Success, + BlockingFailure, + AdvisoryFailure, + Recovered, +} + +impl RuntimeVerificationOutcome { + const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::BlockingFailure => "blocking_failure", + Self::AdvisoryFailure => "advisory_failure", + Self::Recovered => "recovered", + } + } +} + #[derive(Debug, Clone, PartialEq)] struct RuntimeRecentArtifact { path: String, @@ -240,6 +260,7 @@ pub fn export_runtime_evidence_pack( queued_turn_count: thread_read.queued_turns.len(), recent_artifact_count: recent_artifact_paths.len(), known_gaps, + observability_summary, artifacts, }) } @@ -1138,6 +1159,9 @@ fn build_observability_verification_summary_json( verification: &RuntimeEvidenceVerificationSummary, ) -> Option { let mut payload = Map::new(); + let mut blocking_failure = Vec::new(); + let mut advisory_failure = Vec::new(); + let mut recovered = Vec::new(); if verification.artifact_validator.applicable { let issue_count = verification @@ -1174,15 +1198,41 @@ fn build_observability_verification_summary_json( .unwrap_or(false) }) .count(); + let record_count = verification.artifact_validator.records.len(); + let outcome = if issue_count == 0 { + if repaired_count > 0 || fallback_used_count > 0 { + RuntimeVerificationOutcome::Recovered + } else { + RuntimeVerificationOutcome::Success + } + } else if record_count > 0 && repaired_count == record_count { + RuntimeVerificationOutcome::Recovered + } else { + RuntimeVerificationOutcome::BlockingFailure + }; + + match outcome { + RuntimeVerificationOutcome::BlockingFailure => blocking_failure.push(format!( + "Artifact 校验存在 {} 条未恢复 issues。", + issue_count + )), + RuntimeVerificationOutcome::Recovered => recovered.push(format!( + "Artifact 校验已恢复 {} 个产物,fallback {} 次。", + repaired_count, fallback_used_count + )), + RuntimeVerificationOutcome::Success => {} + RuntimeVerificationOutcome::AdvisoryFailure => {} + } payload.insert( "artifactValidator".to_string(), json!({ "applicable": true, - "recordCount": verification.artifact_validator.records.len(), + "recordCount": record_count, "issueCount": issue_count, "repairedCount": repaired_count, - "fallbackUsedCount": fallback_used_count + "fallbackUsedCount": fallback_used_count, + "outcome": outcome.as_str() }), ); } @@ -1210,6 +1260,24 @@ fn build_observability_verification_summary_json( None => unknown_count += 1, } } + let outcome = if failure_count > 0 { + RuntimeVerificationOutcome::BlockingFailure + } else if unknown_count > 0 { + RuntimeVerificationOutcome::AdvisoryFailure + } else { + RuntimeVerificationOutcome::Success + }; + + match outcome { + RuntimeVerificationOutcome::BlockingFailure => { + blocking_failure.push(format!("浏览器验证存在 {} 条失败线索。", failure_count)) + } + RuntimeVerificationOutcome::AdvisoryFailure => { + advisory_failure.push(format!("浏览器验证仍有 {} 条未判定线索。", unknown_count)) + } + RuntimeVerificationOutcome::Success => {} + RuntimeVerificationOutcome::Recovered => {} + } payload.insert( "browserVerification".to_string(), @@ -1218,7 +1286,8 @@ fn build_observability_verification_summary_json( "successCount": success_count, "failureCount": failure_count, "unknownCount": unknown_count, - "latestUpdatedAt": latest_updated_at + "latestUpdatedAt": latest_updated_at, + "outcome": outcome.as_str() }), ); } @@ -1231,6 +1300,18 @@ fn build_observability_verification_summary_json( .map(|value| !value.trim().is_empty()) .unwrap_or(false); let passed = exit_code == Some(0) && !has_error; + let outcome = if passed { + RuntimeVerificationOutcome::Success + } else { + RuntimeVerificationOutcome::BlockingFailure + }; + + if !passed { + let exit_code_text = exit_code + .map(|value| value.to_string()) + .unwrap_or_else(|| "未知".to_string()); + blocking_failure.push(format!("GUI smoke 未通过,exit_code={}。", exit_code_text)); + } payload.insert( "guiSmoke".to_string(), @@ -1239,11 +1320,35 @@ fn build_observability_verification_summary_json( "exitCode": exit_code, "passed": passed, "updatedAt": gui_smoke.get("updatedAt").cloned().unwrap_or(Value::Null), - "hasOutputPreview": gui_smoke.get("outputPreview").is_some() + "hasOutputPreview": gui_smoke.get("outputPreview").is_some(), + "outcome": outcome.as_str() }), ); } + if !blocking_failure.is_empty() || !advisory_failure.is_empty() || !recovered.is_empty() { + payload.insert( + "observabilityVerificationOutcomes".to_string(), + json!({ + "blockingFailure": blocking_failure, + "advisoryFailure": advisory_failure, + "recovered": recovered + }), + ); + payload.insert( + "focusVerificationFailureOutcomes".to_string(), + json!(blocking_failure + .iter() + .chain(advisory_failure.iter()) + .cloned() + .collect::>()), + ); + payload.insert( + "focusVerificationRecoveredOutcomes".to_string(), + json!(recovered), + ); + } + (!payload.is_empty()).then(|| Value::Object(payload)) } @@ -1731,6 +1836,13 @@ mod tests { assert_eq!(result.queued_turn_count, 1); assert_eq!(result.recent_artifact_count, 1); assert!(result.known_gaps.is_empty()); + assert_eq!( + result + .observability_summary + .get("schemaVersion") + .and_then(Value::as_str), + Some("v1") + ); let summary_path = temp_dir .path() @@ -1869,6 +1981,10 @@ mod tests { .known_gaps .iter() .all(|gap| !gap.contains("ArtifactDocument"))); + assert!(result + .observability_summary + .get("verificationSummary") + .is_some()); let runtime_path = temp_dir .path() @@ -1888,6 +2004,9 @@ mod tests { assert!(runtime.contains("\"repairedCount\": 1")); assert!(runtime.contains("\"successCount\": 1")); assert!(runtime.contains("\"passed\": true")); + assert!(runtime.contains("\"outcome\": \"recovered\"")); + assert!(runtime.contains("\"outcome\": \"success\"")); + assert!(runtime.contains("\"focusVerificationRecoveredOutcomes\"")); let artifacts = fs::read_to_string(artifacts_path).expect("artifacts"); assert!(artifacts.contains("\"verification\"")); diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs index a7a7ddd2d..cfad90ed7 100644 --- a/src-tauri/src/services/runtime_review_decision_service.rs +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -11,6 +11,7 @@ use crate::services::runtime_analysis_handoff_service::{ }; use chrono::Utc; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::fs; use std::path::Path; @@ -59,6 +60,7 @@ pub struct RuntimeReviewDecisionTemplateExportResult { pub pending_request_count: usize, pub queued_turn_count: usize, pub default_decision_status: String, + pub verification_summary: Option, pub decision: RuntimeReviewDecisionContent, pub decision_status_options: Vec, pub risk_level_options: Vec, @@ -112,6 +114,9 @@ struct ReviewDecisionContext { evidence_pack_relative_root: String, replay_case_relative_root: String, analysis_artifacts: Vec, + verification_summary: Option, + verification_failure_outcomes: Vec, + verification_recovered_outcomes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -188,9 +193,15 @@ fn sync_runtime_review_decision( })?; let review_checklist = build_review_checklist(); + let verification_context = load_analysis_verification_context(&analysis)?; let existing_decision = load_existing_review_decision_document(&review_absolute_root)? .map(|document| document.decision); - let mut document = build_review_decision_document(&analysis, &exported_at, &review_checklist); + let mut document = build_review_decision_document( + &analysis, + &exported_at, + &review_checklist, + &verification_context, + ); let decision = decision_override .or(existing_decision) .unwrap_or_else(|| document.decision.clone()); @@ -235,6 +246,7 @@ fn sync_runtime_review_decision( pending_request_count: analysis.pending_request_count, queued_turn_count: analysis.queued_turn_count, default_decision_status: DEFAULT_DECISION_STATUS.to_string(), + verification_summary: document.review_context.verification_summary.clone(), decision: document.decision, decision_status_options: document.decision_status_options, risk_level_options: document.risk_level_options, @@ -248,7 +260,10 @@ fn build_review_decision_document( analysis: &RuntimeAnalysisHandoffExportResult, exported_at: &str, review_checklist: &[String], + verification_context: &ReviewDecisionVerificationContext, ) -> ReviewDecisionDocument { + let suggested_actions = build_review_decision_suggested_actions(verification_context); + ReviewDecisionDocument { schema_version: "v1".to_string(), contract_shape: "lime_review_decision_template".to_string(), @@ -288,6 +303,9 @@ fn build_review_decision_document( relative_path: artifact.relative_path.clone(), }) .collect(), + verification_summary: verification_context.summary.clone(), + verification_failure_outcomes: verification_context.failure_outcomes.clone(), + verification_recovered_outcomes: verification_context.recovered_outcomes.clone(), }, decision: RuntimeReviewDecisionContent { decision_status: DEFAULT_DECISION_STATUS.to_string(), @@ -297,8 +315,8 @@ fn build_review_decision_document( risk_tags: Vec::new(), human_reviewer: String::new(), reviewed_at: None, - followup_actions: Vec::new(), - regression_requirements: Vec::new(), + followup_actions: suggested_actions.followup_actions, + regression_requirements: suggested_actions.regression_requirements, notes: String::new(), }, decision_status_options: build_decision_status_options(), @@ -321,6 +339,16 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { .map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path)) .collect::>() .join("\n"); + let verification_summary = + format_markdown_verification_summary(document.review_context.verification_summary.as_ref()); + let verification_failure_outcomes = format_markdown_list( + &document.review_context.verification_failure_outcomes, + "- 无", + ); + let verification_recovered_outcomes = format_markdown_list( + &document.review_context.verification_recovered_outcomes, + "- 无", + ); let decision_status_options = document .decision_status_options .iter() @@ -362,21 +390,28 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { - 产品承接面:`lime`\n\n\ ## 3. 审核清单\n\ {checklist}\n\n\ -## 4. 决策状态\n\ +## 4. 结构化验证摘要\n\ +{verification_summary}\n\n\ +## 5. 验证焦点\n\ +- 阻塞 / 提示失败:\n\ +{verification_failure_outcomes}\n\n\ +- 已恢复结果:\n\ +{verification_recovered_outcomes}\n\n\ +## 6. 决策状态\n\ - 当前值:`{decision_status}`\n\ - 可选值:{decision_status_options}\n\n\ -## 5. 决策摘要\n\ +## 7. 决策摘要\n\ {decision_summary}\n\n\ -## 6. 采用的修复策略\n\ +## 8. 采用的修复策略\n\ {chosen_fix_strategy}\n\n\ -## 7. 风险等级与标签\n\ +## 9. 风险等级与标签\n\ - 风险等级:`{risk_level}`\n\ - 风险标签:{risk_tags}\n\n\ -## 8. 回归要求\n\ +## 10. 回归要求\n\ {regression_requirements}\n\n\ -## 9. 后续动作\n\ +## 11. 后续动作\n\ {followup_actions}\n\n\ -## 10. 审核备注\n\ +## 12. 审核备注\n\ - 审核人:{human_reviewer}\n\ - 审核时间:{reviewed_at}\n\ - 备注:\n{notes}\n", @@ -408,6 +443,9 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { } else { checklist }, + verification_summary = verification_summary, + verification_failure_outcomes = verification_failure_outcomes, + verification_recovered_outcomes = verification_recovered_outcomes, decision_status_options = if decision_status_options.is_empty() { format!("`{DEFAULT_DECISION_STATUS}`") } else { @@ -434,6 +472,8 @@ fn build_review_checklist() -> Vec { vec![ "先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(), "确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(), + "优先核对 verification failure / recovered outcomes,再决定是接受、延后还是补充证据。" + .to_string(), "确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。" .to_string(), "明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(), @@ -441,6 +481,79 @@ fn build_review_checklist() -> Vec { ] } +#[derive(Debug, Clone, Default)] +struct ReviewDecisionVerificationContext { + summary: Option, + failure_outcomes: Vec, + recovered_outcomes: Vec, +} + +#[derive(Debug, Clone, Default)] +struct ReviewDecisionSuggestedActions { + followup_actions: Vec, + regression_requirements: Vec, +} + +const REVIEW_VERIFICATION_COMMAND_EVAL: &str = "npm run harness:eval"; +const REVIEW_VERIFICATION_COMMAND_TREND: &str = "npm run harness:eval:trend"; +const REVIEW_VERIFICATION_COMMAND_GUI_SMOKE: &str = "npm run verify:gui-smoke"; + +fn load_analysis_verification_context( + analysis: &RuntimeAnalysisHandoffExportResult, +) -> Result { + let analysis_context_relative_path = analysis + .artifacts + .iter() + .find(|artifact| { + matches!( + artifact.kind, + crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext + ) + }) + .map(|artifact| artifact.relative_path.clone()); + let Some(relative_path) = analysis_context_relative_path else { + return Ok(ReviewDecisionVerificationContext::default()); + }; + + let absolute_path = Path::new(&analysis.workspace_root) + .join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + if !absolute_path.exists() { + return Ok(ReviewDecisionVerificationContext::default()); + } + + let contents = fs::read_to_string(&absolute_path).map_err(|error| { + format!( + "读取 analysis context 失败 {}: {error}", + absolute_path.display() + ) + })?; + let payload = serde_json::from_str::(&contents).map_err(|error| { + format!( + "解析 analysis context 失败 {}: {error}", + absolute_path.display() + ) + })?; + + Ok(ReviewDecisionVerificationContext { + summary: payload + .pointer("/observability/summary/verificationSummary") + .cloned() + .or_else(|| { + payload + .pointer("/observability/summary/verification_summary") + .cloned() + }), + failure_outcomes: payload + .pointer("/observability/verificationFailureOutcomes") + .map(value_string_list) + .unwrap_or_default(), + recovered_outcomes: payload + .pointer("/observability/verificationRecoveredOutcomes") + .map(value_string_list) + .unwrap_or_default(), + }) +} + fn review_analysis_artifact_kind_key( kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind, ) -> &'static str { @@ -600,6 +713,309 @@ fn normalize_string_list(values: &[String]) -> Vec { .collect() } +fn build_review_decision_suggested_actions( + verification_context: &ReviewDecisionVerificationContext, +) -> ReviewDecisionSuggestedActions { + let mut suggested_actions = ReviewDecisionSuggestedActions::default(); + + if let Some(summary) = verification_context.summary.as_ref() { + if let Some(artifact_validator) = + summary_object_field(summary, "artifactValidator", "artifact_validator") + { + let artifact_outcome = summary_string_field(artifact_validator, "outcome", "outcome"); + let artifact_issue_count = + summary_u64_field(artifact_validator, "issueCount", "issue_count").unwrap_or(0); + let artifact_fallback_count = summary_u64_field( + artifact_validator, + "fallbackUsedCount", + "fallback_used_count", + ) + .unwrap_or(0); + + if matches!( + artifact_outcome, + Some("blocking_failure" | "advisory_failure") + ) { + if artifact_issue_count > 0 { + push_unique_string( + &mut suggested_actions.followup_actions, + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。", + ); + } + if artifact_fallback_count > 0 { + push_unique_string( + &mut suggested_actions.followup_actions, + "补齐 artifact 主路径导出与修复链,减少 fallback_used 持续留在 current 样本。", + ); + } + } + + if matches!(artifact_outcome, Some("recovered")) { + push_review_verification_eval_commands( + &mut suggested_actions.regression_requirements, + ); + push_unique_string( + &mut suggested_actions.followup_actions, + "在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。", + ); + } + } + + if let Some(browser_verification) = + summary_object_field(summary, "browserVerification", "browser_verification") + { + let browser_outcome = summary_string_field(browser_verification, "outcome", "outcome"); + + if matches!(browser_outcome, Some("blocking_failure")) { + push_review_verification_eval_commands( + &mut suggested_actions.regression_requirements, + ); + push_unique_string( + &mut suggested_actions.followup_actions, + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。", + ); + } + + if matches!(browser_outcome, Some("advisory_failure")) { + push_unique_string( + &mut suggested_actions.followup_actions, + "回看 browser verification 导出链,确保 evidence pack / replay / analysis handoff 写出明确 success 或 failure,而不是 unknown。", + ); + } + + if matches!(browser_outcome, Some("success" | "recovered")) { + push_review_verification_eval_commands( + &mut suggested_actions.regression_requirements, + ); + push_unique_string( + &mut suggested_actions.followup_actions, + "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。", + ); + } + } + + if let Some(gui_smoke) = summary_object_field(summary, "guiSmoke", "gui_smoke") { + let gui_smoke_outcome = summary_string_field(gui_smoke, "outcome", "outcome"); + + if matches!(gui_smoke_outcome, Some("blocking_failure")) { + push_review_verification_eval_commands( + &mut suggested_actions.regression_requirements, + ); + push_unique_string( + &mut suggested_actions.regression_requirements, + REVIEW_VERIFICATION_COMMAND_GUI_SMOKE, + ); + push_unique_string( + &mut suggested_actions.followup_actions, + "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。", + ); + } + + if matches!(gui_smoke_outcome, Some("success" | "recovered")) { + push_review_verification_eval_commands( + &mut suggested_actions.regression_requirements, + ); + push_unique_string( + &mut suggested_actions.regression_requirements, + REVIEW_VERIFICATION_COMMAND_GUI_SMOKE, + ); + push_unique_string( + &mut suggested_actions.followup_actions, + "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。", + ); + } + } + } + + if suggested_actions.followup_actions.is_empty() + && !verification_context.failure_outcomes.is_empty() + { + push_unique_string( + &mut suggested_actions.followup_actions, + "先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点,再决定是继续修复还是补证据。", + ); + push_unique_string( + &mut suggested_actions.regression_requirements, + "按 replay case 复现问题并确认修复后行为与预期一致。", + ); + } + + if suggested_actions.followup_actions.is_empty() + && !verification_context.recovered_outcomes.is_empty() + { + push_unique_string( + &mut suggested_actions.followup_actions, + "把 recovered outcome 回挂到 replay / smoke / evidence 主链,避免后续审核再次把已恢复结果误判成当前阻塞。", + ); + } + + suggested_actions +} + +fn push_review_verification_eval_commands(target: &mut Vec) { + push_unique_string(target, REVIEW_VERIFICATION_COMMAND_EVAL); + push_unique_string(target, REVIEW_VERIFICATION_COMMAND_TREND); +} + +fn value_string_list(value: &Value) -> Vec { + value + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn push_unique_string(target: &mut Vec, value: &str) { + let normalized = value.trim(); + if normalized.is_empty() || target.iter().any(|item| item == normalized) { + return; + } + target.push(normalized.to_string()); +} + +fn format_markdown_verification_summary(summary: Option<&Value>) -> String { + let Some(summary) = summary else { + return "- 当前没有结构化验证摘要。".to_string(); + }; + + let mut lines = Vec::new(); + + if let Some(artifact_validator) = + summary_object_field(summary, "artifactValidator", "artifact_validator") + { + lines.push(format!( + "- Artifact 校验:`{}`|{}", + format_verification_outcome_label(summary_string_field( + artifact_validator, + "outcome", + "outcome", + )), + describe_artifact_validator_summary(artifact_validator), + )); + } + + if let Some(browser_verification) = + summary_object_field(summary, "browserVerification", "browser_verification") + { + lines.push(format!( + "- 浏览器验证:`{}`|{}", + format_verification_outcome_label(summary_string_field( + browser_verification, + "outcome", + "outcome", + )), + describe_browser_verification_summary(browser_verification), + )); + } + + if let Some(gui_smoke) = summary_object_field(summary, "guiSmoke", "gui_smoke") { + lines.push(format!( + "- GUI Smoke:`{}`|{}", + format_verification_outcome_label(summary_string_field( + gui_smoke, "outcome", "outcome", + )), + describe_gui_smoke_summary(gui_smoke), + )); + } + + if lines.is_empty() { + "- 当前没有结构化验证摘要。".to_string() + } else { + lines.join("\n") + } +} + +fn summary_object_field<'a>( + summary: &'a Value, + camel_case: &str, + snake_case: &str, +) -> Option<&'a Value> { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .filter(|value| value.is_object()) +} + +fn summary_string_field<'a>( + summary: &'a Value, + camel_case: &str, + snake_case: &str, +) -> Option<&'a str> { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn summary_u64_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .and_then(Value::as_u64) +} + +fn summary_bool_field(summary: &Value, camel_case: &str, snake_case: &str) -> Option { + summary + .get(camel_case) + .or_else(|| summary.get(snake_case)) + .and_then(Value::as_bool) +} + +fn format_verification_outcome_label(value: Option<&str>) -> &'static str { + match value { + Some("success") => "通过", + Some("blocking_failure") => "阻塞失败", + Some("advisory_failure") => "提示失败", + Some("recovered") => "已恢复", + _ => "未定", + } +} + +fn describe_artifact_validator_summary(summary: &Value) -> String { + if summary_bool_field(summary, "applicable", "applicable") == Some(false) { + return "当前没有适用的 Artifact 校验。".to_string(); + } + + format!( + "记录 {} · issues {} · repaired {} · fallback {}", + summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0), + summary_u64_field(summary, "issueCount", "issue_count").unwrap_or(0), + summary_u64_field(summary, "repairedCount", "repaired_count").unwrap_or(0), + summary_u64_field(summary, "fallbackUsedCount", "fallback_used_count").unwrap_or(0), + ) +} + +fn describe_browser_verification_summary(summary: &Value) -> String { + format!( + "记录 {} · 成功 {} · 失败 {} · 未判定 {}", + summary_u64_field(summary, "recordCount", "record_count").unwrap_or(0), + summary_u64_field(summary, "successCount", "success_count").unwrap_or(0), + summary_u64_field(summary, "failureCount", "failure_count").unwrap_or(0), + summary_u64_field(summary, "unknownCount", "unknown_count").unwrap_or(0), + ) +} + +fn describe_gui_smoke_summary(summary: &Value) -> String { + let status = summary_string_field(summary, "status", "status").unwrap_or("未知"); + let exit_code = summary_u64_field(summary, "exitCode", "exit_code") + .map(|value| value.to_string()) + .unwrap_or_else(|| "未知".to_string()); + let passed = summary_bool_field(summary, "passed", "passed").unwrap_or(false); + + format!( + "状态 {} · exit {} · {}", + status, + exit_code, + if passed { "已通过" } else { "未通过" } + ) +} + fn format_markdown_text_block(value: &str, placeholder: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { @@ -790,6 +1206,182 @@ mod tests { } } + fn seed_recovered_verification(detail: &mut SessionDetail, root: &std::path::Path) { + let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json"; + let artifact_absolute_path = + root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + + fs::create_dir_all( + artifact_absolute_path + .parent() + .expect("artifact path should have parent"), + ) + .expect("create artifact dir"); + fs::write( + &artifact_absolute_path, + serde_json::to_string_pretty(&json!({ + "schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "title": "Harness Evidence", + "kind": "analysis", + "status": "ready", + "blocks": [ + { + "id": "block-1", + "type": "rich_text", + "content": "test" + } + ], + "metadata": { + "artifactValidationIssues": ["title 缺失或为空,已使用兜底标题。"], + "artifactValidationRepaired": true, + "artifactFallbackUsed": false + } + })) + .expect("serialize artifact document"), + ) + .expect("write artifact document"); + + detail.items.push(AgentThreadItem { + id: "artifact-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 3, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:20Z".to_string(), + completed_at: Some("2026-03-27T10:00:20Z".to_string()), + updated_at: "2026-03-27T10:00:20Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: artifact_relative_path.to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "browser-tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "browser_snapshot".to_string(), + arguments: None, + output: None, + success: Some(true), + error: None, + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "gui-smoke-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:50Z".to_string(), + completed_at: Some("2026-03-27T10:00:50Z".to_string()), + updated_at: "2026-03-27T10:00:50Z".to_string(), + payload: AgentThreadItemPayload::CommandExecution { + command: "npm run verify:gui-smoke".to_string(), + cwd: root.to_string_lossy().to_string(), + aggregated_output: Some("GUI smoke finished successfully".to_string()), + exit_code: Some(0), + error: None, + }, + }); + } + + fn seed_blocking_verification(detail: &mut SessionDetail, root: &std::path::Path) { + let artifact_relative_path = ".lime/artifacts/thread-1/report-blocking.artifact.json"; + let artifact_absolute_path = + root.join(artifact_relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + + fs::create_dir_all( + artifact_absolute_path + .parent() + .expect("artifact path should have parent"), + ) + .expect("create artifact dir"); + fs::write( + &artifact_absolute_path, + serde_json::to_string_pretty(&json!({ + "schemaVersion": crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION, + "title": "Harness Evidence Blocking", + "kind": "analysis", + "status": "ready", + "blocks": [ + { + "id": "block-1", + "type": "rich_text", + "content": "test" + } + ], + "metadata": { + "artifactValidationIssues": ["title 缺失或为空。"], + "artifactValidationRepaired": false, + "artifactFallbackUsed": false + } + })) + .expect("serialize artifact document"), + ) + .expect("write artifact document"); + + detail.items.push(AgentThreadItem { + id: "artifact-blocking-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 3, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:20Z".to_string(), + completed_at: Some("2026-03-27T10:00:20Z".to_string()), + updated_at: "2026-03-27T10:00:20Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: artifact_relative_path.to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "browser-tool-blocking-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "browser_snapshot".to_string(), + arguments: None, + output: None, + success: Some(false), + error: Some("browser step failed".to_string()), + metadata: None, + }, + }); + detail.items.push(AgentThreadItem { + id: "gui-smoke-blocking-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:50Z".to_string(), + completed_at: Some("2026-03-27T10:00:50Z".to_string()), + updated_at: "2026-03-27T10:00:50Z".to_string(), + payload: AgentThreadItemPayload::CommandExecution { + command: "npm run verify:gui-smoke".to_string(), + cwd: root.to_string_lossy().to_string(), + aggregated_output: Some("GUI smoke failed".to_string()), + exit_code: Some(1), + error: Some("smoke failed".to_string()), + }, + }); + } + #[test] fn should_export_runtime_review_decision_template_to_workspace() { let temp_dir = TempDir::new().expect("temp dir"); @@ -808,6 +1400,7 @@ mod tests { assert_eq!(result.artifacts.len(), 2); assert_eq!(result.analysis_artifacts.len(), 2); assert!(!result.review_checklist.is_empty()); + assert!(result.verification_summary.is_none()); let markdown_path = temp_dir .path() @@ -824,12 +1417,120 @@ mod tests { assert!(markdown.contains("analysis-brief.md")); assert!(markdown.contains("aster-rust")); assert!(markdown.contains("pending_review")); + assert!(markdown.contains("结构化验证摘要")); + assert!(markdown.contains("当前没有结构化验证摘要")); + assert!(markdown.contains("阻塞 / 提示失败")); + assert!(markdown.contains("已恢复结果")); + assert!(markdown.contains("- 无")); + assert!(result.decision.followup_actions.is_empty()); + assert!(result.decision.regression_requirements.is_empty()); let json = fs::read_to_string(json_path).expect("json"); assert!(json.contains("\"contractShape\": \"lime_review_decision_template\"")); assert!(json.contains("\"decisionStatus\": \"pending_review\"")); assert!(json.contains("\"executionEnvironmentReference\": \"codex\"")); assert!(json.contains("\"runtimeFactSource\": \"aster-rust\"")); + assert!(json.contains("\"verificationSummary\": null")); + assert!(json.contains("\"verificationFailureOutcomes\": []")); + assert!(json.contains("\"verificationRecoveredOutcomes\": []")); + } + + #[test] + fn should_include_verification_outcomes_in_review_decision_when_available() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + seed_recovered_verification(&mut detail, temp_dir.path()); + + let result = + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + assert!(result.verification_summary.is_some()); + + let markdown_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/review/review-decision.md"); + let json_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/review/review-decision.json"); + + let markdown = fs::read_to_string(markdown_path).expect("markdown"); + assert!(markdown.contains("结构化验证摘要")); + assert!(markdown.contains("Artifact 校验:`已恢复`")); + assert!(markdown.contains("记录 1 · issues 1 · repaired 1 · fallback 0")); + assert!(markdown.contains("Artifact 校验已恢复 1 个产物,fallback 0 次。")); + assert!(markdown.contains("浏览器验证:`通过`")); + assert!(markdown.contains("GUI Smoke:`通过`")); + assert!(markdown.contains("- 无")); + assert_eq!( + result.decision.followup_actions, + vec![ + "在 evidence pack / analysis handoff 里同时保留 artifact issue 与 repaired outcome,避免只剩修复结论而丢失修复上下文。" + .to_string(), + "把 browser verification 成功样本固定进 current replay 基线,后续 failure 或 unknown 直接对比这条正向路径。" + .to_string(), + "主路径变更时优先复跑 `npm run verify:gui-smoke`,确认 GUI 壳 / DevBridge / Workspace 不从 passed 回退。" + .to_string(), + ] + ); + assert_eq!( + result.decision.regression_requirements, + vec![ + "npm run harness:eval".to_string(), + "npm run harness:eval:trend".to_string(), + "npm run verify:gui-smoke".to_string(), + ] + ); + + let json = fs::read_to_string(json_path).expect("json"); + assert!(json.contains("\"verificationSummary\": {")); + assert!(json.contains("\"verificationFailureOutcomes\": []")); + assert!(json.contains( + "\"verificationRecoveredOutcomes\": [\n \"Artifact 校验已恢复 1 个产物,fallback 0 次。\"\n ]" + )); + assert!(json.contains("\"outcome\": \"recovered\"")); + } + + #[test] + fn should_seed_followup_actions_from_blocking_verification_outcomes() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + seed_blocking_verification(&mut detail, temp_dir.path()); + + let result = + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + let markdown_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/review/review-decision.md"); + let markdown = fs::read_to_string(markdown_path).expect("markdown"); + + assert_eq!( + result.decision.followup_actions, + vec![ + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。" + .to_string(), + "回看 browser replay / browser verification 失败样本,并把失败断言回挂到受影响主路径。" + .to_string(), + "优先收敛 GUI 壳 / DevBridge / Workspace 主路径,再复跑 `npm run verify:gui-smoke`。" + .to_string(), + ] + ); + assert_eq!( + result.decision.regression_requirements, + vec![ + "npm run harness:eval".to_string(), + "npm run harness:eval:trend".to_string(), + "npm run verify:gui-smoke".to_string(), + ] + ); + assert!(markdown.contains( + "回看 artifact validator issue 明细,并收敛 evidence pack / artifacts.json / analysis handoff 的 artifact 字段。" + )); + assert!(markdown.contains("npm run verify:gui-smoke")); } #[test] diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 7d237beda..52b0a6d8c 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.9.0", + "version": "1.10.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e00d64472..5490089dc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.9.0", + "version": "1.10.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs b/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs index f120fbcde..bc5ca1a35 100644 --- a/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs +++ b/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs @@ -77,6 +77,7 @@ impl SessionStore for TestSessionStore { input_tokens: None, output_tokens: None, cached_input_tokens: None, + cache_creation_input_tokens: None, accumulated_total_tokens: None, accumulated_input_tokens: None, accumulated_output_tokens: None, diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index 4f4d63eb7..8585e35f7 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -29,10 +29,6 @@ vi.mock("./channels/ImConfigPage", () => ({ ImConfigPage: () =>
, })); -vi.mock("./workspace/video/VideoPage", () => ({ - VideoPage: () =>
, -})); - vi.mock("./settings-v2", () => ({ SettingsPageV2: () =>
, })); @@ -218,15 +214,6 @@ describe("AppPageContent", () => { ).not.toBeNull(); }); - it("video 页面应渲染现役视频工作台入口", async () => { - const container = renderContent("video"); - await flushEffects(); - - expect( - container.querySelector('[data-testid="video-page"]'), - ).not.toBeNull(); - }); - it("settings 页面应渲染设置页入口", async () => { const container = renderContent("settings"); await flushEffects(); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index b0f30865f..9a9bdde46 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -26,15 +26,6 @@ const PageWrapper = styled.div<{ $isActive: boolean }>` display: ${(props) => (props.$isActive ? "block" : "none")}; `; -const FullscreenWrapper = styled.div<{ $isActive: boolean }>` - flex: 1; - min-height: 0; - overflow: hidden; - display: ${(props) => (props.$isActive ? "flex" : "none")}; - flex-direction: column; - position: relative; -`; - const columnPageStyle = { flex: 1, minHeight: 0, @@ -42,11 +33,6 @@ const columnPageStyle = { flexDirection: "column", } as const; -const ToolsPage = lazy(() => - import("./tools/ToolsPage").then((module) => ({ - default: module.ToolsPage, - })), -); const ResourcesPage = lazy(() => import("./resources").then((module) => ({ default: module.ResourcesPage, @@ -62,16 +48,6 @@ const PluginsPage = lazy(() => default: module.PluginsPage, })), ); -const ImageGenPage = lazy(() => - import("./image-gen").then((module) => ({ - default: module.ImageGenPage, - })), -); -const VideoPage = lazy(() => - import("./workspace/video/VideoPage").then((module) => ({ - default: module.VideoPage, - })), -); const AutomationPage = lazy(() => import("./automation").then((module) => ({ default: module.AutomationPage, @@ -97,26 +73,6 @@ const BrowserRuntimeWorkspace = lazy(() => default: module.BrowserRuntimeWorkspace, })), ); -const TerminalWorkspace = lazy(() => - import("./terminal").then((module) => ({ - default: module.TerminalWorkspace, - })), -); -const SysinfoView = lazy(() => - import("./terminal").then((module) => ({ - default: module.SysinfoView, - })), -); -const FileBrowserView = lazy(() => - import("./terminal").then((module) => ({ - default: module.FileBrowserView, - })), -); -const WebView = lazy(() => - import("./terminal").then((module) => ({ - default: module.WebView, - })), -); const AgentChatPage = lazy(() => import("./agent/chat").then((module) => ({ default: module.AgentChatPage, @@ -136,22 +92,6 @@ export function AppPageContent({ onNavigate, onAgentHasMessagesChange, }: AppPageContentProps) { - if (currentPage === "image-gen") { - return ( -
- -
- ); - } - - if (currentPage === "video") { - return ( -
- -
- ); - } - if (currentPage === "automation") { return (
@@ -215,38 +155,6 @@ export function AppPageContent({ ); } - if (currentPage === "terminal") { - return ( -
- -
- ); - } - - if (currentPage === "sysinfo") { - return ( - - - - ); - } - - if (currentPage === "files") { - return ( - - - - ); - } - - if (currentPage === "web") { - return ( - - - - ); - } - if (currentPage === "resources") { return (
@@ -255,14 +163,6 @@ export function AppPageContent({ ); } - if (currentPage === "tools") { - return ( - - - - ); - } - if (currentPage === "browser-runtime") { const browserRuntimeParams = pageParams as BrowserRuntimePageParams; diff --git a/src/components/README.md b/src/components/README.md index 286122bbf..af48f2b37 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -19,8 +19,6 @@ React 组件层,包含 UI 组件和业务组件。 - `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置) - `settings-v2/` - 设置页面组件(当前主实现) - `skills/` - 技能管理组件 -- `terminal/` - 内置终端组件(使用 Tauri Commands) -- `tools/` - 工具页面组件 - `widgets/` - 右侧小部件栏组件(移植自 Waveterm) - `ui/` - 通用 UI 组件(按钮、输入框等) - `websocket/` - WebSocket 管理组件 diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index f9bd44231..dcebf41c3 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -62,6 +62,7 @@ import { getOrCreateDefaultProject, type Project, } from "@/lib/api/project"; +import { executionRunGetGeneralWorkbenchState } from "@/lib/api/executionRun"; import { cancelMediaTaskArtifact, createImageGenerationTaskArtifact, @@ -127,11 +128,8 @@ import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToo import { useLimeSkills } from "./hooks/useLimeSkills"; import { useServiceSkills } from "./service-skills/useServiceSkills"; import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection"; -import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview"; +import type { HandleSendOptions } from "./hooks/handleSendTypes"; import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation"; -import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntryPrompt"; -import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions"; -import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary"; import { mergeThreadItems } from "./utils/threadTimelineView"; import { openCanvasForReason } from "./workspace/canvasOpenPolicy"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; @@ -170,12 +168,19 @@ import { useWorkspaceVideoTaskActionRuntime } from "./workspace/useWorkspaceVide import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore"; import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime"; import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions"; +import { + buildGeneralWorkbenchSendBoundaryState, + buildGeneralWorkbenchResumePromptFromRunState, + buildInitialDispatchKey, + type GeneralWorkbenchEntryPromptState, + type GeneralWorkbenchSendBoundaryState, + type InitialDispatchPreviewSnapshot, +} from "./workspace/workspaceSendHelpers"; import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime"; import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceGeneralWorkbenchScaffoldRuntime"; import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch"; import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime"; import { useWorkspaceSceneGateRuntime } from "./workspace/useWorkspaceSceneGateRuntime"; -import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime"; import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./workspace/useWorkspaceGeneralWorkbenchSidebarRuntime"; import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGeneralWorkbenchRuntime"; import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime"; @@ -204,6 +209,7 @@ import { resolveSiteSavedContentTargetFromRunResult } from "./utils/siteToolResu import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation"; import { createUnifiedMemory } from "@/lib/api/unifiedMemory"; +import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; import { createInitialSessionImageWorkbenchState, type SessionImageWorkbenchState, @@ -212,6 +218,7 @@ import { SOCIAL_ARTICLE_SKILL_KEY, GENERAL_WORKBENCH_HISTORY_PAGE_SIZE, applyBackendGeneralWorkbenchDocumentState, + isCanvasStateEmpty, isCorruptedGeneralWorkbenchDocumentContent, isSyncContentEmpty, readPersistedGeneralWorkbenchDocument, @@ -2483,41 +2490,224 @@ export function AgentChatWorkspace({ // 用于追踪是否已触发过 AI 引导 const hasTriggeredGuide = useRef(false); const consumedInitialPromptRef = useRef(null); - const { + const consumedInitialPromptKey = consumedInitialPromptRef.current; + const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] = + useState(null); + const [generalWorkbenchEntryPrompt, setGeneralWorkbenchEntryPrompt] = + useState(null); + const [generalWorkbenchEntryCheckPending, setGeneralWorkbenchEntryCheckPending] = + useState(false); + const hydratedPromptSignatureRef = useRef(null); + const dismissedPromptSignatureRef = useRef(null); + const initialDispatchKey = useMemo( + () => buildInitialDispatchKey(initialUserPrompt, initialUserImages), + [initialUserImages, initialUserPrompt], + ); + + useEffect(() => { + if (!initialDispatchKey) { + return; + } + + setBootstrapDispatchSnapshot({ + key: initialDispatchKey, + prompt: initialUserPrompt, + images: initialUserImages || [], + }); + }, [initialDispatchKey, initialUserImages, initialUserPrompt]); + + useEffect(() => { + if (messages.length > 0) { + setBootstrapDispatchSnapshot(null); + return; + } + + if (!initialDispatchKey && !isSending && queuedTurns.length === 0) { + setBootstrapDispatchSnapshot(null); + } + }, [initialDispatchKey, isSending, messages.length, queuedTurns.length]); + + const activeBootstrapDispatch = useMemo(() => { + if ( + initialDispatchKey && + ((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0) + ) { + return { + key: initialDispatchKey, + prompt: initialUserPrompt, + images: initialUserImages || [], + }; + } + + return bootstrapDispatchSnapshot; + }, [ + bootstrapDispatchSnapshot, initialDispatchKey, - isBootstrapDispatchPending, - bootstrapDispatchPreviewMessages, - } = useBootstrapDispatchPreview({ - initialUserPrompt, initialUserImages, - messagesCount: messages.length, - isSending, - queuedTurnCount: queuedTurns.length, - consumedInitialPromptKey: consumedInitialPromptRef.current, - shouldUseCompactGeneralWorkbench, - }); - const { - generalWorkbenchEntryPrompt, - generalWorkbenchEntryCheckPending, - clearGeneralWorkbenchEntryPrompt, - dismissGeneralWorkbenchEntryPrompt, - } = useGeneralWorkbenchEntryPrompt({ - activeTheme, - contentId: contentId ?? undefined, - sessionId: sessionId ?? undefined, - isThemeWorkbench, + initialUserPrompt, + ]); + const isBootstrapDispatchPending = + activeBootstrapDispatch !== null && + consumedInitialPromptKey !== activeBootstrapDispatch.key; + const bootstrapDispatchPreview = + !shouldUseCompactGeneralWorkbench && + activeBootstrapDispatch && + messages.length === 0 && + (isSending || queuedTurns.length > 0) + ? activeBootstrapDispatch + : null; + useEffect(() => { + hydratedPromptSignatureRef.current = null; + dismissedPromptSignatureRef.current = null; + setGeneralWorkbenchEntryPrompt(null); + setGeneralWorkbenchEntryCheckPending(false); + }, [activeTheme, contentId, initialDispatchKey]); + + useEffect(() => { + if (shouldUseCompactGeneralWorkbench) { + return; + } + + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + if ( + !isThemeWorkbench || + autoRunInitialPromptOnMount || + !contentId || + !initialDispatchKey || + !pendingInitialPrompt || + pendingInitialImages.length > 0 || + messages.length > 0 + ) { + return; + } + + if ( + consumedInitialPromptKey === initialDispatchKey || + hydratedPromptSignatureRef.current === initialDispatchKey + ) { + return; + } + + hydratedPromptSignatureRef.current = initialDispatchKey; + hasTriggeredGuide.current = true; + setInput((previous) => previous.trim() || pendingInitialPrompt); + setGeneralWorkbenchEntryPrompt({ + kind: "initial_prompt", + signature: initialDispatchKey, + title: "已恢复待执行创作意图", + description: "进入页面后不会自动开始生成,确认后再继续。", + actionLabel: "继续生成", + prompt: pendingInitialPrompt, + }); + }, [ autoRunInitialPromptOnMount, - shouldUseCompactGeneralWorkbench, - messagesCount: messages.length, + consumedInitialPromptKey, + contentId, initialDispatchKey, - initialUserPrompt, initialUserImages, - consumedInitialPromptKey: consumedInitialPromptRef.current, - onHydrateInitialPrompt: useCallback((prompt: string) => { - hasTriggeredGuide.current = true; - setInput((previous) => previous.trim() || prompt); - }, []), - }); + initialUserPrompt, + isThemeWorkbench, + messages.length, + setInput, + shouldUseCompactGeneralWorkbench, + ]); + + useEffect(() => { + if (shouldUseCompactGeneralWorkbench) { + setGeneralWorkbenchEntryCheckPending(false); + return; + } + + if ( + !isThemeWorkbench || + !contentId || + !sessionId || + messages.length > 0 || + Boolean(initialDispatchKey) + ) { + setGeneralWorkbenchEntryCheckPending(false); + return; + } + + let disposed = false; + setGeneralWorkbenchEntryCheckPending(true); + + void (async () => { + try { + const backendState = await executionRunGetGeneralWorkbenchState( + sessionId, + 3, + ).catch(() => null); + + if (disposed) { + return; + } + + const nextPrompt = + buildGeneralWorkbenchResumePromptFromRunState(backendState); + if (!nextPrompt) { + setGeneralWorkbenchEntryPrompt((current) => + current?.kind === "resume" ? null : current, + ); + return; + } + + if (dismissedPromptSignatureRef.current === nextPrompt.signature) { + return; + } + + setGeneralWorkbenchEntryPrompt((current) => + current?.kind === "initial_prompt" ? current : nextPrompt, + ); + } finally { + if (!disposed) { + setGeneralWorkbenchEntryCheckPending(false); + } + } + })(); + + return () => { + disposed = true; + }; + }, [ + contentId, + initialDispatchKey, + isThemeWorkbench, + messages.length, + sessionId, + shouldUseCompactGeneralWorkbench, + ]); + + const clearGeneralWorkbenchEntryPrompt = useCallback(() => { + setGeneralWorkbenchEntryPrompt(null); + }, []); + + const dismissGeneralWorkbenchEntryPrompt = useCallback( + (options?: { + consumeInitialPrompt?: boolean; + onConsumeInitialPrompt?: () => void; + }) => { + setGeneralWorkbenchEntryPrompt((current) => { + if (!current) { + return current; + } + + if ( + current.kind === "initial_prompt" && + options?.consumeInitialPrompt && + initialDispatchKey + ) { + options.onConsumeInitialPrompt?.(); + } else { + dismissedPromptSignatureRef.current = current.signature; + } + + return null; + }); + }, + [initialDispatchKey], + ); const consumeInitialPrompt = useCallback( (dispatchKey: string | null) => { consumedInitialPromptRef.current = dispatchKey; @@ -2532,22 +2722,57 @@ export function AgentChatWorkspace({ hasTriggeredGuide.current = false; consumedInitialPromptRef.current = null; }, []); - const { - resolveSendBoundary, - finalizeAfterSendSuccess, - rollbackAfterSendFailure, - } = useGeneralWorkbenchSendBoundary({ - isThemeWorkbench, - contentId, - initialDispatchKey, - consumedInitialPromptKey: consumedInitialPromptRef.current, - initialUserImages, - mappedTheme, - socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY, - onConsumeInitialPrompt: consumeInitialPrompt, - onResetConsumedInitialPrompt: resetConsumedInitialPrompt, - onClearEntryPrompt: clearGeneralWorkbenchEntryPrompt, - }); + const resolveSendBoundary = useCallback( + ({ + sourceText, + sendOptions, + }: { + sourceText: string; + sendOptions?: HandleSendOptions; + }): GeneralWorkbenchSendBoundaryState => + buildGeneralWorkbenchSendBoundaryState({ + isThemeWorkbench, + contentId, + initialDispatchKey, + consumedInitialPromptKey, + initialUserImages, + mappedTheme, + socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY, + sourceText, + sendOptions, + }), + [ + contentId, + consumedInitialPromptKey, + initialDispatchKey, + initialUserImages, + isThemeWorkbench, + mappedTheme, + ], + ); + const finalizeAfterSendSuccess = useCallback( + (boundary: GeneralWorkbenchSendBoundaryState) => { + if ( + boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt && + initialDispatchKey + ) { + consumeInitialPrompt(initialDispatchKey); + } + + if (boundary.shouldDismissGeneralWorkbenchEntryPrompt) { + clearGeneralWorkbenchEntryPrompt(); + } + }, + [clearGeneralWorkbenchEntryPrompt, consumeInitialPrompt, initialDispatchKey], + ); + const rollbackAfterSendFailure = useCallback( + (boundary: GeneralWorkbenchSendBoundaryState) => { + if (boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt) { + resetConsumedInitialPrompt(); + } + }, + [resetConsumedInitialPrompt], + ); const { resetRestoredSessionState } = useWorkspaceSessionRestore({ sessionId, sessionMeta, @@ -2698,7 +2923,7 @@ export function AgentChatWorkspace({ browserAssistAutoLaunch: browserAssistRequestAutoLaunch, workspaceRequestMetadataBase: initialRequestMetadata, messages, - bootstrapDispatchPreviewMessages, + bootstrapDispatchPreview, sendMessage, resolveSendBoundary, finalizeAfterSendSuccess, @@ -2752,31 +2977,51 @@ export function AgentChatWorkspace({ submitImageWorkbenchAgentCommandRef.current = submitImageWorkbenchAgentCommand; - const { - handleContinueGeneralWorkbenchEntryPrompt, - handleRestartGeneralWorkbenchEntryPrompt, - } = useGeneralWorkbenchEntryPromptActions({ - generalWorkbenchEntryPrompt, - input, - initialDispatchKey, - onContinuePrompt: async (promptToSend) => { - await handleSendRef.current( - [], - webSearchPreferenceRef.current, - effectiveChatToolPreferences.thinking, - promptToSend, - ); - }, - dismissGeneralWorkbenchEntryPrompt, - onConsumeInitialPrompt: (dispatchKey) => { - consumedInitialPromptRef.current = dispatchKey; - onInitialUserPromptConsumed?.(); - }, - onInputChange: setInput, - onRequirePrompt: () => { + const handleContinueGeneralWorkbenchEntryPrompt = useCallback(async () => { + if (!generalWorkbenchEntryPrompt) { + return; + } + + const promptToSend = + input.trim() || generalWorkbenchEntryPrompt.prompt.trim(); + if (!promptToSend) { toast.info("请先补充要继续执行的内容"); - }, - }); + return; + } + + await handleSendRef.current( + [], + webSearchPreferenceRef.current, + effectiveChatToolPreferences.thinking, + promptToSend, + ); + }, [ + effectiveChatToolPreferences.thinking, + generalWorkbenchEntryPrompt, + handleSendRef, + input, + webSearchPreferenceRef, + ]); + const handleRestartGeneralWorkbenchEntryPrompt = useCallback(() => { + if (!generalWorkbenchEntryPrompt) { + return; + } + + dismissGeneralWorkbenchEntryPrompt({ + consumeInitialPrompt: + generalWorkbenchEntryPrompt.kind === "initial_prompt", + onConsumeInitialPrompt: () => { + consumeInitialPrompt(initialDispatchKey); + }, + }); + setInput(""); + }, [ + consumeInitialPrompt, + dismissGeneralWorkbenchEntryPrompt, + generalWorkbenchEntryPrompt, + initialDispatchKey, + setInput, + ]); const { handleDocumentThinkingEnabledChange, handleDocumentAutoContinueRun, @@ -3446,35 +3691,211 @@ export function AgentChatWorkspace({ setFocusedTimelineItemId(normalizedItemId); setTimelineFocusRequestKey((current) => current + 1); }, []); + const triggerAIGuideRef = useRef(triggerAIGuide); + triggerAIGuideRef.current = triggerAIGuide; - useWorkspaceAutoGuideRuntime({ - contentId, - sessionId, - initialUserPrompt, - initialUserImages, - initialAutoSendRequestMetadata, + useEffect(() => { + if (shouldUseCompactGeneralWorkbench) { + return; + } + + const canvasEmpty = isCanvasStateEmpty(canvasState); + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + const defaultGuidePrompt = + contentId && canvasEmpty && !isThemeWorkbench + ? getDefaultGuidePromptByTheme(mappedTheme) + : undefined; + + if ( + !contentId || + messages.length > 0 || + !project || + !systemPrompt || + isSending || + !canvasEmpty + ) { + return; + } + + if (!initialDispatchKey && generalWorkbenchEntryCheckPending) { + return; + } + + if (initialDispatchKey) { + if ( + isThemeWorkbench && + pendingInitialImages.length === 0 && + !autoRunInitialPromptOnMount + ) { + return; + } + if (consumedInitialPromptRef.current === initialDispatchKey) { + return; + } + + let disposed = false; + consumedInitialPromptRef.current = initialDispatchKey; + hasTriggeredGuide.current = true; + if (import.meta.env.MODE !== "test") { + console.log("[AgentChatPage] 自动发送首条创作意图消息"); + } + + void (async () => { + const started = await handleSend( + pendingInitialImages, + effectiveChatToolPreferences.webSearch, + effectiveChatToolPreferences.thinking, + pendingInitialPrompt, + undefined, + undefined, + initialAutoSendRequestMetadata + ? { + requestMetadata: initialAutoSendRequestMetadata, + } + : undefined, + ); + if (disposed) { + return; + } + if (!started) { + consumedInitialPromptRef.current = null; + return; + } + onInitialUserPromptConsumed?.(); + })(); + + return () => { + disposed = true; + }; + } + + if (hasTriggeredGuide.current) { + return; + } + + if (generalWorkbenchEntryPrompt?.kind === "resume") { + return; + } + + if (defaultGuidePrompt) { + hasTriggeredGuide.current = true; + setInput((previous) => previous.trim() || defaultGuidePrompt); + return; + } + + if (isThemeWorkbench) { + if (shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt) { + return; + } + + hasTriggeredGuide.current = true; + if (import.meta.env.MODE !== "test") { + console.log("[AgentChatPage] 工作区上下文:触发 AI 引导"); + } + triggerAIGuideRef.current(); + return; + } + + hasTriggeredGuide.current = true; + if (import.meta.env.MODE !== "test") { + console.log("[AgentChatPage] 自动触发 AI 创作引导"); + } + triggerAIGuideRef.current(); + }, [ autoRunInitialPromptOnMount, - initialDispatchKey, - messagesCount: messages.length, - projectReady: Boolean(project), - systemPromptReady: Boolean(systemPrompt), - isSending, canvasState, - isThemeWorkbench, - mappedTheme, - shouldUseCompactGeneralWorkbench, - shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt: - shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt, + contentId, generalWorkbenchEntryCheckPending, generalWorkbenchEntryPrompt, - chatToolPreferences: effectiveChatToolPreferences, - setInput, handleSend, - triggerAIGuide, + initialAutoSendRequestMetadata, + initialDispatchKey, + initialUserImages, + initialUserPrompt, + isSending, + isThemeWorkbench, + mappedTheme, + messages.length, onInitialUserPromptConsumed, - hasTriggeredGuideRef: hasTriggeredGuide, - consumedInitialPromptRef, - }); + project, + setInput, + shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt, + shouldUseCompactGeneralWorkbench, + systemPrompt, + effectiveChatToolPreferences.thinking, + effectiveChatToolPreferences.webSearch, + ]); + + useEffect(() => { + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + + if ( + shouldUseCompactGeneralWorkbench || + !initialDispatchKey || + contentId || + !sessionId || + messages.length > 0 || + isSending + ) { + return; + } + + if (consumedInitialPromptRef.current === initialDispatchKey) { + return; + } + + let disposed = false; + consumedInitialPromptRef.current = initialDispatchKey; + + void (async () => { + const started = await handleSend( + pendingInitialImages, + effectiveChatToolPreferences.webSearch, + effectiveChatToolPreferences.thinking, + pendingInitialPrompt, + undefined, + undefined, + initialAutoSendRequestMetadata + ? { + requestMetadata: initialAutoSendRequestMetadata, + } + : undefined, + ); + if (disposed) { + return; + } + if (!started) { + consumedInitialPromptRef.current = null; + return; + } + onInitialUserPromptConsumed?.(); + })(); + + return () => { + disposed = true; + }; + }, [ + contentId, + handleSend, + initialAutoSendRequestMetadata, + initialDispatchKey, + initialUserImages, + initialUserPrompt, + isSending, + messages.length, + onInitialUserPromptConsumed, + sessionId, + shouldUseCompactGeneralWorkbench, + effectiveChatToolPreferences.thinking, + effectiveChatToolPreferences.webSearch, + ]); + + useEffect(() => { + hasTriggeredGuide.current = false; + consumedInitialPromptRef.current = null; + }, [contentId]); useWorkspaceImageWorkbenchEventRuntime({ canvasState, diff --git a/src/components/agent/chat/components/GeneralWorkbenchEntryPromptAccessory.test.tsx b/src/components/agent/chat/components/GeneralWorkbenchEntryPromptAccessory.test.tsx deleted file mode 100644 index 5e2710e7f..000000000 --- a/src/components/agent/chat/components/GeneralWorkbenchEntryPromptAccessory.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { act, type ComponentProps } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GeneralWorkbenchEntryPromptAccessory } from "./GeneralWorkbenchEntryPromptAccessory"; -import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt"; - -interface MountedHarness { - container: HTMLDivElement; - root: Root; -} - -const mountedRoots: MountedHarness[] = []; - -beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; -}); - -afterEach(() => { - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) { - break; - } - - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } - - vi.clearAllMocks(); -}); - -function renderAccessory( - props?: Partial>, -) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - const defaultPrompt: GeneralWorkbenchEntryPromptState = { - kind: "initial_prompt", - signature: "dispatch-1", - title: "已恢复待执行创作意图", - description: "进入页面后不会自动开始生成,确认后再继续。", - actionLabel: "继续生成", - prompt: "请先生成主稿", - }; - const defaultProps: ComponentProps< - typeof GeneralWorkbenchEntryPromptAccessory - > = { - prompt: defaultPrompt, - onRestart: vi.fn(), - onContinue: vi.fn(async () => undefined), - }; - - act(() => { - root.render( - , - ); - }); - - mountedRoots.push({ container, root }); - return { - container, - props: { - ...defaultProps, - ...props, - }, - }; -} - -describe("GeneralWorkbenchEntryPromptAccessory", () => { - it("应渲染提示文案与操作按钮", () => { - const { container } = renderAccessory(); - - expect( - container.querySelector('[data-testid="theme-workbench-entry-prompt"]') - ?.textContent, - ).toContain("已恢复待执行创作意图"); - expect(container.textContent).toContain("进入页面后不会自动开始生成"); - expect(container.textContent).toContain("继续生成"); - expect(container.textContent).toContain("重新开始"); - }); - - it("应分发继续与重启动作", async () => { - const onRestart = vi.fn(); - const onContinue = vi.fn(async () => undefined); - const { container } = renderAccessory({ - onRestart, - onContinue, - }); - - const restartButton = container.querySelector( - '[data-testid="theme-workbench-entry-restart"]', - ); - const continueButton = container.querySelector( - '[data-testid="theme-workbench-entry-continue"]', - ); - - if (!restartButton || !continueButton) { - throw new Error("未找到通用工作台入口提示操作按钮"); - } - - act(() => { - restartButton.click(); - }); - expect(onRestart).toHaveBeenCalledTimes(1); - - await act(async () => { - continueButton.click(); - }); - expect(onContinue).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/agent/chat/components/GeneralWorkbenchEntryPromptAccessory.tsx b/src/components/agent/chat/components/GeneralWorkbenchEntryPromptAccessory.tsx deleted file mode 100644 index 0151e3f5c..000000000 --- a/src/components/agent/chat/components/GeneralWorkbenchEntryPromptAccessory.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { memo } from "react"; -import { Info } from "lucide-react"; -import styled from "styled-components"; -import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt"; - -interface GeneralWorkbenchEntryPromptAccessoryProps { - prompt: GeneralWorkbenchEntryPromptState; - onRestart: () => void; - onContinue: () => Promise | void; -} - -const GeneralWorkbenchEntryPromptCard = styled.div` - display: flex; - flex-direction: column; - gap: 10px; - min-width: min(360px, calc(100vw - 48px)); - max-width: min(420px, calc(100vw - 48px)); - padding: 12px 14px; - border-radius: 18px; - border: 1px solid rgba(191, 219, 254, 0.92); - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.98) 0%, - rgba(239, 246, 255, 0.96) 100% - ); - color: #0f172a; - box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26); -`; - -const GeneralWorkbenchEntryPromptHeader = styled.div` - display: flex; - align-items: flex-start; - gap: 8px; -`; - -const GeneralWorkbenchEntryPromptTitleWrap = styled.div` - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -`; - -const GeneralWorkbenchEntryPromptTitle = styled.span` - font-size: 13px; - font-weight: 700; - line-height: 1.4; -`; - -const GeneralWorkbenchEntryPromptDescription = styled.span` - font-size: 12px; - line-height: 1.5; - color: #475569; -`; - -const GeneralWorkbenchEntryPromptActions = styled.div` - display: flex; - justify-content: flex-end; - gap: 8px; -`; - -const GeneralWorkbenchEntryPromptButton = styled.button<{ - $variant?: "primary" | "ghost"; -}>` - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 88px; - height: 32px; - padding: 0 12px; - border-radius: 999px; - border: 1px solid - ${({ $variant }) => - $variant === "ghost" - ? "rgba(191, 219, 254, 0.92)" - : "rgba(59, 130, 246, 0.94)"}; - background: ${({ $variant }) => - $variant === "ghost" - ? "rgba(255, 255, 255, 0.92)" - : "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"}; - color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")}; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: - transform 0.16s ease, - box-shadow 0.2s ease, - background 0.2s ease; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46); - background: ${({ $variant }) => - $variant === "ghost" - ? "rgba(239, 246, 255, 0.98)" - : "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"}; - } -`; - -export const GeneralWorkbenchEntryPromptAccessory = memo( - function GeneralWorkbenchEntryPromptAccessory({ - prompt, - onRestart, - onContinue, - }: GeneralWorkbenchEntryPromptAccessoryProps) { - return ( - - - - - - {prompt.title} - - - {prompt.description} - - - - - - 重新开始 - - { - void onContinue(); - }} - > - {prompt.actionLabel} - - - - ); - }, -); diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx index a7fc6306b..5e312a1bb 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx @@ -201,9 +201,11 @@ describe("GeneralWorkbenchSidebar", () => { expect(container.textContent).toContain("任务工作台"); expect(container.textContent).toContain("聚焦当前任务、后续节点与相关版本。"); expect(container.textContent).toContain("任务视图"); - expect(container.textContent).toContain("当前任务"); + expect(container.textContent).toContain("当前焦点"); expect(container.textContent).toContain("撰写主稿"); + expect(container.textContent).toContain("后续任务"); expect(container.textContent).toContain("已完成 1/4"); + expect(container.textContent).toContain("已完成 1 项"); expect(container.textContent).toMatch(/相关分支|相关版本/); const stepNodes = Array.from( @@ -216,15 +218,15 @@ describe("GeneralWorkbenchSidebar", () => { '[data-testid="workflow-sidebar-branch-section"]', ) as HTMLElement | null; - expect(stepNodes).toHaveLength(4); + expect(stepNodes).toHaveLength(2); expect(stepNodes.map((node) => node.getAttribute("data-status"))).toEqual([ - "active", "error", "pending", - "completed", ]); expect(taskSection).toBeTruthy(); expect(branchSection).toBeTruthy(); + expect(taskSection?.textContent).toContain("当前焦点"); + expect(taskSection?.textContent).toContain("后续任务"); const taskSectionOrder = taskSection && branchSection ? taskSection.compareDocumentPosition(branchSection) & @@ -449,7 +451,7 @@ describe("GeneralWorkbenchSidebar", () => { expect(container.textContent).toContain("1. 提炼内容主线"); expect(container.textContent).toContain("2. 生成封面提示词"); expect(container.textContent).toContain("允许工具"); - expect(container.textContent).toContain("文件读取"); + expect(container.textContent).toContain("查看文件"); expect(container.textContent).toContain("图片生成"); expect(container.textContent).toContain("适用场景"); expect(container.textContent).toContain( @@ -583,7 +585,7 @@ describe("GeneralWorkbenchSidebar", () => { }); } - expect(container.textContent).toContain("文件读取"); + expect(container.textContent).toContain("查看文件"); expect(container.textContent).toContain("文件不存在"); expect(container.textContent).not.toContain("执行技能 社媒主稿与封面"); }); @@ -644,7 +646,7 @@ describe("GeneralWorkbenchSidebar", () => { } expect(container.textContent).toContain("页面打开"); - expect(container.textContent).toContain("任务输出"); + expect(container.textContent).toContain("查看任务结果"); expect(container.textContent).toContain("用户确认"); expect(container.textContent).not.toContain("网络检索"); expect(container.textContent).not.toContain("执行命令"); @@ -766,6 +768,19 @@ describe("GeneralWorkbenchSidebar", () => { }); } + expect(container.querySelector("button[aria-label='切换相关记录']")).toBeTruthy(); + expect(container.querySelector("button[aria-label='删除分支']")).toBeNull(); + expect(container.textContent).toContain("当前焦点落在"); + + const branchToggle = container.querySelector( + "button[aria-label='切换相关记录']", + ) as HTMLButtonElement | null; + if (branchToggle) { + act(() => { + branchToggle.click(); + }); + } + const mergeButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "采纳", ); @@ -796,6 +811,16 @@ describe("GeneralWorkbenchSidebar", () => { expect(container.textContent).toContain("相关版本"); expect(container.textContent).toContain("新增版本"); + expect(container.textContent).toContain("当前焦点落在"); + + const branchToggle = container.querySelector( + "button[aria-label='切换相关记录']", + ) as HTMLButtonElement | null; + if (branchToggle) { + act(() => { + branchToggle.click(); + }); + } const setMainButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "设为主稿", @@ -820,6 +845,9 @@ describe("GeneralWorkbenchSidebar", () => { workflowTab.click(); }); } + + expect(container.textContent).toContain("最近一组:content_post_with_cover"); + const activityToggle = container.querySelector( "button[aria-label='切换活动日志']", ) as HTMLButtonElement | null; @@ -829,9 +857,10 @@ describe("GeneralWorkbenchSidebar", () => { }); } - expect(container.textContent).toContain("闸门:写作闸门"); - expect(container.textContent).toContain("来源:skill"); - expect(container.textContent).toContain("运行:run-abcd…"); + expect(container.textContent).toContain("过程记录"); + expect(container.textContent).toContain("写作闸门"); + expect(container.textContent).toContain("技能"); + expect(container.textContent).toContain("查看运行 run-abcd…"); }); it("活动日志应按运行维度分组展示步骤", () => { @@ -873,6 +902,7 @@ describe("GeneralWorkbenchSidebar", () => { workflowTab.click(); }); } + expect(container.textContent).toContain("最近一组:research_topic"); const activityToggle = container.querySelector( "button[aria-label='切换活动日志']", @@ -885,12 +915,17 @@ describe("GeneralWorkbenchSidebar", () => { expect(container.textContent).toContain("research_topic"); expect(container.textContent).toContain("write_file"); - expect(container.textContent).toContain("技能:research_topic"); - expect(container.textContent).toContain("修改:content-posts/research.md"); - expect(container.textContent).toContain('输入:{"topic":"AI"}'); - expect(container.textContent).toContain("输出:已完成选题调研"); + expect(container.textContent).toContain("技能"); + expect(container.textContent).toContain("content-posts/research.md"); + expect(container.textContent).toContain('{"topic":"AI"}'); + expect(container.textContent).toContain("已完成选题调研"); + expect( + container.querySelector( + 'button[aria-label="定位活动产物路径-content-posts/research.md"]', + ), + ).toBeNull(); const runButtons = Array.from(container.querySelectorAll("button")).filter( - (button) => button.textContent === "运行:rungrp01", + (button) => button.textContent === "查看运行 rungrp01", ); expect(runButtons.length).toBe(1); }); @@ -917,7 +952,7 @@ describe("GeneralWorkbenchSidebar", () => { } const runButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("运行:run-abcd…"), + (button) => button.textContent?.includes("查看运行 run-abcd…"), ); expect(runButton).toBeTruthy(); if (runButton) { @@ -966,12 +1001,12 @@ describe("GeneralWorkbenchSidebar", () => { }); } - expect(container.textContent).toContain("运行详情"); - expect(container.textContent).toContain("ID:run-detail-1"); - expect(container.textContent).toContain("状态:处理中"); + expect(container.textContent).toContain("当前查看运行"); + expect(container.textContent).toContain("运行ID:run-detail-1"); + expect(container.textContent).toContain("处理中"); }); - it("运行详情应支持复制运行ID与元数据", async () => { + it("运行详情应支持复制运行ID与原始记录", async () => { const { container } = renderSidebar({ activeRunDetail: { id: "run-copy-1", @@ -1012,7 +1047,7 @@ describe("GeneralWorkbenchSidebar", () => { "button[aria-label='复制运行ID']", ) as HTMLButtonElement | null; const copyMetadataButton = container.querySelector( - "button[aria-label='复制运行元数据']", + "button[aria-label='复制原始记录']", ) as HTMLButtonElement | null; expect(copyIdButton).toBeTruthy(); @@ -1084,12 +1119,10 @@ describe("GeneralWorkbenchSidebar", () => { } expect(container.textContent).toContain( - "工作流:social_content_pipeline_v1", + "工作流 social_content_pipeline_v1", ); - expect(container.textContent).toContain("执行ID:exec-artifact-1"); - expect(container.textContent).toContain("版本ID:ver-artifact-1"); expect(container.textContent).toContain( - "阶段:选题闸门 → 写作闸门 → 发布闸门", + "选题闸门 → 写作闸门 → 发布闸门", ); expect(container.textContent).toContain("content-posts/demo.md"); expect(container.textContent).toContain( @@ -1161,8 +1194,6 @@ describe("GeneralWorkbenchSidebar", () => { timeLabel: "11:20", applyTarget: "主稿内容", contextIds: ["material:1"], - runId: "run-artifact-group-1", - executionId: "exec-artifact-group-1", sessionId: "session-group", artifactPaths: ["content-posts/group.md"], gateKey: "write_mode", @@ -1263,7 +1294,9 @@ describe("GeneralWorkbenchSidebar", () => { }); } - expect(container.textContent).toContain("任务提交"); + expect(container.textContent).toContain("任务记录"); + expect(container.textContent).toContain("最近一次:排版优化"); + expect(container.textContent).toContain("共 3 条任务记录,按 2 类归档。"); const toggleCreationTasksButton = container.querySelector( "button[aria-label='切换任务提交记录']", ) as HTMLButtonElement | null; @@ -1275,7 +1308,7 @@ describe("GeneralWorkbenchSidebar", () => { } expect(container.textContent).toContain("配图生成"); expect(container.textContent).toContain("排版优化"); - expect(container.textContent).toContain("本组 2 条"); + expect(container.textContent).toContain("2 条记录"); const copyAbsolutePathButton = container.querySelector( 'button[aria-label="复制任务文件绝对路径-task-image-1"]', diff --git a/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx b/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx index d74625bc1..0226ad926 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx @@ -50,6 +50,8 @@ interface GeneralWorkbenchWorkflowPanelProps { progressPercent: number; onAddImage?: () => Promise | void; onImportDocument?: () => Promise | void; + showBranchRecords: boolean; + onToggleBranchRecords: () => void; creationTaskEventsCount: number; showCreationTasks: boolean; onToggleCreationTasks: () => void; @@ -99,6 +101,34 @@ const TOGGLE_BUTTON_CLASSNAME = const WORKFLOW_INLINE_LABEL_CLASSNAME = "text-[10px] font-semibold text-slate-500"; +const WORKFLOW_QUEUE_HEADER_CLASSNAME = + "mt-3 flex items-center justify-between text-[10px] font-semibold text-slate-500"; + +const WORKFLOW_QUEUE_LIST_CLASSNAME = "mt-2 flex flex-col gap-1.5"; + +function WorkflowQueueRow({ + $status, + className, + ...props +}: React.ComponentPropsWithoutRef<"div"> & { + $status: StepStatus; +}) { + return ( +
+ ); +} + function createDiv(baseClassName: string) { return function ClassedDiv({ className, @@ -126,17 +156,12 @@ function createCode(baseClassName: string) { }; } -function createPre(baseClassName: string) { - return function ClassedPre({ - className, - ...props - }: React.ComponentPropsWithoutRef<"pre">) { - return
;
-  };
-}
-
 const BranchList = createDiv("flex flex-col gap-1.5");
 
+const BranchSectionSummary = createDiv(
+  "mt-2 text-[11px] leading-5 text-slate-500",
+);
+
 function BranchItem({
   $active,
   className,
@@ -147,10 +172,10 @@ function BranchItem({
   return (
     
; @@ -363,6 +463,60 @@ function getBranchMetaText( : "记录一条正在推进中的相关分支"; } +function buildBranchSectionSummaryText(params: { + currentBranch: TopicBranchItem | null; + relatedCount: number; + isVersionMode: boolean; +}): string { + const { currentBranch, relatedCount, isVersionMode } = params; + const recordLabel = isVersionMode ? "版本" : "分支"; + if (!currentBranch) { + return isVersionMode + ? "当前任务还没有沉淀出相关版本记录" + : "当前任务还没有拆出相关分支记录"; + } + if (relatedCount <= 0) { + return `当前焦点落在「${currentBranch.title}」,目前只保留这一条${recordLabel}记录。`; + } + return `当前焦点落在「${currentBranch.title}」,另有 ${relatedCount} 条${recordLabel}记录可在需要时展开查看。`; +} + +function buildCreationTaskSectionSummary(params: { + groups: GeneralWorkbenchCreationTaskGroup[]; + totalCount: number; +}): { + title: string; + meta: string; +} { + const { groups, totalCount } = params; + if (totalCount <= 0 || groups.length === 0) { + return { + title: "最近还没有新的任务记录", + meta: "后续生成的任务文件会按类型归档在这里。", + }; + } + + const latestGroup = groups[0]; + const latestTime = latestGroup.latestTimeLabel || "最近"; + return { + title: `最近一次:${latestGroup.label}`, + meta: `${latestTime} · 共 ${totalCount} 条任务记录,按 ${groups.length} 类归档。`, + }; +} + +function formatCreationTaskCountLabel(count: number): string { + return `${count} 条记录`; +} + +function getCreationTaskTitle(path: string): string { + const normalized = path.trim(); + if (!normalized) { + return "未命名任务"; + } + const segments = normalized.split(/[\\/]+/).filter(Boolean); + return segments[segments.length - 1] || normalized; +} + function formatGateLabel( gateKey?: SidebarActivityLog["gateKey"], ): string | null { @@ -402,6 +556,154 @@ function formatRunStatusLabel(status: AgentRun["status"]): string { return status; } +function getPrimaryActivityLog( + group: GeneralWorkbenchActivityLogGroup, +): GeneralWorkbenchActivityLogGroup["logs"][number] | undefined { + return group.logs.find((log) => log.source === "skill") || group.logs[0]; +} + +function formatActivityStatusLabel( + status: GeneralWorkbenchActivityLogGroup["status"], +): string { + if (status === "running") return "处理中"; + if (status === "failed") return "失败"; + return "已记录"; +} + +function getActivityStatusBadgeClassName( + status: GeneralWorkbenchActivityLogGroup["status"], +) { + return cn( + "inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium", + status === "running" && "border-sky-200 bg-sky-50 text-sky-700", + status === "failed" && "border-rose-200 bg-rose-50 text-rose-700", + status === "completed" && "border-emerald-200 bg-emerald-50 text-emerald-700", + ); +} + +function formatActivitySourceLabel(source?: string): string | null { + const normalized = source?.trim(); + if (!normalized) { + return null; + } + if (normalized === "skill") { + return "技能"; + } + if (normalized === "tool") { + return "工具"; + } + return normalized; +} + +function getRunDetailStatusBadgeClassName(status: AgentRun["status"]) { + return cn( + "inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium", + status === "running" && "border-sky-200 bg-sky-50 text-sky-700", + status === "success" && "border-emerald-200 bg-emerald-50 text-emerald-700", + status === "error" && "border-rose-200 bg-rose-50 text-rose-700", + status === "queued" && "border-amber-200 bg-amber-50 text-amber-700", + status === "canceled" && "border-slate-200 bg-slate-100 text-slate-500", + status === "timeout" && "border-rose-200 bg-rose-50 text-rose-700", + ); +} + +function buildRunDetailSummaryText(params: { + runMetadataSummary: GeneralWorkbenchRunMetadataSummary; + activeRunStagesLabel?: string | null; +}): string { + const { runMetadataSummary, activeRunStagesLabel } = params; + const parts: string[] = []; + if (activeRunStagesLabel) { + parts.push(activeRunStagesLabel); + } + if (runMetadataSummary.workflow) { + parts.push(`工作流 ${runMetadataSummary.workflow}`); + } + if (runMetadataSummary.artifactPaths.length > 0) { + parts.push( + runMetadataSummary.artifactPaths.length === 1 + ? `产物 ${runMetadataSummary.artifactPaths[0]}` + : `产物 ${runMetadataSummary.artifactPaths.length} 项`, + ); + } + return parts.join(" · ") || "查看本次运行的状态与产物记录"; +} + +function buildActivitySummary( + group: GeneralWorkbenchActivityLogGroup, + gateLabel: string | null, +): string { + const parts: string[] = []; + if (gateLabel) { + parts.push(gateLabel); + } + if (group.artifactPaths.length > 0) { + parts.push( + group.artifactPaths.length === 1 + ? `产物 ${group.artifactPaths[0]}` + : `产物 ${group.artifactPaths.length} 项`, + ); + } + if (group.logs.length > 1) { + parts.push(`共 ${group.logs.length} 步`); + } + return parts.join(" · "); +} + +function buildActivitySectionSummary(params: { + groups: GeneralWorkbenchActivityLogGroup[]; + activeRunDetail?: AgentRun | null; +}): { + title: string; + meta: string; +} { + const { groups, activeRunDetail } = params; + if (groups.length === 0) { + return { + title: "最近还没有过程记录", + meta: "技能调用、工具步骤与运行详情会按组收纳在这里。", + }; + } + + const latestGroup = groups[0]; + const primaryLog = getPrimaryActivityLog(latestGroup); + const gateLabel = formatGateLabel(latestGroup.gateKey); + const sourceLabel = formatActivitySourceLabel(latestGroup.source); + const activeRunLabel = activeRunDetail?.id + ? formatRunIdShort(activeRunDetail.id) || activeRunDetail.id + : null; + const metaParts = [ + latestGroup.timeLabel || "最近", + formatActivityStatusLabel(latestGroup.status), + sourceLabel, + gateLabel, + latestGroup.logs.length > 1 ? `${latestGroup.logs.length} 步` : null, + latestGroup.artifactPaths.length > 0 + ? latestGroup.artifactPaths.length === 1 + ? "1 个产物" + : `${latestGroup.artifactPaths.length} 个产物` + : null, + activeRunLabel ? `当前查看 ${activeRunLabel}` : null, + ].filter(Boolean); + + return { + title: `最近一组:${primaryLog?.name || "过程记录"}`, + meta: metaParts.join(" · "), + }; +} + +function buildActivityStepSummary( + log: GeneralWorkbenchActivityLogGroup["logs"][number], +): string | null { + const parts = [log.inputSummary, log.outputSummary] + .map((item) => item?.trim() || "") + .filter((item) => item.length > 0); + if (parts.length === 0) { + return null; + } + return parts.join(" → "); +} + function renderActivityLogItem( group: GeneralWorkbenchActivityLogGroup, onViewRunDetail: GeneralWorkbenchWorkflowPanelProps["onViewRunDetail"], @@ -410,68 +712,80 @@ function renderActivityLogItem( ) { const gateLabel = formatGateLabel(group.gateKey); const runLabel = formatRunIdShort(group.runId); - const sourceLabel = group.source?.trim() || "-"; - const primaryLog = - group.logs.find((log) => log.source === "skill") || group.logs[0]; + const sourceLabel = formatActivitySourceLabel(group.source); + const primaryLog = getPrimaryActivityLog(group); + const activitySummary = buildActivitySummary(group, gateLabel); return ( - - - ● - - {primaryLog?.source === "skill" - ? `技能:${primaryLog.name}` - : primaryLog?.name || "活动日志"} - - {group.timeLabel} - - {gateLabel || sourceLabel ? ( - - {gateLabel ? `闸门:${gateLabel}` : ""} - {gateLabel && sourceLabel ? " · " : ""} - {sourceLabel ? `来源:${sourceLabel}` : ""} - + + +
+ {formatActivityStatusLabel(group.status)} +
+ + {primaryLog?.name || "过程记录"} + + {sourceLabel ? {sourceLabel} : null} + {gateLabel ? {gateLabel} : null} + {group.logs.length > 1 ? ( + {`${group.logs.length} 步`} + ) : null} + {group.artifactPaths.length > 0 ? ( + + {group.artifactPaths.length === 1 + ? "1 个产物" + : `${group.artifactPaths.length} 个产物`} + + ) : null} + + +
+ {group.timeLabel} +
+
+ {activitySummary ? ( + {activitySummary} ) : null} - {group.artifactPaths.length > 0 ? ( - 修改:{group.artifactPaths.join("、")} - ) : null} - + {group.logs.map((log) => ( - - - • - {log.name} - {log.timeLabel} - - {log.inputSummary ? ( - 输入:{log.inputSummary} + + + • + {log.name} + + {log.timeLabel} + + + {buildActivityStepSummary(log) ? ( + + {buildActivityStepSummary(log)} + ) : null} - {log.outputSummary ? ( - 输出:{log.outputSummary} - ) : null} - + ))} - + {group.runId && onViewRunDetail ? ( onViewRunDetail(group.runId!)} > - 运行:{runLabel || group.runId} + 查看运行 {runLabel || group.runId} ) : null} - {group.artifactPaths.map((artifactPath) => ( - - ))} + {!group.runId + ? group.artifactPaths.map((artifactPath) => ( + + )) + : null} -
+ ); } @@ -522,6 +836,8 @@ function GeneralWorkbenchWorkflowPanelComponent({ progressPercent, onAddImage, onImportDocument, + showBranchRecords, + onToggleBranchRecords, creationTaskEventsCount, showCreationTasks, onToggleCreationTasks, @@ -542,7 +858,14 @@ function GeneralWorkbenchWorkflowPanelComponent({ const workflowSnapshot = buildWorkflowStepSnapshot(workflowSteps, 3); const currentWorkflowStep = workflowSnapshot.leadingStep; const remainingSteps = workflowSnapshot.remainingCount; - const sortedWorkflowSteps = workflowSnapshot.sortedSteps; + const visibleQueueSteps = workflowSnapshot.visibleQueueItems.filter( + (step) => step.id !== currentWorkflowStep?.id, + ); + const hiddenQueueCount = Math.max( + workflowSnapshot.openSteps.length - 1 - visibleQueueSteps.length, + 0, + ); + const completedWorkflowSteps = workflowSnapshot.completedCount; const sortedBranchItems = [...branchItems].sort((left, right) => { if (left.isCurrent !== right.isCurrent) { return left.isCurrent ? -1 : 1; @@ -573,6 +896,25 @@ function GeneralWorkbenchWorkflowPanelComponent({ const branchCreateLabel = getBranchCreateLabel(isVersionMode); const branchPrimaryActionLabel = getBranchPrimaryActionLabel(isVersionMode); const branchSecondaryActionLabel = getBranchSecondaryActionLabel(isVersionMode); + const currentBranchItem = + sortedBranchItems.find((item) => item.isCurrent) ?? sortedBranchItems[0] ?? null; + const secondaryBranchCount = Math.max( + sortedBranchItems.length - (currentBranchItem ? 1 : 0), + 0, + ); + const branchSectionSummaryText = buildBranchSectionSummaryText({ + currentBranch: currentBranchItem, + relatedCount: secondaryBranchCount, + isVersionMode, + }); + const creationTaskSectionSummary = buildCreationTaskSectionSummary({ + groups: groupedCreationTaskEvents, + totalCount: creationTaskEventsCount, + }); + const activitySectionSummary = buildActivitySectionSummary({ + groups: groupedActivityLogs, + activeRunDetail, + }); return ( <> @@ -604,7 +946,7 @@ function GeneralWorkbenchWorkflowPanelComponent({
- 当前任务 + 当前焦点
-
- {sortedWorkflowSteps.map((step) => ( -
- - {getStepIcon(step.status)} - -
-
{step.title}
-
- - {getWorkflowStatusLabel(step.status)} + {visibleQueueSteps.length > 0 ? ( +
+
+ 后续任务 + + {hiddenQueueCount > 0 + ? `已展示 ${visibleQueueSteps.length} 项,另有 ${hiddenQueueCount} 项` + : `${visibleQueueSteps.length} 项待处理`}
- ))} -
+
+ {visibleQueueSteps.map((step, index) => ( + + + {getStepIcon(step.status)} + +
+
+ {`后续 ${index + 1}`} +
+
+ {step.title} +
+
+ + {getWorkflowStatusLabel(step.status)} + +
+ ))} +
+
+ ) : null} + {completedWorkflowSteps > 0 ? ( +
+ + {`已完成 ${completedWorkflowSteps} 项`} + + {remainingSteps > 0 ? ( + 已完成项已收起,优先聚焦当前与后续任务 + ) : ( + 当前流程已完成,可回看下方记录 + )} +
+ ) : null}
{branchItems.length} +
- - {branchItems.length === 0 ? ( - {getEmptyBranchText(isVersionMode)} - ) : ( - sortedBranchItems.map((item) => ( - - - -
-
- onSwitchTopic(item.id)}> - {item.title} - - - {item.isCurrent - ? "当前焦点" - : getBranchStatusText(item.status)} - - {!isVersionMode ? ( - onDeleteTopic(item.id)} - aria-label="删除分支" - > - - - ) : null} -
- {getBranchMetaText(item, isVersionMode)} -
-
- - onSetBranchStatus(item.id, "merged")} - > - {branchPrimaryActionLabel} - - onSetBranchStatus(item.id, "pending")} - > - {branchSecondaryActionLabel} - - -
- )) - )} -
+ {branchItems.length === 0 ? ( + {getEmptyBranchText(isVersionMode)} + ) : ( + <> + + {branchSectionSummaryText} + + {showBranchRecords ? ( + + {sortedBranchItems.map((item) => ( + + + +
+
+ onSwitchTopic(item.id)}> + {item.title} + + + {item.isCurrent + ? "当前焦点" + : getBranchStatusText(item.status)} + + {!isVersionMode ? ( + onDeleteTopic(item.id)} + aria-label="删除分支" + > + + + ) : null} +
+ {getBranchMetaText(item, isVersionMode)} + {item.isCurrent ? ( + + onSetBranchStatus(item.id, "merged")} + > + {branchPrimaryActionLabel} + + onSetBranchStatus(item.id, "pending")} + > + {branchSecondaryActionLabel} + + + ) : ( + 切换为当前焦点后再继续处理这条记录 + )} +
+
+
+ ))} +
+ ) : null} + + )}
- 任务提交 + 任务记录 {creationTaskEventsCount} @@ -790,64 +1189,59 @@ function GeneralWorkbenchWorkflowPanelComponent({
+ + + {creationTaskSectionSummary.title} + + + {creationTaskSectionSummary.meta} + + {showCreationTasks ? ( {groupedCreationTaskEvents.length === 0 ? ( - 暂无任务提交 + 最近还没有新的任务记录 ) : ( groupedCreationTaskEvents.map((group) => ( - - - ● + + {group.label} - {group.latestTimeLabel} - - - 类型:{group.taskType} · 本组 {group.tasks.length} 条 - - + + {formatCreationTaskCountLabel(group.tasks.length)} + + + {group.latestTimeLabel} + + + {group.tasks.map((task) => ( - - - • - {task.path} - {task.timeLabel} - - 任务ID:{task.taskId} - {task.absolutePath ? ( - - - - {task.absolutePath} - - { - void onCopyText(task.absolutePath || ""); - }} - > - 复制绝对路径 - - - - ) : ( - - { - void onCopyText(task.path); - }} - > - 复制路径 - - - )} - + + + + + {getCreationTaskTitle(task.path)} + + {task.timeLabel} + + {task.path} + + { + void onCopyText(task.absolutePath || task.path); + }} + > + 复制路径 + + ))} - - + + )) )} @@ -856,7 +1250,7 @@ function GeneralWorkbenchWorkflowPanelComponent({
- 活动日志 + 过程记录 {groupedActivityLogs.length} @@ -875,11 +1269,19 @@ function GeneralWorkbenchWorkflowPanelComponent({
+ + + {activitySectionSummary.title} + + + {activitySectionSummary.meta} + + {showActivityLogs ? ( <> {groupedActivityLogs.length === 0 ? ( - 暂无活动日志 + 最近还没有过程记录 ) : ( groupedActivityLogs.map((group) => renderActivityLogItem( @@ -895,29 +1297,43 @@ function GeneralWorkbenchWorkflowPanelComponent({ 运行详情加载中... ) : activeRunDetail ? ( - 运行详情 - ID:{activeRunDetail.id} - - 状态:{formatRunStatusLabel(activeRunDetail.status)} - - {runMetadataSummary.workflow ? ( - - 工作流:{runMetadataSummary.workflow} - - ) : null} - {runMetadataSummary.executionId ? ( - - 执行ID:{runMetadataSummary.executionId} - - ) : null} - {runMetadataSummary.versionId ? ( - - 版本ID:{runMetadataSummary.versionId} - - ) : null} - {activeRunStagesLabel ? ( - 阶段:{activeRunStagesLabel} - ) : null} + +
+ {formatRunStatusLabel(activeRunDetail.status)} +
+ + 当前查看运行 + + + {formatActivitySourceLabel(activeRunDetail.source) || + "运行"} + + {runMetadataSummary.workflow ? ( + + {runMetadataSummary.workflow} + + ) : null} + {runMetadataSummary.artifactPaths.length > 0 ? ( + + {runMetadataSummary.artifactPaths.length === 1 + ? "1 个产物" + : `${runMetadataSummary.artifactPaths.length} 个产物`} + + ) : null} + + +
+ + {buildRunDetailSummaryText({ + runMetadataSummary, + activeRunStagesLabel, + })} + + 运行ID:{activeRunDetail.id} { void onCopyText(runMetadataText); }} > - 复制运行元数据 + 复制原始记录 {runMetadataSummary.artifactPaths.length > 0 ? ( + 关联产物 {runMetadataSummary.artifactPaths.map((artifactPath) => ( {artifactPath} - { - void onCopyText(artifactPath); - }} - > - 复制路径 - - { - void onRevealArtifactInFinder(artifactPath); - }} - > - 定位 - - { - void onOpenArtifactWithDefaultApp(artifactPath); - }} - > - 打开 - + + { + void onCopyText(artifactPath); + }} + > + 复制 + + { + void onRevealArtifactInFinder(artifactPath); + }} + > + 定位 + + { + void onOpenArtifactWithDefaultApp(artifactPath); + }} + > + 打开 + + ))} ) : null} - {runMetadataText}
) : null} diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 78216fdbf..225ee94b6 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -582,6 +582,50 @@ describe("HarnessStatusPanel", () => { known_gaps: [ "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。", ], + observability_summary: { + schema_version: "v1", + known_gaps: [ + "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。", + ], + signal_coverage: [ + { + signal: "correlation", + status: "exported", + source: "runtime thread identity", + detail: "已导出关联键。", + }, + { + signal: "artifactValidator", + status: "exported", + source: "artifact_document_validator", + detail: "已导出 Artifact 校验结果。", + }, + ], + verification_summary: { + artifact_validator: { + applicable: true, + record_count: 1, + issue_count: 2, + repaired_count: 1, + fallback_used_count: 0, + outcome: "blocking_failure", + }, + browser_verification: { + record_count: 2, + success_count: 1, + failure_count: 1, + unknown_count: 0, + outcome: "blocking_failure", + }, + focus_verification_failure_outcomes: [ + "Artifact 校验存在 2 条未恢复 issues。", + "浏览器验证存在 1 条失败线索。", + ], + focus_verification_recovered_outcomes: [ + "Artifact 校验已恢复 1 个产物,fallback 0 次。", + ], + }, + }, artifacts: [ { kind: "summary", @@ -620,6 +664,10 @@ describe("HarnessStatusPanel", () => { "session-evidence-1", ); expect(document.body.textContent).toContain("问题证据包"); + expect(document.body.textContent).toContain("验证结果"); + expect(document.body.textContent).toContain("阻塞失败"); + expect(document.body.textContent).toContain("验证失败焦点"); + expect(document.body.textContent).toContain("已恢复结果"); expect(document.body.textContent).toContain("当前已知缺口"); expect(document.body.textContent).toContain( ".lime/harness/sessions/session-evidence-1/evidence/summary.md", @@ -875,6 +923,22 @@ describe("HarnessStatusPanel", () => { pending_request_count: 1, queued_turn_count: 0, default_decision_status: "pending_review", + verification_summary: { + artifact_validator: { + applicable: true, + record_count: 1, + issue_count: 2, + repaired_count: 1, + fallback_used_count: 0, + outcome: "blocking_failure", + }, + focus_verification_failure_outcomes: [ + "Artifact 校验存在 2 条未恢复 issues。", + ], + focus_verification_recovered_outcomes: [ + "Artifact 校验已恢复 1 个产物,fallback 0 次。", + ], + }, decision: { decision_status: "pending_review", decision_summary: "", @@ -883,8 +947,14 @@ describe("HarnessStatusPanel", () => { risk_tags: [], human_reviewer: "", reviewed_at: undefined, - followup_actions: [], - regression_requirements: [], + followup_actions: [ + "先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点,再决定是继续修复还是补证据。", + "复查 Artifact 校验相关产物,确认 issues / repaired / fallback 状态与最终结论一致。", + ], + regression_requirements: [ + "按 replay case 复现问题并确认修复后行为与预期一致。", + "重新导出 evidence pack,确认 Artifact 校验摘要已更新。", + ], notes: "", }, decision_status_options: [ @@ -967,6 +1037,17 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain( "确认最终决策由人工审核者填写。", ); + expect(document.body.textContent).toContain("验证结果"); + expect(document.body.textContent).toContain("阻塞失败"); + expect(document.body.textContent).toContain( + "Artifact 校验存在 2 条未恢复 issues。", + ); + expect(document.body.textContent).toContain( + "先对照 analysis-context.json / evidence/runtime.json 核对当前验证失败焦点", + ); + expect(document.body.textContent).toContain( + "重新导出 evidence pack,确认 Artifact 校验摘要已更新。", + ); expect(document.body.textContent).toContain("aster-rust"); expect(mockToast.success).toHaveBeenCalledWith("已导出 2 个人工审核文件"); }); @@ -996,6 +1077,20 @@ describe("HarnessStatusPanel", () => { pending_request_count: 1, queued_turn_count: 0, default_decision_status: "pending_review", + verification_summary: { + artifact_validator: { + applicable: true, + record_count: 1, + issue_count: 1, + repaired_count: 0, + fallback_used_count: 0, + outcome: "blocking_failure", + }, + focus_verification_failure_outcomes: [ + "Artifact 校验存在 1 条未恢复 issue。", + ], + focus_verification_recovered_outcomes: [], + }, decision: { decision_status: "pending_review", decision_summary: "", @@ -1064,6 +1159,20 @@ describe("HarnessStatusPanel", () => { pending_request_count: 1, queued_turn_count: 0, default_decision_status: "pending_review", + verification_summary: { + artifact_validator: { + applicable: true, + record_count: 1, + issue_count: 0, + repaired_count: 1, + fallback_used_count: 0, + outcome: "recovered", + }, + focus_verification_failure_outcomes: [], + focus_verification_recovered_outcomes: [ + "Artifact 校验已恢复 1 个产物,fallback 0 次。", + ], + }, decision: { decision_status: "accepted", decision_summary: "确认最小修复可以接受。", @@ -1131,6 +1240,16 @@ describe("HarnessStatusPanel", () => { await Promise.resolve(); }); + const reviewDialog = document.body.querySelector( + '[role="dialog"]', + ) as HTMLDivElement | null; + + expect(reviewDialog?.textContent).toContain("验证结果"); + expect(reviewDialog?.textContent).toContain("阻塞失败"); + expect(reviewDialog?.textContent).toContain( + "Artifact 校验存在 1 条未恢复 issue。", + ); + const statusSelect = document.body.querySelector( 'select[aria-label="决策状态"]', ) as HTMLSelectElement | null; diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 75a222a66..1a9d153e5 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -112,6 +112,7 @@ import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspac import type { TeamRoleDefinition } from "../utils/teamDefinitions"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; +import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection"; import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog"; interface HarnessEnvironmentSummary { @@ -2149,10 +2150,7 @@ export function HarnessStatusPanel({ ) { sections.push({ key: "plan", label: "规划状态" }); } - if ( - realTeamSummary.total > 0 || - harnessState.delegatedTasks.length > 0 - ) { + if (realTeamSummary.total > 0 || harnessState.delegatedTasks.length > 0) { sections.push({ key: "delegation", label: "子任务" }); } if (harnessState.latestContextTrace.length > 0) { @@ -2787,7 +2785,8 @@ export function HarnessStatusPanel({ > {runtimeTaskPresentation.stepStatus === "error" ? ( - ) : runtimeTaskPresentation.stepStatus === "skipped" ? ( + ) : runtimeTaskPresentation.stepStatus === + "skipped" ? ( ) : ( @@ -2905,7 +2904,9 @@ export function HarnessStatusPanel({
{isCurrentCheckpoint ? "当前" : "已记录"} @@ -3198,30 +3199,49 @@ export function HarnessStatusPanel({ {evidencePack ? (
-
- - - - -
+ {(() => { + const verificationSummary = + evidencePack.observability_summary + ?.verification_summary; + const failureFocus = + verificationSummary?.focus_verification_failure_outcomes ?? + []; + const exportedSignals = + evidencePack.observability_summary?.signal_coverage.filter( + (entry) => entry.status === "exported", + ).length ?? 0; + + return ( +
+ + + + +
+ ); + })()}
@@ -3246,6 +3266,16 @@ export function HarnessStatusPanel({
+ {evidencePack.observability_summary + ?.verification_summary ? ( + + ) : null} + {evidencePack.known_gaps.length > 0 ? (
@@ -3644,8 +3674,8 @@ export function HarnessStatusPanel({ 外部分析交接
- 把 handoff / evidence / replay 主链重新包装成外部 - AI 可直接消费的分析交接;复制后可直接粘贴给 AI, + 把 handoff / evidence / replay 主链重新包装成外部 AI + 可直接消费的分析交接;复制后可直接粘贴给 AI, 不需要你再手写补充 prompt。
@@ -3907,8 +3937,9 @@ export function HarnessStatusPanel({ 人工审核记录
- 把外部 AI 的分析结论回挂为 - `review-decision.md/json` 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 Lime 自动闭环。 + 把外部 AI 的分析结论回挂为 `review-decision.md/json` + 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 + Lime 自动闭环。
@@ -4034,6 +4065,14 @@ export function HarnessStatusPanel({
+ {reviewDecisionTemplate.verification_summary ? ( + + ) : null} +
diff --git a/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx b/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx new file mode 100644 index 000000000..455689e42 --- /dev/null +++ b/src/components/agent/chat/components/HarnessVerificationSummarySection.tsx @@ -0,0 +1,63 @@ +import type { AgentRuntimeEvidenceVerificationSummary } from "@/lib/api/agentRuntime"; +import { buildHarnessEvidenceVerificationCardPresentations } from "@/lib/agentRuntime/harnessVerificationPresentation"; +import { Badge } from "@/components/ui/badge"; +import { ShieldAlert } from "lucide-react"; + +export function HarnessVerificationSummarySection({ + summary, +}: { + summary: AgentRuntimeEvidenceVerificationSummary; +}) { + return ( +
+
+ + 验证结果 +
+
+ {buildHarnessEvidenceVerificationCardPresentations(summary).map( + (card) => ( +
+
+ + {card.title} + + {card.badge.label} +
+
+ {card.description} +
+
+ ), + )} +
+ + {summary.focus_verification_failure_outcomes.length > 0 ? ( +
+
验证失败焦点
+
+ {summary.focus_verification_failure_outcomes.map((outcome, index) => ( +
{outcome}
+ ))} +
+
+ ) : null} + + {summary.focus_verification_recovered_outcomes.length > 0 ? ( +
+
已恢复结果
+
+ {summary.focus_verification_recovered_outcomes.map( + (outcome, index) => ( +
{outcome}
+ ), + )} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/agent/chat/components/InlineToolProcessStep.test.tsx b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx new file mode 100644 index 000000000..715dec595 --- /dev/null +++ b/src/components/agent/chat/components/InlineToolProcessStep.test.tsx @@ -0,0 +1,278 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InlineToolProcessStep } from "./InlineToolProcessStep"; +import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./MarkdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ), +})); + +interface RenderResult { + container: HTMLDivElement; + root: Root; +} + +interface RenderOptions { + isMessageStreaming?: boolean; + onOpenSavedSiteContent?: (target: unknown) => void; +} + +const mountedRoots: RenderResult[] = []; + +function renderTool( + toolCall: ToolCallState, + options?: RenderOptions, +): RenderResult { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const rendered = { container, root }; + mountedRoots.push(rendered); + return rendered; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +describe("InlineToolProcessStep", () => { + it("ToolSearch 在流式阶段应保持结构化预览,不自动展开原始 JSON", () => { + const { container } = renderTool( + { + id: "tool-search-streaming-1", + name: "ToolSearch", + arguments: JSON.stringify({ query: "select:Read,Write" }), + status: "completed", + result: { + success: true, + output: JSON.stringify({ + query: "select:Read,Write", + count: 2, + notes: [], + tools: [{ name: "Read" }, { name: "Write" }], + }), + }, + startTime: new Date("2026-04-13T10:00:00.000Z"), + endTime: new Date("2026-04-13T10:00:01.000Z"), + }, + { isMessageStreaming: true }, + ); + + expect(container.textContent).toContain("找到工具 2 个"); + expect(container.textContent).not.toContain("查询:"); + expect(container.textContent).not.toContain("select:Read,Write"); + expect( + container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'), + ).toBeNull(); + expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull(); + expect(container.textContent).not.toContain('"tools"'); + }); + + it("ToolSearch 展开后应展示结构化工具摘要,而不是原始 JSON", () => { + const { container } = renderTool({ + id: "tool-search-1", + name: "ToolSearch", + arguments: JSON.stringify({ query: "select:Read,Write" }), + status: "completed", + result: { + success: true, + output: JSON.stringify({ + query: "select:Read,Write", + count: 2, + notes: [], + tools: [ + { + name: "Read", + source: "native_registry", + description: "Read a file from disk", + always_visible: true, + }, + { + name: "Write", + source: "native_registry", + description: "Write content to a file", + always_visible: true, + }, + ], + }), + }, + startTime: new Date("2026-04-13T10:10:00.000Z"), + endTime: new Date("2026-04-13T10:10:01.000Z"), + }); + + act(() => { + const toggle = container.querySelector( + 'button[title="展开过程详情"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect( + container.querySelector('[data-testid="inline-tool-process-tool-search-result"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("找到工具:2 个"); + expect(container.textContent).toContain("查看文件"); + expect(container.textContent).toContain("保存文件"); + expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull(); + expect(container.textContent).not.toContain('"always_visible":true'); + expect(container.textContent).not.toContain("Read a file from disk"); + expect(container.textContent).not.toContain("查询:select:Read,Write"); + expect(container.textContent).not.toContain("原生工具"); + expect(container.textContent).not.toContain("默认可见"); + }); + + it("WebSearch 展开后应优先展示搜索结果列表", () => { + const { container } = renderTool({ + id: "tool-search-web-1", + name: "WebSearch", + arguments: JSON.stringify({ query: "AI Agent 最新热点" }), + status: "completed", + result: { + success: true, + output: [ + "Xinhua world news summary at 0030 GMT, March 13", + "https://example.com/xinhua", + "全球要闻摘要,覆盖国际局势与市场动态。", + "", + "Friday morning news: March 13, 2026 | WORLD - wng.org", + "https://example.com/wng", + "补充国际动态与区域冲突更新。", + ].join("\n"), + }, + startTime: new Date("2026-04-13T10:20:00.000Z"), + endTime: new Date("2026-04-13T10:20:01.000Z"), + }); + + act(() => { + const toggle = container.querySelector( + 'button[title="展开过程详情"]', + ) as HTMLButtonElement | null; + toggle?.click(); + }); + + expect( + document.body.querySelector( + '[aria-label="预览搜索结果:Xinhua world news summary at 0030 GMT, March 13"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain( + "Friday morning news: March 13, 2026 | WORLD - wng.org", + ); + expect(container.querySelector('[data-testid="markdown-renderer"]')).toBeNull(); + }); + + it("完成态过程卡不应重复展示执行完成与原始工具名", () => { + const { container } = renderTool({ + id: "tool-inline-ask-user-1", + name: "AskUserQuestion", + arguments: JSON.stringify({ question: "需要继续吗?" }), + status: "completed", + result: { + success: true, + output: "用户已确认继续。", + }, + startTime: new Date("2026-04-13T10:30:00.000Z"), + endTime: new Date("2026-04-13T10:30:01.000Z"), + }); + + expect(container.textContent).toContain("已收集 需要继续吗?"); + expect(container.textContent).not.toContain("执行完成"); + expect(container.textContent).not.toContain("Ask User Question"); + }); + + it("站点导出按钮副文案应优先展示短文件名", () => { + const onOpenSavedSiteContent = vi.fn(); + const { container } = renderTool( + { + id: "tool-inline-site-run-1", + name: "lime_site_run", + arguments: JSON.stringify({ + adapter_name: "x/article", + args: { url: "https://x.com/google/article/1" }, + }), + status: "completed", + result: { + success: true, + output: "ok", + metadata: { + tool_family: "site", + saved_content: { + content_id: "content-inline-site-1", + project_id: "project-inline-site-1", + title: "Google Cloud 周报", + markdown_relative_path: + "exports/social-article/google-cloud/index.md", + image_count: 3, + }, + saved_by: "context_project", + }, + }, + startTime: new Date("2026-04-13T10:40:00.000Z"), + endTime: new Date("2026-04-13T10:40:01.000Z"), + }, + { onOpenSavedSiteContent }, + ); + + expect(container.textContent).toContain("已保存到当前项目:Google Cloud 周报"); + expect(container.textContent).toContain("已导出 Markdown 文稿"); + expect(container.textContent).toContain("附带图片 3 张"); + + const openButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("在下方预览导出 Markdown"), + ) as HTMLButtonElement | undefined; + + expect(openButton).toBeDefined(); + expect(openButton?.textContent).toContain("index.md"); + expect(openButton?.textContent).not.toContain( + "exports/social-article/google-cloud/index.md", + ); + + act(() => { + openButton?.click(); + }); + + expect(onOpenSavedSiteContent).toHaveBeenCalledWith({ + projectId: "project-inline-site-1", + contentId: "content-inline-site-1", + title: "Google Cloud 周报", + preferredTarget: "project_file", + projectFile: { + relativePath: "exports/social-article/google-cloud/index.md", + }, + }); + }); +}); diff --git a/src/components/agent/chat/components/InlineToolProcessStep.tsx b/src/components/agent/chat/components/InlineToolProcessStep.tsx index 27e75f490..edbe9d27d 100644 --- a/src/components/agent/chat/components/InlineToolProcessStep.tsx +++ b/src/components/agent/chat/components/InlineToolProcessStep.tsx @@ -1,7 +1,10 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { open as openExternal } from "@tauri-apps/plugin-shell"; import { ChevronDown, ExternalLink, FileText, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarkdownRenderer } from "./MarkdownRenderer"; +import { SearchResultPreviewList } from "./SearchResultPreviewList"; +import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel"; import { extractLimeToolMetadataBlock, normalizeToolResultImages, @@ -11,18 +14,26 @@ import type { SiteSavedContentTarget } from "../types"; import { buildToolHeadline, getToolDisplayInfo, - humanizeToolName, + normalizeToolNameKey, parseToolCallArguments, resolveToolFilePath, resolveToolPrimarySubject, } from "../utils/toolDisplayInfo"; +import { + isUnifiedWebSearchToolName, + resolveSearchResultPreviewItemsFromText, +} from "../utils/searchResultPreview"; import { normalizeSiteToolResultSummary, - resolveSiteAdapterSourceLabel, + resolveSiteProjectTargetLabel, + resolveSiteSavedContentTargetDisplayName, resolveSiteSavedContentTargetRelativePath, - resolveSiteProjectSourceLabel, resolveSiteSavedContentTargetFromMetadata, } from "../utils/siteToolResultSummary"; +import { + normalizeToolSearchResultSummary, + resolveUserFacingToolSearchItemLabel, +} from "../utils/toolSearchResultSummary"; interface InlineToolProcessStepProps { toolCall: ToolCallState; @@ -68,6 +79,34 @@ function summarizeResultText(value: string): string | null { return `${singleLine.slice(0, 180).trim()}...`; } +function summarizeToolSearchPreview(value: ReturnType< + typeof normalizeToolSearchResultSummary +>): string | null { + if (!value) { + return null; + } + + const toolNames = value.tools + .slice(0, 2) + .map((item) => resolveUserFacingToolSearchItemLabel(item.name)) + .filter(Boolean); + const prefix = `找到工具 ${value.count} 个`; + + if (toolNames.length === 0) { + return prefix; + } + + return `${prefix} · ${toolNames.join(" · ")}`; +} + +function summarizeSearchResultPreview(resultCount: number): string | null { + if (resultCount <= 0) { + return null; + } + + return `找到 ${resultCount} 条搜索结果`; +} + function buildSiteNoticeLines(toolCall: ToolCallState): string[] { const summary = normalizeSiteToolResultSummary(toolCall.result?.metadata); if (!summary) { @@ -77,52 +116,35 @@ function buildSiteNoticeLines(toolCall: ToolCallState): string[] { const lines: string[] = []; const savedProjectId = summary.savedProjectId || summary.savedContent?.projectId || ""; - const savedSourceLabel = resolveSiteProjectSourceLabel(summary.savedBy || ""); + const savedProjectTarget = resolveSiteProjectTargetLabel({ + source: summary.savedBy, + projectId: savedProjectId || undefined, + }); if (summary.savedContent?.title) { - let line = `已保存:${summary.savedContent.title}`; - if (savedProjectId) { - line += ` · 项目 ${savedProjectId}`; - } - if (savedSourceLabel) { - line += ` · ${savedSourceLabel}`; - } - lines.push(line); + lines.push(`已保存到${savedProjectTarget}:${summary.savedContent.title}`); } if (summary.savedContent?.markdownRelativePath) { - lines.push(`Markdown:${summary.savedContent.markdownRelativePath}`); + lines.push("已导出 Markdown 文稿"); } if (typeof summary.savedContent?.imageCount === "number") { - const imageDir = summary.savedContent.imagesRelativeDir; - lines.push( - `图片:${summary.savedContent.imageCount} 张${ - imageDir ? ` · ${imageDir}` : "" - }`, - ); + lines.push(`附带图片 ${summary.savedContent.imageCount} 张`); } if (summary.saveSkippedProjectId) { - const skippedSourceLabel = resolveSiteProjectSourceLabel( - summary.saveSkippedBy || "", - ); - let line = `未写入项目 ${summary.saveSkippedProjectId}`; - if (skippedSourceLabel) { - line += ` · ${skippedSourceLabel}`; - } - lines.push(line); + const skippedProjectTarget = resolveSiteProjectTargetLabel({ + source: summary.saveSkippedBy, + projectId: summary.saveSkippedProjectId, + }); + lines.push(`未保存到${skippedProjectTarget}`); } if (summary.saveErrorMessage) { lines.push(`自动保存失败:${summary.saveErrorMessage}`); } - const adapterSourceLabel = resolveSiteAdapterSourceLabel(summary); - if (adapterSourceLabel) { - lines.push(`脚本来源:${adapterSourceLabel}`); - } - return lines; } @@ -167,15 +189,6 @@ export const InlineToolProcessStep: React.FC = ({ }), [subject, toolCall.name, toolDisplay], ); - const rawToolNameLabel = useMemo(() => { - if ( - toolDisplay.family === "generic" && - toolDisplay.label !== humanizeToolName(toolCall.name) - ) { - return humanizeToolName(toolCall.name); - } - return null; - }, [toolCall.name, toolDisplay.family, toolDisplay.label]); const resultText = useMemo(() => { const rawText = toolCall.result?.error || toolCall.result?.output || ""; return extractLimeToolMetadataBlock(rawText).text.trim(); @@ -188,12 +201,38 @@ export const InlineToolProcessStep: React.FC = ({ () => normalizeToolResultImages(toolCall.result?.images, resultText) || [], [resultText, toolCall.result?.images], ); + const isToolSearch = useMemo( + () => normalizeToolNameKey(toolCall.name) === "toolsearch", + [toolCall.name], + ); + const toolSearchSummary = useMemo( + () => (isToolSearch ? normalizeToolSearchResultSummary(resultText) : null), + [isToolSearch, resultText], + ); + const searchResultItems = useMemo(() => { + if (!isUnifiedWebSearchToolName(toolCall.name)) { + return []; + } + + return resolveSearchResultPreviewItemsFromText(resultText); + }, [resultText, toolCall.name]); + const structuredResultPreview = useMemo(() => { + if (toolSearchSummary) { + return summarizeToolSearchPreview(toolSearchSummary); + } + if (searchResultItems.length > 0) { + return summarizeSearchResultPreview(searchResultItems.length); + } + return resultPreview; + }, [resultPreview, searchResultItems.length, toolSearchSummary]); const savedSiteContentTarget = useMemo( () => resolveSiteSavedContentTargetFromMetadata(toolCall.result?.metadata), [toolCall.result?.metadata], ); - const savedSiteContentRelativePath = useMemo( - () => resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget), + const savedSiteContentDisplayName = useMemo( + () => + resolveSiteSavedContentTargetDisplayName(savedSiteContentTarget) || + resolveSiteSavedContentTargetRelativePath(savedSiteContentTarget), [savedSiteContentTarget], ); const siteNoticeLines = useMemo( @@ -209,31 +248,46 @@ export const InlineToolProcessStep: React.FC = ({ const hasDetails = Boolean(resultText) || resultImages.length > 0 || + searchResultItems.length > 0 || + Boolean(toolSearchSummary) || siteNoticeLines.length > 0 || Boolean(savedSiteContentTarget) || Boolean(skillTitle && skillTitle !== subject); + const handleOpenExternalUrl = useCallback(async (url: string) => { + try { + await openExternal(url); + } catch { + if (typeof window !== "undefined" && typeof window.open === "function") { + window.open(url, "_blank"); + } + } + }, []); + useEffect(() => { - if ( - toolCall.status === "running" || - isMessageStreaming || - siteNoticeLines.length > 0 - ) { + if (toolCall.status === "running" || siteNoticeLines.length > 0) { + setExpanded(true); + return; + } + + if (isMessageStreaming && !toolSearchSummary) { setExpanded(true); } - }, [isMessageStreaming, siteNoticeLines.length, toolCall.status]); - - const statusLabel = - toolCall.status === "running" - ? "执行中" - : toolCall.status === "failed" - ? "执行失败" - : "执行完成"; + }, [ + isMessageStreaming, + siteNoticeLines.length, + toolCall.status, + toolSearchSummary, + ]); const detailBadges = [ isPreload ? "系统预执行" : null, skillTitle && skillTitle !== subject ? `技能:${skillTitle}` : null, - statusLabel, + toolCall.status === "running" + ? "执行中" + : toolCall.status === "failed" + ? "执行失败" + : null, ].filter((value): value is string => Boolean(value)); return ( @@ -287,14 +341,9 @@ export const InlineToolProcessStep: React.FC = ({ {badge} ))}
- {rawToolNameLabel ? ( -
- {rawToolNameLabel} -
- ) : null} - {!expanded && resultPreview ? ( + {!expanded && structuredResultPreview ? (
- {resultPreview} + {structuredResultPreview}
) : null} @@ -361,9 +410,9 @@ export const InlineToolProcessStep: React.FC = ({ ? "在下方预览导出 Markdown" : "打开已保存内容"} - {savedSiteContentRelativePath ? ( + {savedSiteContentDisplayName ? ( - {savedSiteContentRelativePath} + {savedSiteContentDisplayName} ) : null} @@ -372,7 +421,24 @@ export const InlineToolProcessStep: React.FC = ({
) : null} - {resultText ? ( + {toolSearchSummary ? ( + + ) : null} + + {!toolSearchSummary && searchResultItems.length > 0 ? ( + + ) : null} + + {!toolSearchSummary && searchResultItems.length === 0 && resultText ? (
diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 33b898d73..567b6d14b 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -14,7 +14,6 @@ import type { BuiltinInputCommand } from "../../../skill-selection/builtinComman import { TeamSelector } from "./TeamSelector"; import { InputbarWorkflowStatusPanel } from "./InputbarWorkflowStatusPanel"; import { InputbarModelExtra } from "./InputbarModelExtra"; -import { InputbarPromptCacheNotice } from "./InputbarPromptCacheNotice"; import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice"; import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect"; import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect"; @@ -159,14 +158,10 @@ export const InputbarComposerSection: React.FC< currentPendingImages.length > 0 && Boolean(resolvedProviderType?.trim()) && Boolean(resolvedModel?.trim()); - const shouldShowPromptCacheNotice = Boolean(resolvedProviderType?.trim()); const resolvedTopExtra = - topExtra || shouldShowPromptCacheNotice || shouldShowVisionNotice ? ( + topExtra || shouldShowVisionNotice ? ( <> {topExtra} - {shouldShowPromptCacheNotice && resolvedProviderType ? ( - - ) : null} {shouldShowVisionNotice && resolvedProviderType && resolvedModel ? ( ({ ChatModelSelector: () =>
, })); -vi.mock("./components/InputbarPromptCacheNotice", () => ({ - InputbarPromptCacheNotice: (props: { providerType: string }) => ( -
{props.providerType}
- ), -})); - vi.mock("@/lib/dev-bridge", () => ({ safeInvoke: vi.fn(async () => []), })); @@ -828,7 +822,7 @@ describe("Inputbar", () => { expect(latestCall.leftExtra).toBeDefined(); }); - it("已选择 Provider 时应将 prompt cache 提示组件挂到输入区顶部", async () => { + it("已选择 Provider 时不应再将 prompt cache 提示组件常驻挂到输入区顶部", async () => { const { container } = renderInputbar({ providerType: "custom-provider-id", setProviderType: vi.fn(), @@ -843,8 +837,7 @@ describe("Inputbar", () => { expect( container.querySelector('[data-testid="inputbar-prompt-cache-warning"]'), - ).toBeTruthy(); - expect(container.textContent).toContain("custom-provider-id"); + ).toBeNull(); }); it("任务中心工作区应使用继续推进型输入提示", async () => { diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index f348a9318..d84a8c997 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -367,6 +367,57 @@ describe("MessageList", () => { ); }); + it("anthropic-compatible 自定义 Provider 存在缓存写入时不应再透传自动缓存提示", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-cache-write", + role: "assistant", + content: "本轮已完成。", + timestamp: now, + usage: { + input_tokens: 1_500, + output_tokens: 500, + cached_input_tokens: 0, + cache_creation_input_tokens: 256, + }, + }, + ]; + + mockUseConfiguredProviders.mockImplementation(() => ({ + providers: [ + { + key: "custom-provider-id", + label: "Kimi Anthropic", + registryId: "custom-provider-id", + type: "anthropic-compatible", + providerId: "custom-provider-id", + }, + ], + loading: false, + })); + mockFindConfiguredProviderBySelection.mockImplementation( + ( + providers: MockConfiguredProvider[], + selection?: string | null, + ): MockConfiguredProvider | null => + Array.isArray(providers) + ? (providers.find((provider) => provider.key === selection) ?? null) + : null, + ); + + const container = render(messages, { + providerType: "custom-provider-id", + }); + + expect(container.textContent).not.toContain("未声明自动缓存"); + expect(mockTokenUsageDisplay).toHaveBeenCalledWith( + expect.objectContaining({ + promptCacheNotice: undefined, + }), + ); + }); + it("图片任务消息卡应在聊天区渲染预览并支持展开图片画布", () => { const now = new Date(); const messages: Message[] = [ diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index dc5f94996..0c401a857 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -163,6 +163,16 @@ interface MessageListProps { providerType?: string; } +function resolvePromptCacheActivity(usage?: { + cached_input_tokens?: number; + cache_creation_input_tokens?: number; +}): number { + return ( + Math.max(0, usage?.cached_input_tokens ?? 0) + + Math.max(0, usage?.cache_creation_input_tokens ?? 0) + ); +} + function isDeferredTimelineItem(item: AgentThreadItem): boolean { return item.type === "file_artifact" || item.type === "turn_summary"; } @@ -426,7 +436,7 @@ const MessageListInner: React.FC = ({ msg.role === "assistant" && !msg.isThinking && msg.usage && - (msg.usage.cached_input_tokens ?? 0) <= 0, + resolvePromptCacheActivity(msg.usage) <= 0, ), ), [messages, providerType], @@ -886,7 +896,7 @@ const MessageListInner: React.FC = ({ + {template.verification_summary ? ( + + ) : null} +
{openableFilePath && onFileClick && ( @@ -920,11 +862,6 @@ export const ToolCallDisplay: React.FC = ({
{toolHeadline}
- {shouldShowRawToolName ? ( -
- {humanizeToolNameFromInfo(toolCall.name)} -
- ) : null}
@@ -979,9 +916,6 @@ export const ToolCallDisplay: React.FC = ({ {hasSearchResults && isExpanded && (
-
- {searchSemantic.label} -
= ({ className="rounded-md px-2 py-1 text-[11px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700" aria-label={ showRawSearchResultOutput - ? "收起搜索原始输出" - : "查看搜索原始输出" + ? "收起搜索文本详情" + : "查看搜索文本详情" } onClick={() => setShowRawSearchResultOutput((current) => !current) } > - {showRawSearchResultOutput ? "收起原始输出" : "查看原始输出"} + {showRawSearchResultOutput ? "收起文本详情" : "查看文本详情"}
) : null} @@ -1011,60 +945,10 @@ export const ToolCallDisplay: React.FC = ({ {toolSearchSummary && isExpanded ? (
-
- 匹配工具:{toolSearchSummary.count} 个 - {toolSearchSummary.query ? ( - 查询:{toolSearchSummary.query} - ) : null} - {typeof toolSearchSummary.totalDeferredTools === "number" ? ( - Deferred 总数:{toolSearchSummary.totalDeferredTools} - ) : null} -
- {toolSearchSummary.notes.length > 0 ? ( -
- {toolSearchSummary.notes.map((note, index) => ( -
{note}
- ))} -
- ) : null} - {toolSearchSummary.tools.length > 0 ? ( -
- {toolSearchSummary.tools.map((item) => { - const sourceLabel = resolveToolSearchItemSourceLabel(item); - const statusLabel = resolveToolSearchItemStatusLabel(item); - return ( -
-
- - {item.name} - - {sourceLabel ? ( - - {sourceLabel} - - ) : null} - {statusLabel ? ( - - {statusLabel} - - ) : null} -
- {item.description ? ( -
- {item.description} -
- ) : null} -
- ); - })} -
- ) : null} +
) : null} @@ -1109,8 +993,11 @@ export const ToolCallDisplay: React.FC = ({
) : null} {resultPath ? ( -
- {resultPath.label}: {resultPath.value} +
+ {resultPath.label}: {resultPath.displayValue}
) : null}
void; }) { const [expanded, setExpanded] = useState(true); - const semanticSummaries = summarizeSearchQuerySemantics( - toolCalls.map(extractSearchQueryLabelFromInfo), - ); const headline = buildToolGroupHeadlineFromInfo(toolCalls); const queryPreview = toolCalls .slice(0, 2) @@ -1392,15 +1276,6 @@ function SearchToolCallGroup({ )} /> - {semanticSummaries.length > 0 ? ( -
- {semanticSummaries.map((item) => ( - - {item.label} {item.count} - - ))} -
- ) : null} {expanded ? (
{toolCalls.map((toolCall, index) => ( diff --git a/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx b/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx new file mode 100644 index 000000000..c36746ef0 --- /dev/null +++ b/src/components/agent/chat/components/ToolSearchSummaryPanel.test.tsx @@ -0,0 +1,105 @@ +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ToolSearchSummaryPanel } from "./ToolSearchSummaryPanel"; + +interface RenderResult { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: RenderResult[] = []; + +function renderPanel( + summary: ComponentProps["summary"], +): RenderResult { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const rendered = { container, root }; + mountedRoots.push(rendered); + return rendered; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("ToolSearchSummaryPanel", () => { + it("应优先展示用户能看懂的工具标签,并隐藏内部状态标签", () => { + const { container } = renderPanel({ + query: "browser click", + count: 3, + notes: ["未命中任何 deferred 工具"], + tools: [ + { + name: "Read", + source: "native_registry", + alwaysVisible: true, + }, + { + name: "mcp__playwright__browser_click", + source: "extension", + extensionName: "mcp__playwright", + status: "deferred", + deferredLoading: true, + }, + { + name: "WebSearch", + source: "native_registry", + status: "loaded", + }, + ], + }); + + expect(container.textContent).toContain("找到工具:3 个"); + expect(container.textContent).toContain("查询:browser click"); + expect(container.textContent).toContain("查看文件"); + expect(container.textContent).toContain("页面点击"); + expect(container.textContent).toContain("搜索网页"); + expect(container.textContent).toContain("没有找到更多匹配工具"); + expect(container.textContent).not.toContain("Read"); + expect(container.textContent).not.toContain("mcp__playwright__browser_click"); + expect(container.textContent).not.toContain("WebSearch"); + expect(container.textContent).not.toContain("来源:"); + expect(container.textContent).not.toContain("状态:"); + expect(container.textContent).not.toContain("原生工具"); + expect(container.textContent).not.toContain("扩展工具"); + expect(container.textContent).not.toContain("已加载"); + expect(container.textContent).not.toContain("默认可见"); + expect(container.textContent).not.toContain("待加载"); + }); + + it("内部筛选语法查询不应直接展示给用户", () => { + const { container } = renderPanel({ + query: "select:Read,Write", + count: 2, + notes: [], + tools: [{ name: "Read" }, { name: "Write" }], + }); + + expect(container.textContent).toContain("找到工具:2 个"); + expect(container.textContent).not.toContain("查询:"); + expect(container.textContent).not.toContain("select:Read,Write"); + }); +}); diff --git a/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx b/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx new file mode 100644 index 000000000..18679475c --- /dev/null +++ b/src/components/agent/chat/components/ToolSearchSummaryPanel.tsx @@ -0,0 +1,88 @@ +import { + type ToolSearchResultSummary, + resolveUserFacingToolSearchItemLabel, +} from "../utils/toolSearchResultSummary"; + +interface ToolSearchSummaryPanelProps { + summary: ToolSearchResultSummary; + testId?: string; +} + +function shouldShowUserFacingQuery(query: string | undefined): boolean { + const normalized = query?.trim(); + if (!normalized) { + return false; + } + + return !/^(?:select|tool|tools|name|tag):/i.test(normalized); +} + +function resolveUserFacingToolSearchNote(note: string): string | null { + const trimmed = note.trim(); + if (!trimmed) { + return null; + } + + if (/未命中.*deferred/i.test(trimmed)) { + return "没有找到更多匹配工具"; + } + + if ( + /(?:always[_\s-]?visible|native[_\s-]?registry|extension[_\s-]?name|total[_\s-]?deferred|caller)/i.test( + trimmed, + ) + ) { + return null; + } + + return trimmed.replace(/\bdeferred\b/gi, "更多").trim(); +} + +export function ToolSearchSummaryPanel({ + summary, + testId, +}: ToolSearchSummaryPanelProps) { + const userFacingNotes = summary.notes + .map((note) => resolveUserFacingToolSearchNote(note)) + .filter((note): note is string => Boolean(note)); + + return ( +
+
+ 找到工具:{summary.count} 个 + {shouldShowUserFacingQuery(summary.query) ? ( + 查询:{summary.query} + ) : null} +
+ + {userFacingNotes.length > 0 ? ( +
+ {userFacingNotes.map((note, index) => ( +
{note}
+ ))} +
+ ) : null} + + {summary.tools.length > 0 ? ( +
+ {summary.tools.map((item) => { + const label = resolveUserFacingToolSearchItemLabel(item.name); + const rawName = item.name.trim(); + + return ( +
+ {label} +
+ ); + })} +
+ ) : null} +
+ ); +} + +export default ToolSearchSummaryPanel; diff --git a/src/components/agent/chat/components/buildGeneralWorkbenchWorkflowPanelProps.ts b/src/components/agent/chat/components/buildGeneralWorkbenchWorkflowPanelProps.ts index bb4f34e23..c8f33a394 100644 --- a/src/components/agent/chat/components/buildGeneralWorkbenchWorkflowPanelProps.ts +++ b/src/components/agent/chat/components/buildGeneralWorkbenchWorkflowPanelProps.ts @@ -58,6 +58,8 @@ export function buildGeneralWorkbenchWorkflowPanelProps({ onAddImage, onImportDocument, creationTaskEventsCount, + showBranchRecords: workflowPanelState.showBranchRecords, + onToggleBranchRecords: workflowPanelState.toggleBranchRecords, showCreationTasks: workflowPanelState.showCreationTasks, onToggleCreationTasks: workflowPanelState.toggleCreationTasks, groupedCreationTaskEvents: workflowPanelState.groupedCreationTaskEvents, diff --git a/src/components/agent/chat/components/generalWorkbenchExecLogData.ts b/src/components/agent/chat/components/generalWorkbenchExecLogData.ts index d0a366f30..d48359311 100644 --- a/src/components/agent/chat/components/generalWorkbenchExecLogData.ts +++ b/src/components/agent/chat/components/generalWorkbenchExecLogData.ts @@ -4,7 +4,7 @@ import type { ExecLogEntry, ExecLogEntryDetail, } from "./GeneralWorkbenchExecLog"; -import { resolveToolDisplayLabel } from "../utils/toolDisplayInfo"; +import { resolveUserFacingToolDisplayLabel } from "../utils/toolDisplayInfo"; import type { GeneralWorkbenchActivityLogGroup, GeneralWorkbenchCreationTaskGroup, @@ -115,7 +115,7 @@ function buildToolCallEntry( return { id: `${messageId}-tc-${toolCall.id}-${index}`, type: "tool", - typeLabel: resolveToolDisplayLabel(toolCall.name), + typeLabel: resolveUserFacingToolDisplayLabel(toolCall.name), content: argsPreview || toolCall.name, meta: resultMeta, timestamp: toolCall.startTime || fallbackTimestamp, @@ -141,7 +141,7 @@ function buildRunEntry( .map((step) => step.name?.trim() || step.id?.trim() || "") .filter((step): step is string => Boolean(step)); const allowedTools = (skillDetail?.allowed_tools || []) - .map((toolName) => resolveToolDisplayLabel(toolName)) + .map((toolName) => resolveUserFacingToolDisplayLabel(toolName)) .filter((toolName): toolName is string => Boolean(toolName)); const detail: ExecLogEntryDetail | undefined = diff --git a/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts b/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts index c13db412a..1a5d85409 100644 --- a/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts +++ b/src/components/agent/chat/components/useGeneralWorkbenchWorkflowPanelState.ts @@ -31,8 +31,10 @@ export interface GeneralWorkbenchWorkflowPanelState { runMetadataText: string; runMetadataSummary: GeneralWorkbenchRunMetadataSummary; showActivityLogs: boolean; + showBranchRecords: boolean; showCreationTasks: boolean; toggleActivityLogs: () => void; + toggleBranchRecords: () => void; toggleCreationTasks: () => void; } @@ -43,6 +45,7 @@ export function useGeneralWorkbenchWorkflowPanelState({ activeRunMetadata, }: UseGeneralWorkbenchWorkflowPanelStateParams): GeneralWorkbenchWorkflowPanelState { const [showActivityLogs, setShowActivityLogs] = useState(false); + const [showBranchRecords, setShowBranchRecords] = useState(false); const [showCreationTasks, setShowCreationTasks] = useState(false); const completedSteps = useMemo( @@ -84,6 +87,10 @@ export function useGeneralWorkbenchWorkflowPanelState({ setShowActivityLogs((previous) => !previous); }, []); + const toggleBranchRecords = useCallback(() => { + setShowBranchRecords((previous) => !previous); + }, []); + const toggleCreationTasks = useCallback(() => { setShowCreationTasks((previous) => !previous); }, []); @@ -97,8 +104,10 @@ export function useGeneralWorkbenchWorkflowPanelState({ runMetadataText, runMetadataSummary, showActivityLogs, + showBranchRecords, showCreationTasks, toggleActivityLogs, + toggleBranchRecords, toggleCreationTasks, }; } diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index 6e4e77d54..946340f53 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -173,6 +173,7 @@ describe("agentChatHistory", () => { input_tokens: 12000, output_tokens: 19000, cached_input_tokens: 4000, + cache_creation_input_tokens: 1200, }, }, ], @@ -184,6 +185,7 @@ describe("agentChatHistory", () => { input_tokens: 12000, output_tokens: 19000, cached_input_tokens: 4000, + cache_creation_input_tokens: 1200, }); }); @@ -436,6 +438,7 @@ describe("agentChatHistory", () => { input_tokens: 20480, output_tokens: 10240, cached_input_tokens: 8192, + cache_creation_input_tokens: 2048, }, }, ]; @@ -463,6 +466,7 @@ describe("agentChatHistory", () => { input_tokens: 20480, output_tokens: 10240, cached_input_tokens: 8192, + cache_creation_input_tokens: 2048, }); }); diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index eac0846ce..e0a0bed98 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -76,6 +76,9 @@ const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => { const outputTokens = (usage as { output_tokens?: unknown }).output_tokens; const cachedInputTokens = (usage as { cached_input_tokens?: unknown }) .cached_input_tokens; + const cacheCreationInputTokens = ( + usage as { cache_creation_input_tokens?: unknown } + ).cache_creation_input_tokens; if ( typeof inputTokens !== "number" || typeof outputTokens !== "number" || @@ -96,6 +99,12 @@ const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => { cachedInputTokens >= 0 ? cachedInputTokens : undefined, + cache_creation_input_tokens: + typeof cacheCreationInputTokens === "number" && + Number.isFinite(cacheCreationInputTokens) && + cacheCreationInputTokens >= 0 + ? cacheCreationInputTokens + : undefined, }; }; @@ -904,7 +913,7 @@ const buildAssistantHydrationSignature = (message: Message): string => { const buildHistoryMessageSignature = (message: Message): string => { const usageSignature = message.usage - ? `${message.usage.input_tokens}:${message.usage.output_tokens}:${message.usage.cached_input_tokens ?? ""}` + ? `${message.usage.input_tokens}:${message.usage.output_tokens}:${message.usage.cached_input_tokens ?? ""}:${message.usage.cache_creation_input_tokens ?? ""}` : ""; return [ message.role, diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts index d8a9c87b6..e9b2b8706 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts @@ -49,6 +49,7 @@ describe("agentStreamRuntimeHandler", () => { input_tokens: 12_000, output_tokens: 19_000, cached_input_tokens: 8_000, + cache_creation_input_tokens: 1_200, }, } as AgentEvent, requestState: { @@ -102,6 +103,7 @@ describe("agentStreamRuntimeHandler", () => { input_tokens: 12_000, output_tokens: 19_000, cached_input_tokens: 8_000, + cache_creation_input_tokens: 1_200, }, }); }); diff --git a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx deleted file mode 100644 index 197af3110..000000000 --- a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - buildInitialDispatchKey, - useBootstrapDispatchPreview, -} from "./useBootstrapDispatchPreview"; - -interface HookHarness { - getValue: () => ReturnType; - rerender: ( - props?: Partial<{ - initialUserPrompt?: string; - initialUserImages?: Array<{ data: string; mediaType: string }>; - messagesCount: number; - isSending: boolean; - queuedTurnCount: number; - consumedInitialPromptKey?: string | null; - shouldUseCompactGeneralWorkbench?: boolean; - }>, - ) => void; - unmount: () => void; -} - -function mountHook( - initialProps?: Partial<{ - initialUserPrompt?: string; - initialUserImages?: Array<{ data: string; mediaType: string }>; - messagesCount: number; - isSending: boolean; - queuedTurnCount: number; - consumedInitialPromptKey?: string | null; - shouldUseCompactGeneralWorkbench?: boolean; - }>, -): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - let hookValue: ReturnType | null = null; - let currentProps = { - initialUserPrompt: "", - initialUserImages: [], - messagesCount: 0, - isSending: false, - queuedTurnCount: 0, - consumedInitialPromptKey: null, - shouldUseCompactGeneralWorkbench: false, - ...initialProps, - }; - - function TestComponent() { - hookValue = useBootstrapDispatchPreview(currentProps); - return null; - } - - const render = ( - nextProps?: Partial<{ - initialUserPrompt?: string; - initialUserImages?: Array<{ data: string; mediaType: string }>; - messagesCount: number; - isSending: boolean; - queuedTurnCount: number; - consumedInitialPromptKey?: string | null; - shouldUseCompactGeneralWorkbench?: boolean; - }>, - ) => { - currentProps = { - ...currentProps, - ...nextProps, - }; - act(() => { - root.render(); - }); - }; - - render(); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - rerender: render, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - }; -} - -describe("useBootstrapDispatchPreview", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - }); - - afterEach(() => { - // noop - }); - - it("应生成稳定的 initialDispatchKey", () => { - expect( - buildInitialDispatchKey("写一篇文章", [ - { data: "abcdef1234567890", mediaType: "image/png" }, - ]), - ).toContain("写一篇文章"); - }); - - it("发送中且无消息时应展示 bootstrap 预览消息", () => { - const harness = mountHook({ - initialUserPrompt: "请开始处理这个任务", - isSending: true, - }); - - try { - const value = harness.getValue(); - expect(value.initialDispatchKey).toBeTruthy(); - expect(value.shouldShowBootstrapDispatchPreview).toBe(true); - expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2); - expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe( - "请开始处理这个任务", - ); - } finally { - harness.unmount(); - } - }); - - it("有真实消息后应清空 bootstrap 预览", () => { - const harness = mountHook({ - initialUserPrompt: "请开始处理这个任务", - isSending: true, - }); - - try { - expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength( - 2, - ); - - harness.rerender({ - messagesCount: 1, - isSending: false, - }); - - expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength( - 0, - ); - expect(harness.getValue().shouldShowBootstrapDispatchPreview).toBe(false); - } finally { - harness.unmount(); - } - }); - - it("初始意图已标记消费后,只要仍在排队中就继续展示 bootstrap 预览", () => { - const prompt = "帮我把这篇文章发布到微信公众号后台"; - const dispatchKey = buildInitialDispatchKey(prompt, [])!; - const harness = mountHook({ - initialUserPrompt: prompt, - consumedInitialPromptKey: dispatchKey, - queuedTurnCount: 1, - }); - - try { - const value = harness.getValue(); - expect(value.shouldShowBootstrapDispatchPreview).toBe(true); - expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2); - expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe(prompt); - expect(value.bootstrapDispatchPreviewMessages[1]?.content).toBe( - "正在开始处理任务…", - ); - } finally { - harness.unmount(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts deleted file mode 100644 index 9250116ad..000000000 --- a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import type { Message, MessageImage } from "../types"; - -export interface InitialDispatchPreviewSnapshot { - key: string; - prompt?: string; - images: MessageImage[]; -} - -interface UseBootstrapDispatchPreviewOptions { - initialUserPrompt?: string; - initialUserImages?: MessageImage[]; - messagesCount: number; - isSending: boolean; - queuedTurnCount: number; - consumedInitialPromptKey?: string | null; - shouldUseCompactGeneralWorkbench?: boolean; -} - -export function buildInitialDispatchKey( - prompt?: string, - images?: MessageImage[], -): string | null { - const normalizedPrompt = (prompt || "").trim(); - const normalizedImages = images || []; - - if (!normalizedPrompt && normalizedImages.length === 0) { - return null; - } - - const imageSignature = normalizedImages - .map( - (image, index) => - `${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`, - ) - .join("|"); - - return `${normalizedPrompt}::${imageSignature}`; -} - -export function buildInitialDispatchPreviewMessages( - dispatchKey: string, - prompt?: string, - images?: MessageImage[], - assistantPreviewText?: string, -): Message[] { - const normalizedPrompt = (prompt || "").trim(); - const normalizedImages = images || []; - - if (!normalizedPrompt && normalizedImages.length === 0) { - return []; - } - - const timestamp = new Date(); - const normalizedAssistantPreviewText = - assistantPreviewText?.trim() || "正在开始处理任务…"; - const isAssistantThinking = - normalizedAssistantPreviewText === "正在开始处理任务…"; - - return [ - { - id: `initial-dispatch:${dispatchKey}:user`, - role: "user", - content: normalizedPrompt, - images: normalizedImages.length > 0 ? normalizedImages : undefined, - timestamp, - }, - { - id: `initial-dispatch:${dispatchKey}:assistant`, - role: "assistant", - content: normalizedAssistantPreviewText, - timestamp: new Date(timestamp.getTime() + 1), - isThinking: isAssistantThinking, - }, - ]; -} - -export function useBootstrapDispatchPreview({ - initialUserPrompt, - initialUserImages, - messagesCount, - isSending, - queuedTurnCount, - consumedInitialPromptKey, - shouldUseCompactGeneralWorkbench = false, -}: UseBootstrapDispatchPreviewOptions) { - const initialDispatchKey = useMemo( - () => buildInitialDispatchKey(initialUserPrompt, initialUserImages), - [initialUserImages, initialUserPrompt], - ); - const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] = - useState(null); - - useEffect(() => { - if (!initialDispatchKey) { - return; - } - - setBootstrapDispatchSnapshot({ - key: initialDispatchKey, - prompt: initialUserPrompt, - images: initialUserImages || [], - }); - }, [initialDispatchKey, initialUserImages, initialUserPrompt]); - - useEffect(() => { - if (messagesCount > 0) { - setBootstrapDispatchSnapshot(null); - return; - } - - if (!initialDispatchKey && !isSending && queuedTurnCount === 0) { - setBootstrapDispatchSnapshot(null); - } - }, [initialDispatchKey, isSending, messagesCount, queuedTurnCount]); - - const activeBootstrapDispatch = useMemo(() => { - if ( - initialDispatchKey && - ((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0) - ) { - return { - key: initialDispatchKey, - prompt: initialUserPrompt, - images: initialUserImages || [], - }; - } - - return bootstrapDispatchSnapshot; - }, [ - bootstrapDispatchSnapshot, - initialDispatchKey, - initialUserImages, - initialUserPrompt, - ]); - - const isBootstrapDispatchPending = - activeBootstrapDispatch !== null && - consumedInitialPromptKey !== activeBootstrapDispatch.key; - const shouldShowBootstrapDispatchPreview = - !shouldUseCompactGeneralWorkbench && - Boolean(activeBootstrapDispatch) && - messagesCount === 0 && - (isSending || queuedTurnCount > 0); - const bootstrapDispatchPreviewMessages = useMemo(() => { - if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) { - return [] as Message[]; - } - - return buildInitialDispatchPreviewMessages( - activeBootstrapDispatch.key, - activeBootstrapDispatch.prompt, - activeBootstrapDispatch.images, - ); - }, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]); - - return { - initialDispatchKey, - activeBootstrapDispatch, - isBootstrapDispatchPending, - shouldShowBootstrapDispatchPreview, - bootstrapDispatchPreviewMessages, - }; -} diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPrompt.test.tsx b/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPrompt.test.tsx deleted file mode 100644 index c03d769b5..000000000 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPrompt.test.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { GeneralWorkbenchRunState as BackendGeneralWorkbenchRunState } from "@/lib/api/executionRun"; -import { - useGeneralWorkbenchEntryPrompt, - type GeneralWorkbenchResumeWorkflowState, -} from "./useGeneralWorkbenchEntryPrompt"; - -interface HookHarness { - getValue: () => ReturnType; - rerender: (props?: Partial) => void; - unmount: () => void; - onHydrateInitialPrompt: ReturnType; -} - -interface HookProps { - activeTheme: string; - contentId?: string; - sessionId?: string; - isThemeWorkbench: boolean; - autoRunInitialPromptOnMount: boolean; - shouldUseCompactGeneralWorkbench: boolean; - messagesCount: number; - initialDispatchKey: string | null; - initialUserPrompt?: string; - initialUserImages?: Array<{ data: string; mediaType: string }>; - consumedInitialPromptKey?: string | null; -} - -function mountHook(initialProps?: Partial): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const onHydrateInitialPrompt = vi.fn(); - - const loadWorkflow = vi.fn( - async ( - _contentId: string, - ): Promise => null, - ); - const loadRunState = vi.fn( - async ( - _sessionId: string, - ): Promise => null, - ); - - let hookValue: ReturnType | null = - null; - let currentProps: HookProps = { - activeTheme: "general", - contentId: "content-1", - sessionId: "session-1", - isThemeWorkbench: true, - autoRunInitialPromptOnMount: false, - shouldUseCompactGeneralWorkbench: false, - messagesCount: 0, - initialDispatchKey: null, - initialUserPrompt: "", - initialUserImages: [], - consumedInitialPromptKey: null, - ...initialProps, - }; - - function TestComponent() { - hookValue = useGeneralWorkbenchEntryPrompt({ - ...currentProps, - onHydrateInitialPrompt, - loadWorkflow, - loadRunState, - }); - return null; - } - - const render = (nextProps?: Partial) => { - currentProps = { - ...currentProps, - ...nextProps, - }; - act(() => { - root.render(); - }); - }; - - render(); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - rerender: render, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - onHydrateInitialPrompt, - }; -} - -async function flushEffects(times = 4) { - for (let index = 0; index < times; index += 1) { - await act(async () => { - await Promise.resolve(); - }); - } -} - -describe("useGeneralWorkbenchEntryPrompt", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("主题工作台初始意图应先进入预填提示态", async () => { - const harness = mountHook({ - initialDispatchKey: "initial-dispatch", - initialUserPrompt: "请先生成内容主稿", - }); - - try { - await flushEffects(); - expect(harness.onHydrateInitialPrompt).toHaveBeenCalledWith( - "请先生成内容主稿", - "initial-dispatch", - ); - expect(harness.getValue().generalWorkbenchEntryPrompt).toMatchObject({ - kind: "initial_prompt", - prompt: "请先生成内容主稿", - }); - } finally { - harness.unmount(); - } - }); - - it("启用自动执行时不应进入预填提示态", async () => { - const harness = mountHook({ - initialDispatchKey: "initial-dispatch", - initialUserPrompt: "请先生成内容主稿", - autoRunInitialPromptOnMount: true, - }); - - try { - await flushEffects(); - expect(harness.onHydrateInitialPrompt).not.toHaveBeenCalled(); - expect(harness.getValue().generalWorkbenchEntryPrompt).toBeNull(); - } finally { - harness.unmount(); - } - }); - - it("无初始意图时应基于 run-state 生成 resume prompt", async () => { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const onHydrateInitialPrompt = vi.fn(); - const loadRunState = vi.fn( - async ( - _sessionId: string, - ): Promise => ({ - run_state: "auto_running", - current_gate_key: "write_mode", - queue_items: [ - { - run_id: "run-1", - title: "撰写主稿", - gate_key: "write_mode", - status: "running", - source: "skill", - source_ref: null, - started_at: new Date().toISOString(), - }, - ], - latest_terminal: null, - recent_terminals: [], - updated_at: new Date().toISOString(), - }), - ); - const hookValueRef: { - current: ReturnType | null; - } = { current: null }; - - function TestComponent() { - hookValueRef.current = useGeneralWorkbenchEntryPrompt({ - activeTheme: "general", - contentId: "content-1", - sessionId: "session-1", - isThemeWorkbench: true, - autoRunInitialPromptOnMount: false, - shouldUseCompactGeneralWorkbench: false, - messagesCount: 0, - initialDispatchKey: null, - initialUserPrompt: "", - initialUserImages: [], - consumedInitialPromptKey: null, - onHydrateInitialPrompt, - loadRunState, - }); - return null; - } - - act(() => { - root.render(); - }); - - try { - await flushEffects(); - expect(loadRunState).toHaveBeenCalledWith("session-1"); - expect(hookValueRef.current?.generalWorkbenchEntryPrompt).toMatchObject({ - kind: "resume", - title: "发现上次未完成任务", - description: expect.stringContaining("撰写主稿"), - }); - expect(hookValueRef.current?.generalWorkbenchEntryCheckPending).toBe( - false, - ); - } finally { - act(() => { - root.unmount(); - }); - container.remove(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPrompt.ts b/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPrompt.ts deleted file mode 100644 index 8d3df9302..000000000 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPrompt.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - executionRunGetGeneralWorkbenchState, - type GeneralWorkbenchRunState, - type GeneralWorkbenchRunTerminalItem, - type GeneralWorkbenchRunTodoItem, -} from "@/lib/api/executionRun"; -import type { MessageImage } from "../types"; - -export interface GeneralWorkbenchEntryPromptState { - kind: "initial_prompt" | "resume"; - signature: string; - title: string; - description: string; - actionLabel: string; - prompt: string; -} - -export interface GeneralWorkbenchResumeWorkflowStep { - id: string; - title: string; - status: "pending" | "active" | "completed" | "skipped" | "error"; - result?: unknown; -} - -export interface GeneralWorkbenchResumeWorkflowState { - id: string; - current_step_index: number; - updated_at: number; - steps: GeneralWorkbenchResumeWorkflowStep[]; -} - -interface UseGeneralWorkbenchEntryPromptOptions { - activeTheme: string; - contentId?: string; - sessionId?: string; - isThemeWorkbench: boolean; - autoRunInitialPromptOnMount: boolean; - shouldUseCompactGeneralWorkbench: boolean; - messagesCount: number; - initialDispatchKey: string | null; - initialUserPrompt?: string; - initialUserImages?: MessageImage[]; - consumedInitialPromptKey?: string | null; - onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void; - loadWorkflow?: ( - contentId: string, - ) => Promise; - loadRunState?: ( - sessionId: string, - ) => Promise; -} - -const defaultLoadGeneralWorkbenchRunState = (sessionId: string) => - executionRunGetGeneralWorkbenchState(sessionId, 3); - -function resolveGeneralWorkbenchGateLabel( - gateKey?: GeneralWorkbenchRunTodoItem["gate_key"], -): string | null { - switch (gateKey) { - case "topic_select": - return "选题确认"; - case "write_mode": - return "写作推进"; - case "publish_confirm": - return "发布确认"; - case null: - case undefined: - default: - return null; - } -} - -function hasWorkflowMeaningfulProgress( - workflow: GeneralWorkbenchResumeWorkflowState | null, -): boolean { - if (!workflow) { - return false; - } - - if (workflow.current_step_index > 0) { - return true; - } - - return workflow.steps.some( - (step) => - step.status === "completed" || - step.status === "skipped" || - step.status === "error" || - Boolean(step.result), - ); -} - -export function buildGeneralWorkbenchResumePromptFromWorkflow( - workflow: GeneralWorkbenchResumeWorkflowState | null, -): GeneralWorkbenchEntryPromptState | null { - if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) { - return null; - } - - const hasPendingStep = workflow.steps.some( - (step) => step.status !== "completed" && step.status !== "skipped", - ); - if (!hasPendingStep) { - return null; - } - - const activeStep = - workflow.steps.find( - (step) => - step.status === "active" || - step.status === "pending" || - step.status === "error", - ) || workflow.steps[workflow.current_step_index]; - const stepTitle = activeStep?.title?.trim() || "当前创作阶段"; - - return { - kind: "resume", - signature: `workflow:${workflow.id}:${workflow.updated_at}:${activeStep?.id || ""}`, - title: "发现上次未完成任务", - description: `检测到当前文稿上次停留在“${stepTitle}”,可以直接衔接已有进度继续。`, - actionLabel: "继续上次任务", - prompt: `请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“${stepTitle}”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。`, - }; -} - -function resolveGeneralWorkbenchPendingRunCandidate( - state: GeneralWorkbenchRunState | null, -): GeneralWorkbenchRunTodoItem | GeneralWorkbenchRunTerminalItem | null { - if (!state) { - return null; - } - - const activeQueueItem = (state.queue_items || []).find((item) => - ["queued", "running", "error", "timeout"].includes(item.status), - ); - if (activeQueueItem) { - return activeQueueItem; - } - - if ( - state.latest_terminal && - ["queued", "running", "error", "timeout"].includes( - state.latest_terminal.status, - ) - ) { - return state.latest_terminal; - } - - return null; -} - -export function buildGeneralWorkbenchResumePromptFromRunState( - state: GeneralWorkbenchRunState | null, -): GeneralWorkbenchEntryPromptState | null { - const pendingRun = resolveGeneralWorkbenchPendingRunCandidate(state); - if (!pendingRun) { - return null; - } - - const runTitle = pendingRun.title?.trim() || "最近一次创作任务"; - const gateLabel = resolveGeneralWorkbenchGateLabel(pendingRun.gate_key); - const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : ""; - - return { - kind: "resume", - signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`, - title: "发现上次未完成任务", - description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`, - actionLabel: "继续上次任务", - prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`, - }; -} - -export function useGeneralWorkbenchEntryPrompt({ - activeTheme, - contentId, - sessionId, - isThemeWorkbench, - autoRunInitialPromptOnMount, - shouldUseCompactGeneralWorkbench, - messagesCount, - initialDispatchKey, - initialUserPrompt, - initialUserImages, - consumedInitialPromptKey, - onHydrateInitialPrompt, - loadWorkflow, - loadRunState = defaultLoadGeneralWorkbenchRunState, -}: UseGeneralWorkbenchEntryPromptOptions) { - const [generalWorkbenchEntryPrompt, setGeneralWorkbenchEntryPrompt] = - useState(null); - const [ - generalWorkbenchEntryCheckPending, - setGeneralWorkbenchEntryCheckPending, - ] = useState(false); - const hydratedPromptSignatureRef = useRef(null); - const dismissedPromptSignatureRef = useRef(null); - - useEffect(() => { - hydratedPromptSignatureRef.current = null; - dismissedPromptSignatureRef.current = null; - setGeneralWorkbenchEntryPrompt(null); - setGeneralWorkbenchEntryCheckPending(false); - }, [activeTheme, contentId, initialDispatchKey]); - - useEffect(() => { - if (shouldUseCompactGeneralWorkbench) { - return; - } - - const pendingInitialPrompt = (initialUserPrompt || "").trim(); - const pendingInitialImages = initialUserImages || []; - if ( - !isThemeWorkbench || - autoRunInitialPromptOnMount || - !contentId || - !initialDispatchKey || - !pendingInitialPrompt || - pendingInitialImages.length > 0 || - messagesCount > 0 - ) { - return; - } - - if ( - consumedInitialPromptKey === initialDispatchKey || - hydratedPromptSignatureRef.current === initialDispatchKey - ) { - return; - } - - hydratedPromptSignatureRef.current = initialDispatchKey; - onHydrateInitialPrompt(pendingInitialPrompt, initialDispatchKey); - setGeneralWorkbenchEntryPrompt({ - kind: "initial_prompt", - signature: initialDispatchKey, - title: "已恢复待执行创作意图", - description: "进入页面后不会自动开始生成,确认后再继续。", - actionLabel: "继续生成", - prompt: pendingInitialPrompt, - }); - }, [ - consumedInitialPromptKey, - contentId, - initialDispatchKey, - initialUserImages, - initialUserPrompt, - isThemeWorkbench, - messagesCount, - onHydrateInitialPrompt, - autoRunInitialPromptOnMount, - shouldUseCompactGeneralWorkbench, - ]); - - useEffect(() => { - if (shouldUseCompactGeneralWorkbench) { - setGeneralWorkbenchEntryCheckPending(false); - return; - } - - if ( - !isThemeWorkbench || - !contentId || - !sessionId || - messagesCount > 0 || - Boolean(initialDispatchKey) - ) { - setGeneralWorkbenchEntryCheckPending(false); - return; - } - - let disposed = false; - setGeneralWorkbenchEntryCheckPending(true); - - void (async () => { - try { - const [workflow, backendState] = await Promise.all([ - loadWorkflow ? loadWorkflow(contentId).catch(() => null) : null, - loadRunState(sessionId).catch(() => null), - ]); - - if (disposed) { - return; - } - - const nextPrompt = - buildGeneralWorkbenchResumePromptFromWorkflow(workflow) ?? - buildGeneralWorkbenchResumePromptFromRunState(backendState); - - if (!nextPrompt) { - setGeneralWorkbenchEntryPrompt((current) => - current?.kind === "resume" ? null : current, - ); - return; - } - - if (dismissedPromptSignatureRef.current === nextPrompt.signature) { - return; - } - - setGeneralWorkbenchEntryPrompt((current) => - current?.kind === "initial_prompt" ? current : nextPrompt, - ); - } finally { - if (!disposed) { - setGeneralWorkbenchEntryCheckPending(false); - } - } - })(); - - return () => { - disposed = true; - }; - }, [ - contentId, - initialDispatchKey, - isThemeWorkbench, - loadRunState, - loadWorkflow, - messagesCount, - sessionId, - shouldUseCompactGeneralWorkbench, - ]); - - const clearGeneralWorkbenchEntryPrompt = useCallback(() => { - setGeneralWorkbenchEntryPrompt(null); - }, []); - - const dismissGeneralWorkbenchEntryPrompt = useCallback( - (options?: { - consumeInitialPrompt?: boolean; - onConsumeInitialPrompt?: () => void; - }) => { - setGeneralWorkbenchEntryPrompt((current) => { - if (!current) { - return current; - } - - if ( - current.kind === "initial_prompt" && - options?.consumeInitialPrompt && - initialDispatchKey - ) { - options.onConsumeInitialPrompt?.(); - } else { - dismissedPromptSignatureRef.current = current.signature; - } - - return null; - }); - }, - [initialDispatchKey], - ); - - return { - generalWorkbenchEntryPrompt, - generalWorkbenchEntryCheckPending, - clearGeneralWorkbenchEntryPrompt, - dismissGeneralWorkbenchEntryPrompt, - }; -} diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPromptActions.test.tsx b/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPromptActions.test.tsx deleted file mode 100644 index 649a93418..000000000 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPromptActions.test.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useGeneralWorkbenchEntryPromptActions } from "./useGeneralWorkbenchEntryPromptActions"; -import type { GeneralWorkbenchEntryPromptState } from "./useGeneralWorkbenchEntryPrompt"; - -interface HookProps { - generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null; - input: string; - initialDispatchKey: string | null; -} - -interface HookHarness { - getValue: () => ReturnType; - rerender: (props?: Partial) => void; - unmount: () => void; - onContinuePrompt: ReturnType; - dismissGeneralWorkbenchEntryPrompt: ReturnType; - onConsumeInitialPrompt: ReturnType; - onInputChange: ReturnType; - onRequirePrompt: ReturnType; -} - -function mountHook(initialProps?: Partial): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - const onContinuePrompt = vi.fn(async () => undefined); - const dismissGeneralWorkbenchEntryPrompt = vi.fn(); - const onConsumeInitialPrompt = vi.fn(); - const onInputChange = vi.fn(); - const onRequirePrompt = vi.fn(); - - let hookValue: ReturnType< - typeof useGeneralWorkbenchEntryPromptActions - > | null = null; - let currentProps: HookProps = { - generalWorkbenchEntryPrompt: null, - input: "", - initialDispatchKey: null, - ...initialProps, - }; - - function TestComponent() { - hookValue = useGeneralWorkbenchEntryPromptActions({ - ...currentProps, - onContinuePrompt, - dismissGeneralWorkbenchEntryPrompt, - onConsumeInitialPrompt, - onInputChange, - onRequirePrompt, - }); - return null; - } - - const render = (nextProps?: Partial) => { - currentProps = { - ...currentProps, - ...nextProps, - }; - act(() => { - root.render(); - }); - }; - - render(); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - rerender: render, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - onContinuePrompt, - dismissGeneralWorkbenchEntryPrompt, - onConsumeInitialPrompt, - onInputChange, - onRequirePrompt, - }; -} - -describe("useGeneralWorkbenchEntryPromptActions", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("继续时应优先发送当前输入,没有输入时回退到提示文案", async () => { - const harness = mountHook({ - generalWorkbenchEntryPrompt: { - kind: "initial_prompt", - signature: "dispatch-1", - title: "已恢复待执行创作意图", - description: "desc", - actionLabel: "继续生成", - prompt: "请先生成主稿", - }, - input: "", - initialDispatchKey: "dispatch-1", - }); - - try { - await act(async () => { - await harness.getValue().handleContinueGeneralWorkbenchEntryPrompt(); - }); - expect(harness.onContinuePrompt).toHaveBeenCalledWith("请先生成主稿"); - - harness.rerender({ - input: "我已经补充了额外要求", - }); - - await act(async () => { - await harness.getValue().handleContinueGeneralWorkbenchEntryPrompt(); - }); - expect(harness.onContinuePrompt).toHaveBeenLastCalledWith( - "我已经补充了额外要求", - ); - } finally { - harness.unmount(); - } - }); - - it("继续时没有任何可发送内容应提示补充", async () => { - const harness = mountHook({ - generalWorkbenchEntryPrompt: { - kind: "resume", - signature: "resume-1", - title: "发现上次未完成任务", - description: "desc", - actionLabel: "继续任务", - prompt: " ", - }, - input: " ", - }); - - try { - await act(async () => { - await harness.getValue().handleContinueGeneralWorkbenchEntryPrompt(); - }); - expect(harness.onRequirePrompt).toHaveBeenCalledTimes(1); - expect(harness.onContinuePrompt).not.toHaveBeenCalled(); - } finally { - harness.unmount(); - } - }); - - it("重新开始初始提示时应消费意图并清空输入", () => { - const harness = mountHook({ - generalWorkbenchEntryPrompt: { - kind: "initial_prompt", - signature: "dispatch-1", - title: "已恢复待执行创作意图", - description: "desc", - actionLabel: "继续生成", - prompt: "请先生成主稿", - }, - initialDispatchKey: "dispatch-1", - input: "已有内容", - }); - - try { - act(() => { - harness.getValue().handleRestartGeneralWorkbenchEntryPrompt(); - }); - - expect(harness.dismissGeneralWorkbenchEntryPrompt).toHaveBeenCalledTimes( - 1, - ); - const options = - harness.dismissGeneralWorkbenchEntryPrompt.mock.calls[0]?.[0]; - expect(options?.consumeInitialPrompt).toBe(true); - options?.onConsumeInitialPrompt?.(); - expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1"); - expect(harness.onInputChange).toHaveBeenCalledWith(""); - } finally { - harness.unmount(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPromptActions.ts b/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPromptActions.ts deleted file mode 100644 index c37c6ab7f..000000000 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchEntryPromptActions.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useCallback } from "react"; -import type { GeneralWorkbenchEntryPromptState } from "./useGeneralWorkbenchEntryPrompt"; - -interface DismissGeneralWorkbenchEntryPromptOptions { - consumeInitialPrompt?: boolean; - onConsumeInitialPrompt?: () => void; -} - -interface UseGeneralWorkbenchEntryPromptActionsOptions { - generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null; - input: string; - initialDispatchKey: string | null; - onContinuePrompt: (prompt: string) => Promise | void; - dismissGeneralWorkbenchEntryPrompt: ( - options?: DismissGeneralWorkbenchEntryPromptOptions, - ) => void; - onConsumeInitialPrompt?: (dispatchKey: string | null) => void; - onInputChange: (value: string) => void; - onRequirePrompt?: () => void; -} - -export function useGeneralWorkbenchEntryPromptActions({ - generalWorkbenchEntryPrompt, - input, - initialDispatchKey, - onContinuePrompt, - dismissGeneralWorkbenchEntryPrompt, - onConsumeInitialPrompt, - onInputChange, - onRequirePrompt, -}: UseGeneralWorkbenchEntryPromptActionsOptions) { - const handleContinueGeneralWorkbenchEntryPrompt = useCallback(async () => { - if (!generalWorkbenchEntryPrompt) { - return; - } - - const promptToSend = - input.trim() || generalWorkbenchEntryPrompt.prompt.trim(); - if (!promptToSend) { - onRequirePrompt?.(); - return; - } - - await onContinuePrompt(promptToSend); - }, [generalWorkbenchEntryPrompt, input, onContinuePrompt, onRequirePrompt]); - - const handleRestartGeneralWorkbenchEntryPrompt = useCallback(() => { - if (!generalWorkbenchEntryPrompt) { - return; - } - - dismissGeneralWorkbenchEntryPrompt({ - consumeInitialPrompt: - generalWorkbenchEntryPrompt.kind === "initial_prompt", - onConsumeInitialPrompt: () => { - onConsumeInitialPrompt?.(initialDispatchKey); - }, - }); - onInputChange(""); - }, [ - dismissGeneralWorkbenchEntryPrompt, - generalWorkbenchEntryPrompt, - initialDispatchKey, - onConsumeInitialPrompt, - onInputChange, - ]); - - return { - handleContinueGeneralWorkbenchEntryPrompt, - handleRestartGeneralWorkbenchEntryPrompt, - }; -} diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx b/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx deleted file mode 100644 index 8922151ee..000000000 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useGeneralWorkbenchSendBoundary } from "./useGeneralWorkbenchSendBoundary"; - -interface HookHarnessProps { - isThemeWorkbench: boolean; - contentId?: string; - initialDispatchKey: string | null; - consumedInitialPromptKey: string | null; - mappedTheme: string; -} - -interface HookHarness { - getValue: () => ReturnType; - rerender: (props?: Partial) => void; - unmount: () => void; - onConsumeInitialPrompt: ReturnType; - onResetConsumedInitialPrompt: ReturnType; - onClearEntryPrompt: ReturnType; -} - -function mountHook(initialProps?: Partial): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - const onConsumeInitialPrompt = vi.fn(); - const onResetConsumedInitialPrompt = vi.fn(); - const onClearEntryPrompt = vi.fn(); - - let hookValue: ReturnType | null = - null; - let currentProps: HookHarnessProps = { - isThemeWorkbench: true, - contentId: "content-1", - initialDispatchKey: "dispatch-1", - consumedInitialPromptKey: null, - mappedTheme: "general", - ...initialProps, - }; - - function TestComponent() { - hookValue = useGeneralWorkbenchSendBoundary({ - ...currentProps, - initialUserImages: [], - socialArticleSkillKey: "content_post_with_cover", - onConsumeInitialPrompt, - onResetConsumedInitialPrompt, - onClearEntryPrompt, - }); - return null; - } - - const render = (nextProps?: Partial) => { - currentProps = { - ...currentProps, - ...nextProps, - }; - - act(() => { - root.render(); - }); - }; - - render(); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - rerender: render, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - onConsumeInitialPrompt, - onResetConsumedInitialPrompt, - onClearEntryPrompt, - }; -} - -describe("useGeneralWorkbenchSendBoundary", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("应识别工作区内容编排的首条意图消费", () => { - const harness = mountHook(); - - try { - const boundary = harness.getValue().resolveSendBoundary({ - sourceText: "请生成今天的社媒主稿", - }); - - expect(boundary.sourceText).toBe( - "/content_post_with_cover 请生成今天的社媒主稿", - ); - expect(boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt).toBe( - true, - ); - expect(boundary.shouldDismissGeneralWorkbenchEntryPrompt).toBe(true); - expect(boundary.browserRequirementMatch).toBeNull(); - } finally { - harness.unmount(); - } - }); - - it("需要真实浏览器时应仅保留 requirement 检测,不再恢复旧状态机", () => { - const harness = mountHook(); - - try { - const boundary = harness.getValue().resolveSendBoundary({ - sourceText: "帮我把这篇文章发布到微信公众号后台", - }); - - expect(boundary.sourceText).toBe( - "/content_post_with_cover 帮我把这篇文章发布到微信公众号后台", - ); - expect(boundary.browserRequirementMatch).toEqual( - expect.objectContaining({ - requirement: "required_with_user_step", - launchUrl: "https://mp.weixin.qq.com/", - platformLabel: "微信公众号后台", - }), - ); - expect(harness.onConsumeInitialPrompt).not.toHaveBeenCalled(); - expect(harness.onClearEntryPrompt).not.toHaveBeenCalled(); - } finally { - harness.unmount(); - } - }); - - it("发送失败时应回滚已消费的首条意图", () => { - const harness = mountHook(); - - try { - const boundary = harness.getValue().resolveSendBoundary({ - sourceText: "请生成今天的社媒主稿", - }); - - act(() => { - harness.getValue().rollbackAfterSendFailure(boundary); - }); - - expect(harness.onResetConsumedInitialPrompt).toHaveBeenCalledTimes(1); - } finally { - harness.unmount(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.ts b/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.ts deleted file mode 100644 index 54c553ff5..000000000 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { useCallback } from "react"; -import type { BrowserTaskRequirementMatch } from "../utils/browserTaskRequirement"; -import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement"; -import type { MessageImage } from "../types"; -import type { HandleSendOptions } from "./handleSendTypes"; - -interface BuildGeneralWorkbenchSendBoundaryStateOptions { - isThemeWorkbench: boolean; - contentId?: string; - initialDispatchKey: string | null; - consumedInitialPromptKey: string | null; - initialUserImages?: MessageImage[]; - mappedTheme: string; - socialArticleSkillKey: string; - sourceText: string; - sendOptions?: HandleSendOptions; -} - -export interface GeneralWorkbenchSendBoundaryState { - sourceText: string; - browserRequirementMatch: BrowserTaskRequirementMatch | null; - shouldConsumePendingGeneralWorkbenchInitialPrompt: boolean; - shouldDismissGeneralWorkbenchEntryPrompt: boolean; -} - -interface UseGeneralWorkbenchSendBoundaryOptions { - isThemeWorkbench: boolean; - contentId?: string; - initialDispatchKey: string | null; - consumedInitialPromptKey: string | null; - initialUserImages?: MessageImage[]; - mappedTheme: string; - socialArticleSkillKey: string; - onConsumeInitialPrompt: (dispatchKey: string) => void; - onResetConsumedInitialPrompt: () => void; - onClearEntryPrompt: () => void; -} - -export function buildGeneralWorkbenchSendBoundaryState({ - isThemeWorkbench, - contentId, - initialDispatchKey, - consumedInitialPromptKey, - initialUserImages, - mappedTheme, - socialArticleSkillKey, - sourceText, - sendOptions, -}: BuildGeneralWorkbenchSendBoundaryStateOptions): GeneralWorkbenchSendBoundaryState { - const shouldConsumePendingGeneralWorkbenchInitialPrompt = - isThemeWorkbench && - Boolean(contentId) && - Boolean(initialDispatchKey) && - consumedInitialPromptKey !== initialDispatchKey && - (initialUserImages || []).length === 0 && - !sendOptions?.purpose; - const shouldDismissGeneralWorkbenchEntryPrompt = - isThemeWorkbench && !sendOptions?.purpose; - - const trimmedSourceText = sourceText.trim(); - const shouldWrapWithGeneralWorkbenchSkill = - isThemeWorkbench && - mappedTheme === "general" && - !sendOptions?.purpose && - trimmedSourceText.length > 0 && - !trimmedSourceText.startsWith("/") && - !trimmedSourceText.startsWith("@"); - const nextSourceText = shouldWrapWithGeneralWorkbenchSkill - ? `/${socialArticleSkillKey} ${trimmedSourceText}` - : sourceText; - const browserRequirementSourceText = shouldWrapWithGeneralWorkbenchSkill - ? trimmedSourceText - : nextSourceText; - - const browserRequirementMatch = - mappedTheme === "general" && !sendOptions?.purpose - ? detectBrowserTaskRequirement(browserRequirementSourceText) - : null; - - return { - sourceText: nextSourceText, - browserRequirementMatch, - shouldConsumePendingGeneralWorkbenchInitialPrompt, - shouldDismissGeneralWorkbenchEntryPrompt, - }; -} - -export function useGeneralWorkbenchSendBoundary({ - isThemeWorkbench, - contentId, - initialDispatchKey, - consumedInitialPromptKey, - initialUserImages, - mappedTheme, - socialArticleSkillKey, - onConsumeInitialPrompt, - onResetConsumedInitialPrompt, - onClearEntryPrompt, -}: UseGeneralWorkbenchSendBoundaryOptions) { - const resolveSendBoundary = useCallback( - ({ - sourceText, - sendOptions, - }: { - sourceText: string; - sendOptions?: HandleSendOptions; - }) => - buildGeneralWorkbenchSendBoundaryState({ - isThemeWorkbench, - contentId, - initialDispatchKey, - consumedInitialPromptKey, - initialUserImages, - mappedTheme, - socialArticleSkillKey, - sourceText, - sendOptions, - }), - [ - consumedInitialPromptKey, - contentId, - initialDispatchKey, - initialUserImages, - isThemeWorkbench, - mappedTheme, - socialArticleSkillKey, - ], - ); - - const finalizeAfterSendSuccess = useCallback( - (boundary: GeneralWorkbenchSendBoundaryState) => { - if ( - boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt && - initialDispatchKey - ) { - onConsumeInitialPrompt(initialDispatchKey); - } - - if (boundary.shouldDismissGeneralWorkbenchEntryPrompt) { - onClearEntryPrompt(); - } - }, - [initialDispatchKey, onClearEntryPrompt, onConsumeInitialPrompt], - ); - - const rollbackAfterSendFailure = useCallback( - (boundary: GeneralWorkbenchSendBoundaryState) => { - if (boundary.shouldConsumePendingGeneralWorkbenchInitialPrompt) { - onResetConsumedInitialPrompt(); - } - }, - [onResetConsumedInitialPrompt], - ); - - return { - resolveSendBoundary, - finalizeAfterSendSuccess, - rollbackAfterSendFailure, - }; -} diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 8812f196d..542ba8d43 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -3859,6 +3859,40 @@ describe("AgentChatPage 自动引导", { timeout: 20_000 }, () => { expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); }); + it("初始创作意图点击重新开始后应清空输入并消费待执行意图", async () => { + mockIsSpecializedWorkbenchTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + const onInitialUserPromptConsumed = vi.fn(); + const initialUserPrompt = "请先帮我写一篇社媒文案提纲。"; + + const container = renderPage({ + projectId: "project-social-intent-restart", + contentId: "content-social-intent-restart", + theme: "general", + lockTheme: true, + initialUserPrompt, + onInitialUserPromptConsumed, + }); + await flushEffects(12); + + clickButton(container, "theme-workbench-entry-restart"); + await flushEffects(12); + + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { input?: string } + | undefined; + expect(latestInputbarProps?.input || "").toBe(""); + expect( + container.querySelector('[data-testid="theme-workbench-entry-prompt"]'), + ).toBeNull(); + expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); + }); + it("存在 initialRequestMetadata 时应把结构化回放透传到首发 requestMetadata", async () => { mockIsSpecializedWorkbenchTheme.mockReturnValue(true); mockUseThemeContextWorkspace.mockReturnValue( diff --git a/src/components/agent/chat/protocol-fact-source-guard.test.ts b/src/components/agent/chat/protocol-fact-source-guard.test.ts index b8670a7ea..1a21ec194 100644 --- a/src/components/agent/chat/protocol-fact-source-guard.test.ts +++ b/src/components/agent/chat/protocol-fact-source-guard.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest"; const PROTOCOL_GUARD_DIRS = [ join(process.cwd(), "src/components/agent/chat"), - join(process.cwd(), "src/components/terminal/ai"), join(process.cwd(), "src/components/smart-input"), ] as const; const API_PROTOCOL_GUARD_DIR = join(process.cwd(), "src/lib/api"); diff --git a/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts b/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts index 932db2194..5e21e7d55 100644 --- a/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts +++ b/src/components/agent/chat/team-workspace-runtime/liveRuntimeProjector.ts @@ -13,7 +13,7 @@ import { type TeamWorkspaceRuntimeSessionSnapshot, type TeamWorkspaceRuntimeStatus, } from "../teamWorkspaceRuntime"; -import { resolveToolDisplayLabel } from "../utils/toolDisplayInfo"; +import { resolveUserFacingToolDisplayLabel } from "../utils/toolDisplayInfo"; import { resolveTeamWorkspaceDisplayRuntimeStatusLabel } from "../utils/teamWorkspaceCopy"; const LIVE_ACTIVITY_ENTRY_LIMIT = 3; @@ -210,7 +210,7 @@ function buildToolActivityEntry(params: { }) { const { sessionId, toolId, toolName, result } = params; const displayToolName = toolName?.trim() - ? resolveToolDisplayLabel(toolName) + ? resolveUserFacingToolDisplayLabel(toolName) : null; const title = displayToolName ? `处理中 · ${displayToolName}` : "处理中"; const detail = result diff --git a/src/components/agent/chat/teamWorkspaceRuntime.ts b/src/components/agent/chat/teamWorkspaceRuntime.ts index f2eea6f96..e2b0241c2 100644 --- a/src/components/agent/chat/teamWorkspaceRuntime.ts +++ b/src/components/agent/chat/teamWorkspaceRuntime.ts @@ -13,7 +13,7 @@ import { resolveTeamWorkspaceDisplayMemberStatusLabel, resolveTeamWorkspaceDisplayRuntimeStatusLabel, } from "./utils/teamWorkspaceCopy"; -import { resolveToolDisplayLabel } from "./utils/toolDisplayInfo"; +import { resolveUserFacingToolDisplayLabel } from "./utils/toolDisplayInfo"; export type TeamWorkspaceRuntimeStatus = AsterSubagentSessionInfo["runtime_status"]; @@ -332,7 +332,7 @@ function resolveItemActivityDescriptor(item: AgentThreadItem): { }; case "tool_call": { const displayToolName = item.tool_name - ? resolveToolDisplayLabel(item.tool_name) + ? resolveUserFacingToolDisplayLabel(item.tool_name) : null; return { title: displayToolName ? `工具 ${displayToolName}` : "工具输出", diff --git a/src/components/agent/chat/utils/agentThreadGrouping.test.ts b/src/components/agent/chat/utils/agentThreadGrouping.test.ts index d2f585805..a307972ca 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.test.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.test.ts @@ -112,9 +112,9 @@ describe("agentThreadGrouping", () => { "artifact", "process", ]); - expect(model.groups[1]?.previewLines).toEqual(["产出了 wechat-draft.md"]); + expect(model.groups[1]?.previewLines).toEqual(["生成了 wechat-draft.md"]); expect(model.groups[2]?.previewLines).toContain( - "执行了 npm test -- AgentThreadTimeline", + "运行了 npm test -- AgentThreadTimeline", ); expect(model.summaryChips).toEqual([ { kind: "process", label: "执行过程", count: 3 }, @@ -139,7 +139,7 @@ describe("agentThreadGrouping", () => { const model = buildAgentThreadDisplayModel(items); expect(model.groups.map((group) => group.kind)).toEqual(["process"]); - expect(model.groups[0]?.previewLines).toEqual(["写了 nested-draft.md"]); + expect(model.groups[0]?.previewLines).toEqual(["保存了 nested-draft.md"]); }); it("应通过 filesystem event protocol 识别目录与输出文件位置线索", () => { @@ -166,8 +166,8 @@ describe("agentThreadGrouping", () => { expect(model.groups.map((group) => group.kind)).toEqual(["process"]); expect(model.groups[0]?.previewLines).toEqual([ - "看了 reports", - "动了 run.log", + "查看了 reports", + "处理了 run.log", ]); }); @@ -226,6 +226,68 @@ describe("agentThreadGrouping", () => { ]); }); + it("交互与任务结果预览应使用更直白的用户文案", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("question-1", 1), + type: "tool_call", + tool_name: "AskUserQuestion", + arguments: { question: "需要继续吗?" }, + }, + { + ...createBaseItem("task-output-1", 2), + type: "tool_call", + tool_name: "TaskOutput", + arguments: { task_id: "video-task-1" }, + }, + { + ...createBaseItem("list-peers-1", 3), + type: "tool_call", + tool_name: "ListPeers", + arguments: { team_name: "当前团队" }, + }, + ]; + + const model = buildAgentThreadDisplayModel(items); + + expect(model.groups[0]?.previewLines).toEqual([ + "等你确认:需要继续吗?", + "已查看结果 video-task-1", + "已查看 当前团队", + ]); + }); + + it("协作任务控制预览应直接表达查看、继续与暂停动作", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("wait-agent-1", 1), + type: "tool_call", + tool_name: "WaitAgent", + arguments: { id: "agent-1" }, + }, + { + ...createBaseItem("resume-agent-1", 2), + type: "tool_call", + tool_name: "ResumeAgent", + arguments: { id: "agent-1" }, + }, + { + ...createBaseItem("close-agent-1", 3), + type: "tool_call", + tool_name: "CloseAgent", + arguments: { id: "agent-1" }, + }, + ]; + + const model = buildAgentThreadDisplayModel(items); + + expect(model.groups[0]?.previewLines).toEqual([ + "已查看 agent-1", + "已继续 agent-1", + "已暂停 agent-1", + ]); + }); + it("内部路由型 turn_summary 不应抢占整轮摘要", () => { const items: AgentThreadItem[] = [ { diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 253347e50..1abab1616 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -6,7 +6,9 @@ import { } from "@/lib/filesystem-event-protocol"; import type { AgentThreadItem, AgentThreadItemStatus } from "../types"; import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder"; -import { resolveToolDisplayLabel } from "./toolDisplayInfo"; +import { + resolveUserFacingToolDisplayLabel, +} from "./toolDisplayInfo"; import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation"; export type AgentThreadGroupKind = @@ -456,7 +458,7 @@ function summarizeSearchItem(item: AgentThreadItem): string | null { const args = asRecord(item.arguments); return prefixAction( readString(args, ["query", "q", "pattern", "search", "url"]) || - resolveToolDisplayLabel(item.tool_name), + resolveUserFacingToolDisplayLabel(item.tool_name), "搜了 ", ["搜了 ", "查了 ", "搜索了 ", "检索了 "], ); @@ -467,12 +469,13 @@ function summarizeFileItem(item: AgentThreadItem): string | null { const fileLabel = path ? fileNameFromPath(path) : null; if (item.type === "file_artifact") { - return prefixAction(fileLabel || item.path, "产出了 ", [ + return prefixAction(fileLabel || item.path, "生成了 ", [ + "生成了 ", "产出了 ", - "写了 ", - "改了 ", - "看了 ", - "动了 ", + "保存了 ", + "修改了 ", + "查看了 ", + "处理了 ", ]); } @@ -487,9 +490,9 @@ function summarizeFileItem(item: AgentThreadItem): string | null { normalized.includes("list") ) { return prefixAction( - fileLabel || resolveToolDisplayLabel(item.tool_name), - "看了 ", - ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name), + "查看了 ", + ["查看了 ", "看了 ", "读了 ", "保存了 ", "修改了 ", "处理了 "], ); } @@ -500,9 +503,9 @@ function summarizeFileItem(item: AgentThreadItem): string | null { normalized.includes("save") ) { return prefixAction( - fileLabel || resolveToolDisplayLabel(item.tool_name), - "写了 ", - ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name), + "保存了 ", + ["保存了 ", "写了 ", "查看了 ", "修改了 ", "处理了 "], ); } @@ -513,16 +516,16 @@ function summarizeFileItem(item: AgentThreadItem): string | null { normalized.includes("update") ) { return prefixAction( - fileLabel || resolveToolDisplayLabel(item.tool_name), - "改了 ", - ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name), + "修改了 ", + ["修改了 ", "改了 ", "查看了 ", "保存了 ", "处理了 "], ); } return prefixAction( - fileLabel || resolveToolDisplayLabel(item.tool_name), - "动了 ", - ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + fileLabel || resolveUserFacingToolDisplayLabel(item.tool_name), + "处理了 ", + ["处理了 ", "查看了 ", "保存了 ", "修改了 ", "生成了 "], ); } @@ -533,7 +536,7 @@ function summarizeCommandItem(item: AgentThreadItem): string | null { if (item.type === "command_execution") { return prefixAction( item.command, - "执行了 ", + "运行了 ", ["执行了 ", "跑了 ", "运行了 "], 64, ); @@ -543,8 +546,8 @@ function summarizeCommandItem(item: AgentThreadItem): string | null { const args = asRecord(item.arguments); return prefixAction( readString(args, ["command", "cmd", "script"]) || - resolveToolDisplayLabel(item.tool_name), - "执行了 ", + resolveUserFacingToolDisplayLabel(item.tool_name), + "运行了 ", ["执行了 ", "跑了 ", "运行了 "], 64, ); @@ -609,21 +612,32 @@ function summarizeCollaborationItem(item: AgentThreadItem): string | null { if (normalized === "listpeers") { return prefixAction( readString(args, ["team_name", "teamName"]) || "当前团队", - "已列出 ", - ["已列出 ", "列出了 ", "查看了 "], + "已查看 ", + ["已查看 ", "查看了 ", "已列出 ", "列出了 "], ); } - if ( - normalized === "waitagent" || - normalized === "resumeagent" || - normalized === "closeagent" - ) { + if (normalized === "waitagent") { return prefixAction( - readString(args, ["id", "ids", "session_id"]) || - resolveToolDisplayLabel(item.tool_name), - "处理了 ", - ["处理了 ", "继续了 ", "暂停了 ", "查看了 "], + readString(args, ["id", "ids", "session_id"]) || "任务进展", + "已查看 ", + ["已查看 ", "查看了 "], + ); + } + + if (normalized === "resumeagent") { + return prefixAction( + readString(args, ["id", "ids", "session_id"]) || "当前任务", + "已继续 ", + ["已继续 ", "继续了 "], + ); + } + + if (normalized === "closeagent") { + return prefixAction( + readString(args, ["id", "ids", "session_id"]) || "当前任务", + "已暂停 ", + ["已暂停 ", "暂停了 "], ); } @@ -665,20 +679,56 @@ function summarizeOtherItem(item: AgentThreadItem): string | null { const normalized = normalizeToolName(item.tool_name); const args = asRecord(item.arguments); + if (normalized === "askuserquestion") { + return prefixAction( + readString(args, ["question", "prompt", "header"]) || "等你确认这一步", + "等你确认:", + ["等你确认:", "等你补充:"], + ); + } + + if (normalized === "taskoutput") { + return prefixAction( + readString(args, ["task_id", "taskId", "subject"]) || "任务结果", + "已查看结果 ", + ["已查看结果 ", "查看了 "], + ); + } + + if (normalized === "listskills") { + return "已查看技能列表"; + } + + if (normalized === "loadskill") { + return prefixAction( + readString(args, ["name", "skill", "path"]) || "技能", + "已加载 ", + ["已加载 ", "加载了 "], + ); + } + + if (normalized === "skill") { + return prefixAction( + readString(args, ["name", "skill", "path", "command"]) || "技能", + "已使用 ", + ["已使用 ", "用了 "], + ); + } + if (normalized === "sendusermessage" || normalized === "brief") { return prefixAction( readString(args, ["message"]) || - resolveToolDisplayLabel(item.tool_name), + resolveUserFacingToolDisplayLabel(item.tool_name), "已发送 ", ["已发送 ", "发送了 "], ); } - return prefixAction(resolveToolDisplayLabel(item.tool_name), "执行了 ", [ - "执行了 ", - "跑了 ", - "运行了 ", - ]); + return prefixAction( + resolveUserFacingToolDisplayLabel(item.tool_name), + "处理了 ", + ["处理了 ", "执行了 ", "跑了 ", "运行了 "], + ); } return null; } diff --git a/src/components/agent/chat/utils/harnessState.ts b/src/components/agent/chat/utils/harnessState.ts index 92d391925..3e9bb2c43 100644 --- a/src/components/agent/chat/utils/harnessState.ts +++ b/src/components/agent/chat/utils/harnessState.ts @@ -10,7 +10,7 @@ import { } from "@/lib/artifact-protocol"; import { extractFilesystemEventPathsFromValue } from "@/lib/filesystem-event-protocol"; import type { ActionRequired, AgentRuntimeStatus, Message } from "../types"; -import { resolveToolDisplayLabel } from "./toolDisplayInfo"; +import { resolveUserFacingToolDisplayLabel } from "./toolDisplayInfo"; import { isInternalRoutingTurnSummaryText } from "./turnSummaryPresentation"; import { resolveArtifactPreviewText, @@ -1305,7 +1305,7 @@ function deriveHarnessSessionStateFromItems( summary: artifactPath || queryLabel || - resolveToolDisplayLabel(item.tool_name), + resolveUserFacingToolDisplayLabel(item.tool_name), preview: buildTextPreview(item.output), content: maybeKeepTextContent(item.output), artifactPath, diff --git a/src/components/agent/chat/utils/internalImagePlaceholder.test.ts b/src/components/agent/chat/utils/internalImagePlaceholder.test.ts new file mode 100644 index 000000000..7e80aaf2e --- /dev/null +++ b/src/components/agent/chat/utils/internalImagePlaceholder.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { + sanitizeContentPartsForDisplay, + sanitizeMessageTextForDisplay, +} from "./internalImagePlaceholder"; +import type { ContentPart } from "../types"; + +describe("internalImagePlaceholder", () => { + it("应清理紧邻工具调用的调度自述文本", () => { + const contentParts: ContentPart[] = [ + { + type: "text", + text: "ToolSearch 只返回了元数据,让我直接调用 WebSearch 进行多组检索。", + }, + { + type: "tool_use", + toolCall: { + id: "tool-narration-strip", + name: "WebSearch", + arguments: "{}", + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-04-13T09:00:00.000Z"), + }, + }, + { + type: "text", + text: "已经整理出 3 个可信来源。", + }, + ]; + + expect( + sanitizeContentPartsForDisplay(contentParts, { + role: "assistant", + }), + ).toEqual([ + contentParts[1], + contentParts[2], + ]); + }); + + it("应清理紧邻工具调用的页面操作自述", () => { + const contentParts: ContentPart[] = [ + { + type: "tool_use", + toolCall: { + id: "tool-narration-page", + name: "webReader", + arguments: "{}", + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-04-13T09:01:00.000Z"), + }, + }, + { + type: "text", + text: "我已经打开 GitHub 搜索页,接下来开始筛选结果。", + }, + { + type: "text", + text: "筛到两个官方仓库入口。", + }, + ]; + + expect( + sanitizeContentPartsForDisplay(contentParts, { + role: "assistant", + }), + ).toEqual([ + contentParts[0], + contentParts[2], + ]); + }); + + it("带结论的正常说明不应被误删", () => { + const contentParts: ContentPart[] = [ + { + type: "text", + text: "我用 WebSearch 查到 3 个官方来源,结论是目前只支持桌面端。", + }, + { + type: "tool_use", + toolCall: { + id: "tool-narration-keep", + name: "WebSearch", + arguments: "{}", + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-04-13T09:02:00.000Z"), + }, + }, + ]; + + expect( + sanitizeContentPartsForDisplay(contentParts, { + role: "assistant", + }), + ).toEqual(contentParts); + }); + + it("不挨着工具调用的普通说明不应被清理", () => { + const contentParts: ContentPart[] = [ + { + type: "text", + text: "ToolSearch 用于查询当前可用工具,这里是在解释概念。", + }, + { + type: "text", + text: "下面再继续说明使用方式。", + }, + ]; + + expect( + sanitizeContentPartsForDisplay(contentParts, { + role: "assistant", + }), + ).toEqual(contentParts); + }); + + it("普通消息文本清洗仍不应误删工具说明", () => { + const text = + "ToolSearch 用于查询当前可用工具,这里是在给用户解释概念。"; + + expect( + sanitizeMessageTextForDisplay(text, { + role: "assistant", + }), + ).toBe(text); + }); +}); diff --git a/src/components/agent/chat/utils/internalImagePlaceholder.ts b/src/components/agent/chat/utils/internalImagePlaceholder.ts index 95f81f42e..87b09070e 100644 --- a/src/components/agent/chat/utils/internalImagePlaceholder.ts +++ b/src/components/agent/chat/utils/internalImagePlaceholder.ts @@ -10,6 +10,21 @@ const BRACKET_IMAGE_PLACEHOLDER_TEST_RE = /\[\s*Image\s*#\d+\s*\]/i; const BARE_IMAGE_PLACEHOLDER_TEST_RE = /(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/i; const EXACT_IMAGE_TASK_LABEL_RE = /^\[?\s*Image\s*#(\d+)\s*\]?$/i; +const TOOL_NARRATION_TOOL_NAME_RE = + /\b(?:ToolSearch|WebSearch|WebFetch|Read|Write|Edit|Glob|Grep|Bash|StructuredOutput|webReader)\b|(?:mcp__[\w-]+(?:__[\w-]+)?|lime_[\w-]+)/i; +const TOOL_NARRATION_ACTION_RE = + /调用|使用|执行|检索|搜索|读取|抓取|访问|打开|分析|查找|扩搜|筛选|切换|转去|改为|尝试/i; +const TOOL_NARRATION_SELF_PROCESS_RE = + /让我|我将|我会|接下来|现在|继续|直接|先|然后|随后|改为|转去|尝试|开始/i; +const TOOL_NARRATION_SCHEDULING_RE = + /只返回了元数据|未命中|没有返回|改为|转去|切换到|直接调用/i; +const TOOL_NARRATION_NAVIGATION_TARGET_RE = + /搜索页|结果页|网页|页面|链接|文件|目录|仓库|日志|结果/i; +const TOOL_NARRATION_NAVIGATION_RE = + /已经打开|已打开|打开了|开始筛选|继续筛选|开始查看|继续查看|开始检索|继续检索|开始分析|继续分析|开始整理|继续整理/i; +const TOOL_NARRATION_RESULT_RE = + /结果如下|结论|我发现|发现了|查到|查到了|显示|表明|说明|意味着|共有|共计|\d+\s*(?:个|条|项|篇|页|处)/i; +const TOOL_NARRATION_MAX_LENGTH = 120; function collapseDisplayWhitespace(value: string): string { return value @@ -32,6 +47,41 @@ function replaceImagePlaceholders(text: string, replacement: string): string { ); } +function hasAdjacentToolUse( + parts: ContentPart[], + index: number, +): boolean { + return ( + parts[index - 1]?.type === "tool_use" || parts[index + 1]?.type === "tool_use" + ); +} + +function shouldStripAssistantToolNarration(text: string): boolean { + const normalized = collapseDisplayWhitespace(text); + if (!normalized || normalized.length > TOOL_NARRATION_MAX_LENGTH) { + return false; + } + + if (TOOL_NARRATION_RESULT_RE.test(normalized)) { + return false; + } + + const hasToolName = TOOL_NARRATION_TOOL_NAME_RE.test(normalized); + const hasAction = TOOL_NARRATION_ACTION_RE.test(normalized); + const hasSelfProcess = TOOL_NARRATION_SELF_PROCESS_RE.test(normalized); + const hasSchedulingCue = TOOL_NARRATION_SCHEDULING_RE.test(normalized); + + if (hasToolName && hasAction && (hasSelfProcess || hasSchedulingCue)) { + return true; + } + + return ( + hasSelfProcess && + TOOL_NARRATION_NAVIGATION_RE.test(normalized) && + TOOL_NARRATION_NAVIGATION_TARGET_RE.test(normalized) + ); +} + export function containsInternalImagePlaceholder(text: string): boolean { return ( BRACKET_IMAGE_PLACEHOLDER_TEST_RE.test(text) || @@ -125,7 +175,7 @@ export function sanitizeContentPartsForDisplay( return parts; } - const sanitizedParts = parts.flatMap((part) => { + const sanitizedParts = parts.flatMap((part, index) => { if (part.type !== "text") { return [part]; } @@ -135,6 +185,14 @@ export function sanitizeContentPartsForDisplay( return []; } + if ( + options.role === "assistant" && + hasAdjacentToolUse(parts, index) && + shouldStripAssistantToolNarration(sanitizedText) + ) { + return []; + } + return [ { ...part, diff --git a/src/components/agent/chat/utils/protocolResidue.test.ts b/src/components/agent/chat/utils/protocolResidue.test.ts index ea58f399c..b1dc12613 100644 --- a/src/components/agent/chat/utils/protocolResidue.test.ts +++ b/src/components/agent/chat/utils/protocolResidue.test.ts @@ -46,4 +46,31 @@ describe("protocolResidue", () => { expect(containsAssistantProtocolResidue(normal)).toBe(false); expect(stripAssistantProtocolResidue(normal)).toBe(normal); }); + + it("应清理 provider 泄露的 Built-in Tool 执行痕迹,但保留正常说明", () => { + const leaked = [ + "让我们进行多组 WebSearch 检索,获取最新热点。 Z.ai Built-in Tool: webReader", + "", + "Input:", + "JSON", + '{"url":"https://example.com/search?q=ai","return_format":"text"}', + "", + 'Executing on server... Output: webReader_result_summary: [{"text":"ok","type":"text"}]', + "", + "我会继续整理结果。", + ].join("\n"); + + expect(containsAssistantProtocolResidue(leaked)).toBe(true); + expect(stripAssistantProtocolResidue(leaked)).toBe( + "让我们进行多组 WebSearch 检索,获取最新热点。\n\n我会继续整理结果。", + ); + }); + + it("正常解释 Input 与 Output 概念时不应误删", () => { + const normal = + "Input 是工具入参,Output 是执行结果,这里只是解释概念,不是运行时协议残留。"; + + expect(containsAssistantProtocolResidue(normal)).toBe(false); + expect(stripAssistantProtocolResidue(normal)).toBe(normal); + }); }); diff --git a/src/components/agent/chat/utils/protocolResidue.ts b/src/components/agent/chat/utils/protocolResidue.ts index 476956445..0d4edd80d 100644 --- a/src/components/agent/chat/utils/protocolResidue.ts +++ b/src/components/agent/chat/utils/protocolResidue.ts @@ -2,6 +2,10 @@ const TOOL_PROTOCOL_BLOCK_RE = /]*>[\s\S]*?<\/tool_\1>/gi; const TOOL_PROTOCOL_TAG_RE = /<\/?tool_(?:call|result)\b[^>]*\/?>/gi; const TOOL_PROTOCOL_DETECT_RE = /<\/?tool_(?:call|result)\b[^>]*\/?>/i; +const PROVIDER_TRACE_TOOL_MARKER_RE = + /(?:^|\s)(?:[A-Za-z0-9.-]+\s+)?Built-in Tool:\s*[A-Za-z0-9_.-]+/i; +const PROVIDER_TRACE_EXECUTING_RE = /^executing on server(?:\.\.\.)?/i; +const PROVIDER_TRACE_RESULT_SUMMARY_RE = /\b[A-Za-z0-9_]+_result_summary\b/i; const INTERNAL_PROTOCOL_PARAGRAPH_PATTERNS = [ /you must call the [`"]?structuredoutput[`"]? tool now/i, /you must use the [`"]?structuredoutput[`"]? tool/i, @@ -19,6 +23,55 @@ const INTERNAL_PROTOCOL_LINE_PATTERNS = [ /^output final deliver artifact document$/i, ] as const; +function isLikelyProviderTraceJsonLine(line: string): boolean { + const normalized = line.trim(); + if (!normalized) { + return true; + } + + return ( + normalized === "JSON" || + normalized === "```json" || + normalized === "```" || + /^(?:\[|{)/.test(normalized) || + /^(?:\]|})/.test(normalized) || + /^"(?:[^"]+)"\s*:/.test(normalized) || + /^[:,}\]]+$/.test(normalized) + ); +} + +function isProviderTraceContinuationLine(line: string): boolean { + const normalized = line.trim(); + if (!normalized) { + return true; + } + + return ( + /^input:$/i.test(normalized) || + /^output:/i.test(normalized) || + PROVIDER_TRACE_EXECUTING_RE.test(normalized) || + PROVIDER_TRACE_RESULT_SUMMARY_RE.test(normalized) || + isLikelyProviderTraceJsonLine(normalized) + ); +} + +function stripInlineProviderTraceStart(line: string): string | null { + const match = line.match(PROVIDER_TRACE_TOOL_MARKER_RE); + if (!match || typeof match.index !== "number") { + return null; + } + + return line.slice(0, match.index).trimEnd(); +} + +function containsProviderTraceResidue(text: string): boolean { + return ( + PROVIDER_TRACE_TOOL_MARKER_RE.test(text) || + PROVIDER_TRACE_EXECUTING_RE.test(text) || + PROVIDER_TRACE_RESULT_SUMMARY_RE.test(text) + ); +} + function isAssistantProtocolResidueLine(line: string): boolean { const normalized = line.trim(); if (!normalized) { @@ -66,8 +119,88 @@ function normalizeProtocolStripWhitespace(text: string): string { .trim(); } +function stripProviderTraceResidue(text: string): string { + if (!text) { + return ""; + } + + const lines = text.split(/\r?\n/); + const keptLines: string[] = []; + let skippingTrace = false; + let sawBlankWithinTrace = false; + + const appendLine = (line: string) => { + keptLines.push(line); + }; + + for (const line of lines) { + if (!skippingTrace) { + const inlinePrefix = stripInlineProviderTraceStart(line); + if (inlinePrefix !== null) { + if (inlinePrefix) { + appendLine(inlinePrefix); + } + skippingTrace = true; + sawBlankWithinTrace = false; + continue; + } + + if ( + PROVIDER_TRACE_EXECUTING_RE.test(line.trim()) || + PROVIDER_TRACE_RESULT_SUMMARY_RE.test(line) + ) { + skippingTrace = true; + sawBlankWithinTrace = false; + continue; + } + + appendLine(line); + continue; + } + + if (isProviderTraceContinuationLine(line)) { + if (!line.trim()) { + sawBlankWithinTrace = true; + } + continue; + } + + if ( + sawBlankWithinTrace && + keptLines.length > 0 && + keptLines[keptLines.length - 1]?.trim() !== "" + ) { + appendLine(""); + } + + skippingTrace = false; + sawBlankWithinTrace = false; + + const inlinePrefix = stripInlineProviderTraceStart(line); + if (inlinePrefix !== null) { + if (inlinePrefix) { + appendLine(inlinePrefix); + } + skippingTrace = true; + continue; + } + + if ( + PROVIDER_TRACE_EXECUTING_RE.test(line.trim()) || + PROVIDER_TRACE_RESULT_SUMMARY_RE.test(line) + ) { + skippingTrace = true; + continue; + } + + appendLine(line); + } + + return normalizeProtocolStripWhitespace(keptLines.join("\n")); +} + export function containsAssistantProtocolResidue(text: string): boolean { - if (TOOL_PROTOCOL_DETECT_RE.test(text)) { + if (TOOL_PROTOCOL_DETECT_RE.test(text) || containsProviderTraceResidue(text)) { return true; } @@ -85,7 +218,8 @@ export function stripAssistantProtocolResidue(text: string): string { return ""; } - const withoutBlocks = text.replace(TOOL_PROTOCOL_BLOCK_RE, "\n"); + const withoutProviderTrace = stripProviderTraceResidue(text); + const withoutBlocks = withoutProviderTrace.replace(TOOL_PROTOCOL_BLOCK_RE, "\n"); const withoutTags = withoutBlocks.replace(TOOL_PROTOCOL_TAG_RE, "\n"); const sanitizedParagraphs = withoutTags .split(/\n{2,}/) diff --git a/src/components/agent/chat/utils/siteToolResultSummary.ts b/src/components/agent/chat/utils/siteToolResultSummary.ts index 372c583c7..7c9053992 100644 --- a/src/components/agent/chat/utils/siteToolResultSummary.ts +++ b/src/components/agent/chat/utils/siteToolResultSummary.ts @@ -342,6 +342,22 @@ export function resolveSiteProjectSourceLabel(source?: string): string | null { return null; } +export function resolveSiteProjectTargetLabel(params: { + source?: string; + projectId?: string; +}): string { + if (params.source === "context_project") { + return "当前项目"; + } + if (params.source === "explicit_project") { + return "所选项目"; + } + if (params.projectId?.trim()) { + return `项目 ${params.projectId.trim()}`; + } + return "项目"; +} + export function resolveSiteAdapterSourceLabel( summary: SiteToolResultSummary, ): string | null { diff --git a/src/components/agent/chat/utils/toolDisplayInfo.test.ts b/src/components/agent/chat/utils/toolDisplayInfo.test.ts index 52a2c98ff..728ade9f2 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.test.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; import { + buildToolHeadline, + buildToolGroupHeadline, extractSearchQueryLabel, + getToolDisplayInfo, normalizeToolNameKey, + resolveUserFacingToolDisplayLabel, resolveToolDisplayLabel, } from "./toolDisplayInfo"; @@ -73,6 +77,17 @@ describe("toolDisplayInfo", () => { expect(resolveToolDisplayLabel("BashOutputTool")).toBe("任务输出"); }); + it("应为用户可见场景提供更自然的工具标签", () => { + expect(resolveUserFacingToolDisplayLabel("FileReadTool")).toBe("查看文件"); + expect(resolveUserFacingToolDisplayLabel("write_file")).toBe("保存文件"); + expect(resolveUserFacingToolDisplayLabel("TaskOutput")).toBe( + "查看任务结果", + ); + expect(resolveUserFacingToolDisplayLabel("mcp__playwright__browser_click")).toBe( + "页面点击", + ); + }); + it("应隐藏 ToolSearch 中的内部协议查询词", () => { expect( extractSearchQueryLabel({ @@ -84,4 +99,68 @@ describe("toolDisplayInfo", () => { }), ).toBe("内部流程"); }); + + it("无主体对象时应直接展示动作句,避免重复拼接工具类别", () => { + expect( + buildToolHeadline({ + toolDisplay: getToolDisplayInfo("TaskList", "completed"), + toolName: "TaskList", + }), + ).toBe("已获取任务列表"); + + expect( + buildToolHeadline({ + toolDisplay: getToolDisplayInfo("ListSkills", "completed"), + toolName: "ListSkills", + }), + ).toBe("已获取技能列表"); + }); + + it("应为查看类与计划类批次生成更自然的标题", () => { + expect( + buildToolGroupHeadline([ + { + id: "tool-read-1", + name: "Read", + arguments: JSON.stringify({ file_path: "docs/guide.md" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-04-13T00:00:00.000Z"), + endTime: new Date("2026-04-13T00:00:01.000Z"), + }, + { + id: "tool-glob-1", + name: "glob", + arguments: JSON.stringify({ pattern: "src/**/*.tsx" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-04-13T00:00:02.000Z"), + endTime: new Date("2026-04-13T00:00:03.000Z"), + }, + ]), + ).toBe("已查看"); + + expect( + buildToolGroupHeadline([ + { + id: "tool-task-list-1", + name: "TaskList", + arguments: JSON.stringify({}), + status: "completed", + result: { success: true, output: "[]" }, + startTime: new Date("2026-04-13T00:00:04.000Z"), + endTime: new Date("2026-04-13T00:00:05.000Z"), + }, + { + id: "tool-task-update-1", + name: "TaskUpdate", + arguments: JSON.stringify({ task_id: "task-1" }), + status: "completed", + result: { success: true, output: "{}" }, + startTime: new Date("2026-04-13T00:00:06.000Z"), + endTime: new Date("2026-04-13T00:00:07.000Z"), + }, + ]), + ).toBe("已处理 2 项安排"); + }); }); diff --git a/src/components/agent/chat/utils/toolDisplayInfo.ts b/src/components/agent/chat/utils/toolDisplayInfo.ts index e6ec538e0..b7a3ca9a2 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.ts @@ -89,19 +89,19 @@ const TOOL_STATUS_ACTIONS = { running: "搜索中", }, read: { - failed: "读取失败", - completed: "已读取", - running: "读取中", + failed: "查看失败", + completed: "已查看", + running: "查看中", }, list: { - failed: "列出失败", - completed: "已列出", - running: "浏览中", + failed: "查看失败", + completed: "已查看", + running: "查看中", }, write: { - failed: "写入失败", - completed: "已写入", - running: "写入中", + failed: "保存失败", + completed: "已保存", + running: "保存中", }, edit: { failed: "编辑失败", @@ -109,9 +109,9 @@ const TOOL_STATUS_ACTIONS = { running: "编辑中", }, command: { - failed: "执行失败", - completed: "已执行", - running: "执行中", + failed: "运行失败", + completed: "已运行", + running: "运行中", }, plan: { failed: "更新失败", @@ -148,7 +148,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "read", label: "文件读取", - verb: "读取", + verb: "查看", icon: Eye, groupTitle: "探索", actionKey: "read", @@ -159,7 +159,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "read", label: "文件读取", - verb: "读取", + verb: "查看", icon: Eye, groupTitle: "探索", actionKey: "read", @@ -170,7 +170,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "read", label: "文档读取", - verb: "读取", + verb: "查看", icon: FileText, groupTitle: "探索", actionKey: "read", @@ -181,7 +181,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "read", label: "资源读取", - verb: "读取", + verb: "查看", icon: FileText, groupTitle: "探索", actionKey: "read", @@ -192,7 +192,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "write", label: "文件写入", - verb: "写入", + verb: "保存", icon: FilePlus, groupTitle: "写入", actionKey: "write", @@ -203,7 +203,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "write", label: "文件写入", - verb: "写入", + verb: "保存", icon: FilePlus, groupTitle: "写入", actionKey: "write", @@ -214,7 +214,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "write", label: "文件创建", - verb: "创建", + verb: "保存", icon: FilePlus, groupTitle: "写入", actionKey: "write", @@ -280,10 +280,15 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "文件匹配", - verb: "列出", + verb: "查找", icon: FolderOpen, groupTitle: "探索", actionKey: "list", + actions: { + failed: "查找失败", + completed: "已找到", + running: "查找中", + }, }, ], [ @@ -291,7 +296,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "目录浏览", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -302,7 +307,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "目录浏览", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -313,7 +318,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "目录浏览", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -324,7 +329,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "目录浏览", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -335,7 +340,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "资源列表", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -346,7 +351,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "list", label: "资源模板列表", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -511,7 +516,7 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "command", label: "命令执行", - verb: "执行", + verb: "运行", icon: Terminal, groupTitle: "命令", actionKey: "command", @@ -651,14 +656,14 @@ const EXACT_TOOL_CONFIGS = new Map([ { family: "task", label: "任务输出", - verb: "读取输出", + verb: "查看结果", icon: FileText, groupTitle: "任务", actionKey: "task", actions: { - failed: "读取失败", - completed: "已读取输出", - running: "读取中", + failed: "查看结果失败", + completed: "已查看结果", + running: "查看结果中", }, }, ], @@ -1501,6 +1506,41 @@ const TOOL_NAME_KEY_ALIASES: Record = { writetodos: "taskupdate", }; +const USER_FACING_TOOL_LABELS: Record = { + 文件读取: "查看文件", + 文档读取: "查看文档", + 资源读取: "查看内容", + 文件写入: "保存文件", + 文件创建: "保存文件", + 文件编辑: "修改文件", + 批量编辑: "修改文件", + 笔记本编辑: "修改文件", + 补丁应用: "修改文件", + 文件匹配: "查找文件", + 目录浏览: "查看文件夹", + 资源列表: "查看资源", + 资源模板列表: "查看资源模板", + 内容检索: "查找内容", + 工具搜索: "查找工具", + 文档搜索: "查找文档", + 文档查询: "查看文档", + 网络搜索: "搜索网页", + 图片搜索: "搜索图片", + 联网搜图: "搜索图片", + 命令执行: "运行命令", + 技能执行: "使用技能", + 技能列表: "查看技能", + 技能加载: "加载技能", + 任务输出: "查看任务结果", + 工作区同步: "同步内容", + 图像分析: "分析图片", + 图片查看: "查看图片", + 站点能力目录: "查看站点能力", + 站点能力搜索: "搜索站点能力", + 站点能力详情: "查看站点能力", + 站点能力执行: "运行站点能力", +}; + export const normalizeToolNameKey = (value: string): string => { const normalized = value .replace(/[\s_-]+/g, "") @@ -1569,6 +1609,11 @@ export const resolveToolPrimarySubject = ( filePath?: string | null, ): string | null => { const normalizedName = normalizeToolNameKey(toolName); + const searchQueryPreview = resolveToolArgumentPreview(args, [ + "query", + "q", + "search_query", + ]); if (filePath) return getFileName(filePath); @@ -1723,6 +1768,16 @@ export const resolveToolPrimarySubject = ( return resolveToolArgumentPreview(args, ["query", "q"]) || "站点能力"; } + if (normalizedName === "toolsearch") { + if ( + searchQueryPreview && + !/^(?:select|tool|tools|name|tag):/i.test(searchQueryPreview) + ) { + return searchQueryPreview; + } + return "可用工具"; + } + if (normalizedName === "askuserquestion") { return resolveToolArgumentPreview(args, [ "question", @@ -1867,7 +1922,7 @@ export const getToolDisplayInfo = ( { family: "write", label: "文件写入", - verb: "写入", + verb: "保存", icon: FilePlus, groupTitle: "写入", actionKey: "write", @@ -1881,7 +1936,7 @@ export const getToolDisplayInfo = ( { family: "read", label: "文件读取", - verb: "读取", + verb: "查看", icon: Eye, groupTitle: "探索", actionKey: "read", @@ -1899,7 +1954,7 @@ export const getToolDisplayInfo = ( { family: "command", label: "命令执行", - verb: "执行", + verb: "运行", icon: Terminal, groupTitle: "命令", actionKey: "command", @@ -1931,7 +1986,7 @@ export const getToolDisplayInfo = ( { family: "list", label: "目录浏览", - verb: "列出", + verb: "查看", icon: FolderOpen, groupTitle: "探索", actionKey: "list", @@ -1962,7 +2017,7 @@ export const buildToolHeadline = (params: { } if (toolDisplay.label !== humanizeToolName(toolName)) { - return `${toolDisplay.action} ${toolDisplay.label}`; + return toolDisplay.action; } return toolDisplay.label; @@ -1971,6 +2026,14 @@ export const buildToolHeadline = (params: { export const resolveToolDisplayLabel = (toolName: string): string => getToolDisplayInfo(toolName, "completed").label; +export const toUserFacingToolDisplayLabel = (label: string): string => { + const normalized = label.trim(); + return USER_FACING_TOOL_LABELS[normalized] || normalized; +}; + +export const resolveUserFacingToolDisplayLabel = (toolName: string): string => + toUserFacingToolDisplayLabel(resolveToolDisplayLabel(toolName).trim() || toolName); + export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { const first = toolCalls[0]!; const info = getToolDisplayInfo(first.name, first.status); @@ -1996,23 +2059,23 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { ? "站点浏览失败" : "已浏览站点能力"; } - return running ? "探索中" : failed ? "探索失败" : "已探索"; + return running ? "查看中" : failed ? "查看失败" : "已查看"; } if (info.family === "command") { return failed - ? `执行失败 ${toolCalls.length} 条命令` + ? `运行失败 ${toolCalls.length} 条命令` : running - ? `执行中 ${toolCalls.length} 条命令` - : `已执行 ${toolCalls.length} 条命令`; + ? `运行中 ${toolCalls.length} 条命令` + : `已运行 ${toolCalls.length} 条命令`; } if (info.family === "write") { return failed - ? `写入失败 ${toolCalls.length} 个文件` + ? `保存失败 ${toolCalls.length} 个文件` : running - ? `写入中 ${toolCalls.length} 个文件` - : `已写入 ${toolCalls.length} 个文件`; + ? `保存中 ${toolCalls.length} 个文件` + : `已保存 ${toolCalls.length} 个文件`; } if (info.family === "edit") { @@ -2049,10 +2112,10 @@ export const buildToolGroupHeadline = (toolCalls: ToolCallState[]): string => { if (info.family === "plan") { return failed - ? `计划更新失败 ${toolCalls.length} 次` + ? `安排处理失败 ${toolCalls.length} 项` : running - ? `计划更新中 ${toolCalls.length} 次` - : `已更新 ${toolCalls.length} 次计划`; + ? `安排处理中 ${toolCalls.length} 项` + : `已处理 ${toolCalls.length} 项安排`; } if (info.family === "skill") { diff --git a/src/components/agent/chat/utils/toolSearchResultSummary.test.ts b/src/components/agent/chat/utils/toolSearchResultSummary.test.ts index d8368e1fd..54346616c 100644 --- a/src/components/agent/chat/utils/toolSearchResultSummary.test.ts +++ b/src/components/agent/chat/utils/toolSearchResultSummary.test.ts @@ -3,6 +3,7 @@ import { normalizeToolSearchResultSummary, resolveToolSearchItemSourceLabel, resolveToolSearchItemStatusLabel, + resolveUserFacingToolSearchItemLabel, } from "./toolSearchResultSummary"; describe("toolSearchResultSummary", () => { @@ -97,4 +98,16 @@ describe("toolSearchResultSummary", () => { }), ).toBe("待加载"); }); + + it("应把常见工具名转换成更自然的搜索展示文案", () => { + expect(resolveUserFacingToolSearchItemLabel("Read")).toBe("查看文件"); + expect(resolveUserFacingToolSearchItemLabel("Write")).toBe("保存文件"); + expect(resolveUserFacingToolSearchItemLabel("glob")).toBe("查找文件"); + expect(resolveUserFacingToolSearchItemLabel("TaskOutput")).toBe( + "查看任务结果", + ); + expect(resolveUserFacingToolSearchItemLabel("mcp__playwright__browser_click")).toBe( + "页面点击", + ); + }); }); diff --git a/src/components/agent/chat/utils/toolSearchResultSummary.ts b/src/components/agent/chat/utils/toolSearchResultSummary.ts index 3ea5376e6..a500d1eba 100644 --- a/src/components/agent/chat/utils/toolSearchResultSummary.ts +++ b/src/components/agent/chat/utils/toolSearchResultSummary.ts @@ -1,3 +1,5 @@ +import { resolveUserFacingToolDisplayLabel } from "./toolDisplayInfo"; + export interface ToolSearchResultItemSummary { name: string; description?: string; @@ -164,3 +166,7 @@ export function resolveToolSearchItemStatusLabel( } return null; } + +export function resolveUserFacingToolSearchItemLabel(toolName: string): string { + return resolveUserFacingToolDisplayLabel(toolName); +} diff --git a/src/components/agent/chat/workspace/GeneralWorkbenchSidebarSection.tsx b/src/components/agent/chat/workspace/GeneralWorkbenchSidebarSection.tsx deleted file mode 100644 index 581456f37..000000000 --- a/src/components/agent/chat/workspace/GeneralWorkbenchSidebarSection.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { SkillDetailInfo } from "@/lib/api/skill-execution"; -import { GeneralWorkbenchSidebar } from "../components/GeneralWorkbenchSidebar"; -import type { - GeneralWorkbenchSidebarExecLogContract, - GeneralWorkbenchSidebarProps, -} from "../components/generalWorkbenchSidebarContract"; -import type { Message } from "../types"; - -type GeneralWorkbenchWorkflowProps = Pick< - GeneralWorkbenchSidebarProps, - | "branchMode" - | "onNewTopic" - | "onSwitchTopic" - | "onDeleteTopic" - | "branchItems" - | "onSetBranchStatus" - | "workflowSteps" - | "onAddImage" - | "onImportDocument" - | "activityLogs" - | "creationTaskEvents" - | "onViewRunDetail" - | "activeRunDetail" - | "activeRunDetailLoading" ->; - -type GeneralWorkbenchContextWorkspaceProps = { - contextSearchQuery: GeneralWorkbenchSidebarProps["contextSearchQuery"]; - setContextSearchQuery: GeneralWorkbenchSidebarProps["onContextSearchQueryChange"]; - contextSearchMode: GeneralWorkbenchSidebarProps["contextSearchMode"]; - setContextSearchMode: GeneralWorkbenchSidebarProps["onContextSearchModeChange"]; - contextSearchLoading: GeneralWorkbenchSidebarProps["contextSearchLoading"]; - contextSearchError?: GeneralWorkbenchSidebarProps["contextSearchError"]; - contextSearchBlockedReason?: GeneralWorkbenchSidebarProps["contextSearchBlockedReason"]; - submitContextSearch: GeneralWorkbenchSidebarProps["onSubmitContextSearch"]; - addTextContext?: GeneralWorkbenchSidebarProps["onAddTextContext"]; - addLinkContext?: GeneralWorkbenchSidebarProps["onAddLinkContext"]; - addFileContext?: GeneralWorkbenchSidebarProps["onAddFileContext"]; - sidebarContextItems: GeneralWorkbenchSidebarProps["contextItems"]; - toggleContextActive: GeneralWorkbenchSidebarProps["onToggleContextActive"]; - contextBudget: GeneralWorkbenchSidebarProps["contextBudget"]; -}; - -interface GeneralWorkbenchHistoryProps { - hasMore?: boolean; - loading?: boolean; - onLoadMore?: GeneralWorkbenchSidebarExecLogContract["onLoadMoreHistory"]; - skillDetailMap?: Record; - messages?: Message[]; -} - -interface GeneralWorkbenchSidebarSectionProps { - visible: boolean; - workflowProps: GeneralWorkbenchWorkflowProps; - contextWorkspace: GeneralWorkbenchContextWorkspaceProps; - onViewContextDetail?: GeneralWorkbenchSidebarProps["onViewContextDetail"]; - onRequestCollapse?: GeneralWorkbenchSidebarProps["onRequestCollapse"]; - headerActionSlot?: GeneralWorkbenchSidebarProps["headerActionSlot"]; - topSlot?: GeneralWorkbenchSidebarProps["topSlot"]; - historyProps?: GeneralWorkbenchHistoryProps; -} - -export function GeneralWorkbenchSidebarSection({ - visible, - workflowProps, - contextWorkspace, - onViewContextDetail, - onRequestCollapse, - headerActionSlot, - topSlot, - historyProps, -}: GeneralWorkbenchSidebarSectionProps) { - if (!visible) { - return null; - } - - return ( - - ); -} diff --git a/src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx b/src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx deleted file mode 100644 index 2dc39f06c..000000000 --- a/src/components/agent/chat/workspace/WorkspaceCanvasContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ComponentProps, ReactNode } from "react"; -import { CanvasWorkbenchLayout } from "../components/CanvasWorkbenchLayout"; - -interface WorkspaceCanvasContentProps { - liveCanvasPreview: ReactNode; - currentImageWorkbenchActive: boolean; - shouldShowCanvasLoadingState: boolean; - teamWorkbenchView: ComponentProps["teamView"]; - canvasWorkbenchLayoutProps: Omit< - ComponentProps, - "teamView" - >; -} - -export function WorkspaceCanvasContent({ - liveCanvasPreview, - currentImageWorkbenchActive, - shouldShowCanvasLoadingState, - teamWorkbenchView, - canvasWorkbenchLayoutProps, -}: WorkspaceCanvasContentProps) { - if (!liveCanvasPreview && !teamWorkbenchView) { - return null; - } - - if (currentImageWorkbenchActive) { - return liveCanvasPreview; - } - - if (!teamWorkbenchView && shouldShowCanvasLoadingState) { - return liveCanvasPreview; - } - - return ( - - ); -} diff --git a/src/components/agent/chat/workspace/WorkspaceChatContent.tsx b/src/components/agent/chat/workspace/WorkspaceChatContent.tsx deleted file mode 100644 index 7eade5027..000000000 --- a/src/components/agent/chat/workspace/WorkspaceChatContent.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import type { ComponentProps, ReactNode } from "react"; -import { Info } from "lucide-react"; -import { StepProgress } from "@/lib/workspace/workbenchUi"; -import { EmptyState } from "../components/EmptyState"; -import { MessageList } from "../components/MessageList"; -import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; -import { WorkspacePendingA2UIPanel } from "./WorkspacePendingA2UIPanel"; -import { - ChatContainer, - ChatContainerInner, - ChatContent, - ChatInputSlot, - EntryBanner, - EntryBannerClose, - MessageViewport, -} from "./WorkspaceStyles"; -import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui"; -import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice"; - -interface WorkspaceChatContentProps { - entryBannerVisible: boolean; - entryBannerMessage?: string; - onDismissEntryBanner: () => void; - serviceSkillExecutionCard?: ReactNode; - stepProgressProps?: ComponentProps | null; - showChatLayout: boolean; - compactChrome: boolean; - contextWorkspaceEnabled: boolean; - generalWorkbenchMessageViewportBottomPadding?: string; - messageListProps: ComponentProps; - teamWorkspaceDockProps?: ComponentProps | null; - emptyStateProps: ComponentProps; - showWorkspaceAlert: boolean; - onSelectWorkspaceDirectory: () => void; - onDismissWorkspaceAlert: () => void; - pendingA2UIForm?: A2UIResponse | null; - onPendingA2UISubmit?: (formData: A2UIFormData) => void; - a2uiSubmissionNotice?: A2UISubmissionNoticeData | null; - showInlineInputbar: boolean; - inputbarNode: ReactNode; -} - -export function WorkspaceChatContent({ - entryBannerVisible, - entryBannerMessage, - onDismissEntryBanner, - serviceSkillExecutionCard, - stepProgressProps, - showChatLayout, - compactChrome, - contextWorkspaceEnabled, - generalWorkbenchMessageViewportBottomPadding, - messageListProps, - teamWorkspaceDockProps, - emptyStateProps, - showWorkspaceAlert, - onSelectWorkspaceDirectory, - onDismissWorkspaceAlert, - pendingA2UIForm, - onPendingA2UISubmit, - a2uiSubmissionNotice, - showInlineInputbar, - inputbarNode, -}: WorkspaceChatContentProps) { - const messageListNode = ( - - ); - - return ( - - - {entryBannerVisible && entryBannerMessage ? ( - - - {entryBannerMessage} - - 关闭 - - - ) : null} - - {stepProgressProps ? : null} - {serviceSkillExecutionCard} - - {showChatLayout ? ( - - <> - {contextWorkspaceEnabled ? ( - - {messageListNode} - - ) : ( - messageListNode - )} - {teamWorkspaceDockProps ? ( - - ) : null} - - - ) : ( - - )} - - {showChatLayout && ( - <> - {showWorkspaceAlert ? ( -
- - 工作区目录不存在,请重新选择一个本地目录后继续 - - - -
- ) : null} - - {showInlineInputbar ? ( - {inputbarNode} - ) : null} - - )} -
-
- ); -} diff --git a/src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx b/src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx deleted file mode 100644 index 2cb7bba09..000000000 --- a/src/components/agent/chat/workspace/WorkspaceContentSyncNotice.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; -import type { SyncStatus } from "../hooks/useContentSync"; -import { ContentSyncNotice, ContentSyncNoticeText } from "./WorkspaceStyles"; - -interface WorkspaceContentSyncNoticeProps { - status: Exclude; -} - -function resolveContentSyncNoticeMeta(status: Exclude): { - label: string; - Icon: typeof Loader2; - animated?: boolean; -} { - switch (status) { - case "syncing": - return { - label: "正在同步到当前内容…", - Icon: Loader2, - animated: true, - }; - case "success": - return { - label: "内容已同步", - Icon: CheckCircle2, - }; - case "error": - default: - return { - label: "同步失败,将自动重试", - Icon: AlertTriangle, - }; - } -} - -export function WorkspaceContentSyncNotice({ - status, -}: WorkspaceContentSyncNoticeProps) { - const notice = resolveContentSyncNoticeMeta(status); - const NoticeIcon = notice.Icon; - - return ( - - - {notice.label} - - ); -} diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index 1e55d8072..4bc7f571e 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -1,19 +1,39 @@ -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { AlertTriangle, CheckCircle2, Info, Loader2 } from "lucide-react"; import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; +import { StepProgress } from "@/lib/workspace/workbenchUi"; +import type { A2UIFormData, A2UIResponse } from "@/lib/workspace/a2ui"; +import { CanvasWorkbenchLayout } from "../components/CanvasWorkbenchLayout"; import { ChatNavbar } from "../components/ChatNavbar"; import { EmptyState } from "../components/EmptyState"; -import { WorkspaceChatContent } from "./WorkspaceChatContent"; -import { WorkspaceMainScene } from "./WorkspaceMainScene"; +import { MessageList } from "../components/MessageList"; +import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; +import { WorkspaceMainArea } from "./WorkspaceMainArea"; +import { WorkspacePendingA2UIPanel } from "./WorkspacePendingA2UIPanel"; import { buildWorkspaceEmptyStateProps, buildWorkspaceNavbarProps, } from "./chatSurfaceProps"; import { isCanvasStateEmpty } from "./generalWorkbenchHelpers"; +import type { SyncStatus } from "../hooks/useContentSync"; +import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice"; +import { + ChatContainer, + ChatContainerInner, + ChatContent, + ChatInputSlot, + ContentSyncNotice, + ContentSyncNoticeText, + EntryBanner, + EntryBannerClose, + MessageViewport, +} from "./WorkspaceStyles"; -type WorkspaceMainSceneProps = Omit< - ComponentProps, - "chatContent" | "chatNavbarProps" +type WorkspaceMainAreaProps = Omit< + ComponentProps, + "navbarNode" | "contentSyncNoticeNode" | "forceCanvasMode" | "chatContent" | "canvasContent" >; +type CanvasWorkbenchLayoutProps = ComponentProps; type ChatToolPreferences = { webSearch: boolean; thinking: boolean; @@ -21,38 +41,186 @@ type ChatToolPreferences = { subagent: boolean; }; type ChatToolPreferenceKey = keyof ChatToolPreferences; +type StepProgressProps = ComponentProps; +type MessageListProps = ComponentProps; +type TeamWorkspaceDockProps = ComponentProps; +type EmptyStateProps = ComponentProps; -interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps { +interface WorkspaceChatContentParams { entryBannerVisible: boolean; entryBannerMessage?: string; onDismissEntryBanner: () => void; - serviceSkillExecutionCard?: ComponentProps< - typeof WorkspaceChatContent - >["serviceSkillExecutionCard"]; - stepProgressProps?: ComponentProps< - typeof WorkspaceChatContent - >["stepProgressProps"]; + serviceSkillExecutionCard?: ReactNode; + stepProgressProps?: StepProgressProps | null; + showChatLayout: boolean; + compactChrome: boolean; + contextWorkspaceEnabled: boolean; + generalWorkbenchMessageViewportBottomPadding?: string; + messageListProps: MessageListProps; + teamWorkspaceDockProps?: TeamWorkspaceDockProps | null; + emptyStateProps: EmptyStateProps; + showWorkspaceAlert: boolean; + onSelectWorkspaceDirectory: () => void; + onDismissWorkspaceAlert: () => void; + pendingA2UIForm?: A2UIResponse | null; + onPendingA2UISubmit?: (formData: A2UIFormData) => void; + a2uiSubmissionNotice?: A2UISubmissionNoticeData | null; + showInlineInputbar: boolean; + inputbarNode: ReactNode; +} + +function resolveContentSyncNoticeMeta(status: Exclude): { + label: string; + Icon: typeof Loader2; + animated?: boolean; +} { + switch (status) { + case "syncing": + return { + label: "正在同步到当前内容…", + Icon: Loader2, + animated: true, + }; + case "success": + return { + label: "内容已同步", + Icon: CheckCircle2, + }; + case "error": + default: + return { + label: "同步失败,将自动重试", + Icon: AlertTriangle, + }; + } +} + +function renderWorkspaceChatContent({ + entryBannerVisible, + entryBannerMessage, + onDismissEntryBanner, + serviceSkillExecutionCard, + stepProgressProps, + showChatLayout, + compactChrome, + contextWorkspaceEnabled, + generalWorkbenchMessageViewportBottomPadding, + messageListProps, + teamWorkspaceDockProps, + emptyStateProps, + showWorkspaceAlert, + onSelectWorkspaceDirectory, + onDismissWorkspaceAlert, + pendingA2UIForm, + onPendingA2UISubmit, + a2uiSubmissionNotice, + showInlineInputbar, + inputbarNode, +}: WorkspaceChatContentParams): ReactNode { + const messageListNode = ( + + ); + + return ( + + + {entryBannerVisible && entryBannerMessage ? ( + + + {entryBannerMessage} + + 关闭 + + + ) : null} + + {stepProgressProps ? : null} + {serviceSkillExecutionCard} + + {showChatLayout ? ( + + <> + {contextWorkspaceEnabled ? ( + + {messageListNode} + + ) : ( + messageListNode + )} + {teamWorkspaceDockProps ? ( + + ) : null} + + + ) : ( + + )} + + {showChatLayout ? ( + <> + {showWorkspaceAlert ? ( +
+ + 工作区目录不存在,请重新选择一个本地目录后继续 + + + +
+ ) : null} + + {showInlineInputbar ? ( + {inputbarNode} + ) : null} + + ) : null} +
+
+ ); +} + +interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { + entryBannerVisible: boolean; + entryBannerMessage?: string; + onDismissEntryBanner: () => void; + serviceSkillExecutionCard?: WorkspaceChatContentParams["serviceSkillExecutionCard"]; + stepProgressProps?: WorkspaceChatContentParams["stepProgressProps"]; showChatLayout: boolean; contextWorkspaceEnabled: boolean; generalWorkbenchMessageViewportBottomPadding?: string; - messageListProps: ComponentProps< - typeof WorkspaceChatContent - >["messageListProps"]; - teamWorkspaceDockProps?: ComponentProps< - typeof WorkspaceChatContent - >["teamWorkspaceDockProps"]; + messageListProps: WorkspaceChatContentParams["messageListProps"]; + teamWorkspaceDockProps?: WorkspaceChatContentParams["teamWorkspaceDockProps"]; workspaceAlertVisible: boolean; onSelectWorkspaceDirectory: () => void; onDismissWorkspaceAlert: () => void; - pendingA2UIForm?: ComponentProps< - typeof WorkspaceChatContent - >["pendingA2UIForm"]; - onPendingA2UISubmit?: ComponentProps< - typeof WorkspaceChatContent - >["onPendingA2UISubmit"]; - a2uiSubmissionNotice?: ComponentProps< - typeof WorkspaceChatContent - >["a2uiSubmissionNotice"]; + pendingA2UIForm?: WorkspaceChatContentParams["pendingA2UIForm"]; + onPendingA2UISubmit?: WorkspaceChatContentParams["onPendingA2UISubmit"]; + a2uiSubmissionNotice?: WorkspaceChatContentParams["a2uiSubmissionNotice"]; shouldHideGeneralWorkbenchInputForTheme: boolean; input: ComponentProps["input"]; setInput: ComponentProps["setInput"]; @@ -148,6 +316,15 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps { typeof ChatNavbar >["contextCompactionRunning"]; onCompactContext?: ComponentProps["onCompactContext"]; + isThemeWorkbench: boolean; + contentId?: string; + syncStatus: SyncStatus; + hasLiveCanvasPreviewContent: boolean; + liveCanvasPreview: ReactNode; + currentImageWorkbenchActive: boolean; + shouldShowCanvasLoadingState: boolean; + teamWorkbenchView: CanvasWorkbenchLayoutProps["teamView"]; + canvasWorkbenchLayoutProps: Omit; } export function WorkspaceConversationScene({ @@ -296,34 +473,29 @@ export function WorkspaceConversationScene({ onOpenSettings, }); - const chatContent = ( - - ); + const chatContent = renderWorkspaceChatContent({ + entryBannerVisible, + entryBannerMessage, + onDismissEntryBanner, + serviceSkillExecutionCard, + stepProgressProps, + showChatLayout, + compactChrome, + contextWorkspaceEnabled, + generalWorkbenchMessageViewportBottomPadding, + messageListProps, + teamWorkspaceDockProps, + emptyStateProps, + showWorkspaceAlert: workspaceAlertVisible, + onSelectWorkspaceDirectory, + onDismissWorkspaceAlert, + pendingA2UIForm, + onPendingA2UISubmit, + a2uiSubmissionNotice, + showInlineInputbar: + !contextWorkspaceEnabled && !shouldHideGeneralWorkbenchInputForTheme, + inputbarNode, + }); const chatNavbarProps = buildWorkspaceNavbarProps({ visible: navbarVisible, @@ -353,22 +525,60 @@ export function WorkspaceConversationScene({ onOpenSettings, }); + const navbarNode = chatNavbarProps ? ( + + ) : null; + const shouldShowContentSyncNotice = + !isThemeWorkbench && + Boolean(contentId) && + (syncStatus === "syncing" || + syncStatus === "success" || + syncStatus === "error"); + const contentSyncNoticeNode = + shouldShowContentSyncNotice + ? (() => { + const notice = resolveContentSyncNoticeMeta(syncStatus); + const NoticeIcon = notice.Icon; + + return ( + + + {notice.label} + + ); + })() + : null; + const canvasContent = + !liveCanvasPreview && !teamWorkbenchView ? null : currentImageWorkbenchActive || + (!teamWorkbenchView && shouldShowCanvasLoadingState) ? ( + liveCanvasPreview + ) : ( + + ); + const forceCanvasMode = Boolean( + isThemeWorkbench && + (hasLiveCanvasPreviewContent || Boolean(teamWorkbenchView)), + ); + return ( - ; -type GeneralWorkbenchSidebarWorkflowProps = - GeneralWorkbenchSidebarSectionProps["workflowProps"]; -type GeneralWorkbenchSidebarHistoryProps = NonNullable< - GeneralWorkbenchSidebarSectionProps["historyProps"] +type GeneralWorkbenchSidebarWorkflowProps = Pick< + GeneralWorkbenchSidebarProps, + | "branchMode" + | "onNewTopic" + | "onSwitchTopic" + | "onDeleteTopic" + | "branchItems" + | "onSetBranchStatus" + | "workflowSteps" + | "onAddImage" + | "onImportDocument" + | "activityLogs" + | "creationTaskEvents" + | "onViewRunDetail" + | "activeRunDetail" + | "activeRunDetailLoading" >; +type GeneralWorkbenchSidebarHistoryProps = { + hasMore?: boolean; + loading?: boolean; + onLoadMore?: GeneralWorkbenchSidebarExecLogContract["onLoadMoreHistory"]; + skillDetailMap?: Record; + messages?: Message[]; +}; type GeneralWorkbenchHarnessSummary = Pick< ComponentProps, | "runState" @@ -27,7 +49,7 @@ interface WorkspaceGeneralWorkbenchSidebarProps { isThemeWorkbench: boolean; enablePanelCollapse: boolean; onRequestCollapse: NonNullable< - GeneralWorkbenchSidebarSectionProps["onRequestCollapse"] + GeneralWorkbenchSidebarProps["onRequestCollapse"] >; generalWorkbenchHarnessSummary: GeneralWorkbenchHarnessSummary | null; harnessPanelVisible: boolean; @@ -50,7 +72,7 @@ interface WorkspaceGeneralWorkbenchSidebarProps { activeRunDetailLoading: GeneralWorkbenchSidebarWorkflowProps["activeRunDetailLoading"]; }; contextWorkspace: ReturnType; - onViewContextDetail?: GeneralWorkbenchSidebarSectionProps["onViewContextDetail"]; + onViewContextDetail?: GeneralWorkbenchSidebarProps["onViewContextDetail"]; history?: { hasMore?: GeneralWorkbenchSidebarHistoryProps["hasMore"]; loading?: GeneralWorkbenchSidebarHistoryProps["loading"]; @@ -89,51 +111,48 @@ export function WorkspaceGeneralWorkbenchSidebar({ /> ) : null; + if (!visible) { + return null; + } + return ( - ); } diff --git a/src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx b/src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx deleted file mode 100644 index 44d02b4ed..000000000 --- a/src/components/agent/chat/workspace/WorkspaceHarnessDialog.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { ComponentProps } from "react"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { HarnessStatusPanel } from "../components/HarnessStatusPanel"; - -interface WorkspaceHarnessDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - maxWidth: string; - panelProps: ComponentProps; -} - -export function WorkspaceHarnessDialog({ - open, - onOpenChange, - maxWidth, - panelProps, -}: WorkspaceHarnessDialogProps) { - return ( - - - - - - ); -} diff --git a/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx b/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx index 21edc9d64..100e6b4ad 100644 --- a/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx +++ b/src/components/agent/chat/workspace/WorkspaceHarnessDialogs.tsx @@ -1,8 +1,8 @@ import type { ComponentProps } from "react"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; import { AgentRuntimeStrip } from "../components/AgentRuntimeStrip"; import { HarnessStatusPanel } from "../components/HarnessStatusPanel"; import { TeamMemoryShadowCard } from "../components/TeamMemoryShadowCard"; -import { WorkspaceHarnessDialog } from "./WorkspaceHarnessDialog"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; type HarnessPanelBaseProps = Pick< @@ -57,19 +57,25 @@ export function GeneralWorkbenchHarnessDialogSection({ } return ( - - ) : undefined, - }} - /> + + + + ) : undefined + } + /> + + ); } @@ -114,39 +120,43 @@ export function GeneralWorkbenchDialogSection({ } return ( - - - {teamMemorySnapshot ? ( - - ) : null} -
- ), - }} - /> + + + + + {teamMemorySnapshot ? ( + + ) : null} +
+ } + /> + + ); } diff --git a/src/components/agent/chat/workspace/WorkspaceInputbar.tsx b/src/components/agent/chat/workspace/WorkspaceInputbar.tsx deleted file mode 100644 index cbb7eaaf7..000000000 --- a/src/components/agent/chat/workspace/WorkspaceInputbar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { type ComponentProps, type ReactNode } from "react"; -import { Inputbar } from "../components/Inputbar"; -import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; - -interface WorkspaceInputbarProps { - inputbarProps: Omit, "overlayAccessory">; - accessory?: ReactNode; - teamWorkspaceDockProps?: ComponentProps | null; -} - -export function WorkspaceInputbar({ - inputbarProps, - accessory, - teamWorkspaceDockProps, -}: WorkspaceInputbarProps) { - const overlayAccessory = - accessory || teamWorkspaceDockProps ? ( - <> - {accessory} - {teamWorkspaceDockProps ? ( - - ) : null} - - ) : undefined; - - return ; -} diff --git a/src/components/agent/chat/workspace/WorkspaceMainScene.tsx b/src/components/agent/chat/workspace/WorkspaceMainScene.tsx deleted file mode 100644 index fb0094990..000000000 --- a/src/components/agent/chat/workspace/WorkspaceMainScene.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { ComponentProps, ReactNode } from "react"; -import type { SyncStatus } from "../hooks/useContentSync"; -import { ChatNavbar } from "../components/ChatNavbar"; -import { WorkspaceCanvasContent } from "./WorkspaceCanvasContent"; -import { WorkspaceContentSyncNotice } from "./WorkspaceContentSyncNotice"; -import { WorkspaceMainArea } from "./WorkspaceMainArea"; - -type WorkspaceCanvasContentProps = ComponentProps< - typeof WorkspaceCanvasContent ->; -type WorkspaceMainAreaProps = ComponentProps; - -interface WorkspaceMainSceneProps { - chatNavbarProps: ComponentProps | null; - isThemeWorkbench: boolean; - contentId?: string; - syncStatus: SyncStatus; - hasLiveCanvasPreviewContent: boolean; - liveCanvasPreview: ReactNode; - currentImageWorkbenchActive: WorkspaceCanvasContentProps["currentImageWorkbenchActive"]; - shouldShowCanvasLoadingState: WorkspaceCanvasContentProps["shouldShowCanvasLoadingState"]; - teamWorkbenchView: WorkspaceCanvasContentProps["teamWorkbenchView"]; - canvasWorkbenchLayoutProps: WorkspaceCanvasContentProps["canvasWorkbenchLayoutProps"]; - compactChrome: WorkspaceMainAreaProps["compactChrome"]; - shellBottomInset: WorkspaceMainAreaProps["shellBottomInset"]; - layoutMode: WorkspaceMainAreaProps["layoutMode"]; - chatContent: WorkspaceMainAreaProps["chatContent"]; - chatPanelWidth?: WorkspaceMainAreaProps["chatPanelWidth"]; - chatPanelMinWidth?: WorkspaceMainAreaProps["chatPanelMinWidth"]; - generalWorkbenchDialog: WorkspaceMainAreaProps["generalWorkbenchDialog"]; - generalWorkbenchHarnessDialog: WorkspaceMainAreaProps["generalWorkbenchHarnessDialog"]; - showFloatingInputOverlay: WorkspaceMainAreaProps["showFloatingInputOverlay"]; - hasPendingA2UIForm: WorkspaceMainAreaProps["hasPendingA2UIForm"]; - inputbarNode: WorkspaceMainAreaProps["inputbarNode"]; -} - -export function WorkspaceMainScene({ - chatNavbarProps, - isThemeWorkbench, - contentId, - syncStatus, - hasLiveCanvasPreviewContent, - liveCanvasPreview, - currentImageWorkbenchActive, - shouldShowCanvasLoadingState, - teamWorkbenchView, - canvasWorkbenchLayoutProps, - compactChrome, - shellBottomInset, - layoutMode, - chatContent, - chatPanelWidth, - chatPanelMinWidth, - generalWorkbenchDialog, - generalWorkbenchHarnessDialog, - showFloatingInputOverlay, - hasPendingA2UIForm, - inputbarNode, -}: WorkspaceMainSceneProps) { - const navbarNode = chatNavbarProps ? ( - - ) : null; - const contentSyncNoticeNode = - !isThemeWorkbench && contentId && syncStatus !== "idle" ? ( - - ) : null; - const canvasContent = ( - - ); - - return ( - - ); -} diff --git a/src/components/agent/chat/workspace/WorkspacePageShell.tsx b/src/components/agent/chat/workspace/WorkspacePageShell.tsx deleted file mode 100644 index 2829fecfd..000000000 --- a/src/components/agent/chat/workspace/WorkspacePageShell.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ComponentProps, ReactNode } from "react"; -import { PanelLeftOpen } from "lucide-react"; -import { ChatSidebar } from "../components/ChatSidebar"; -import { - PageContainer, - GeneralWorkbenchLeftExpandButton, -} from "./WorkspaceStyles"; - -interface WorkspacePageShellProps { - compactChrome: boolean; - isThemeWorkbench: boolean; - generalWorkbenchSidebarNode: ReactNode; - showChatPanel: boolean; - showSidebar: boolean; - chatSidebarProps: ComponentProps | null; - showGeneralWorkbenchLeftExpandButton: boolean; - onExpandGeneralWorkbenchSidebar: () => void; - mainAreaNode: ReactNode; -} - -export function WorkspacePageShell({ - compactChrome, - isThemeWorkbench, - generalWorkbenchSidebarNode, - showChatPanel, - showSidebar, - chatSidebarProps, - showGeneralWorkbenchLeftExpandButton, - onExpandGeneralWorkbenchSidebar, - mainAreaNode, -}: WorkspacePageShellProps) { - return ( - - {isThemeWorkbench ? ( - generalWorkbenchSidebarNode - ) : showChatPanel && showSidebar && chatSidebarProps ? ( - - ) : null} - {showGeneralWorkbenchLeftExpandButton ? ( - - - - ) : null} - - {mainAreaNode} - - ); -} diff --git a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx index fe324a6fb..41681c8d2 100644 --- a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx @@ -1,14 +1,21 @@ -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { PanelLeftOpen } from "lucide-react"; import { ChatSidebar } from "../components/ChatSidebar"; import { buildWorkspaceChatSidebarProps } from "./chatSurfaceProps"; -import { WorkspacePageShell } from "./WorkspacePageShell"; +import { + GeneralWorkbenchLeftExpandButton, + PageContainer, +} from "./WorkspaceStyles"; -type WorkspacePageShellProps = Omit< - ComponentProps, - "chatSidebarProps" ->; - -interface WorkspaceShellSceneProps extends WorkspacePageShellProps { +interface WorkspaceShellSceneProps { + compactChrome: boolean; + isThemeWorkbench: boolean; + generalWorkbenchSidebarNode: ReactNode; + showChatPanel: boolean; + showSidebar: boolean; + showGeneralWorkbenchLeftExpandButton: boolean; + onExpandGeneralWorkbenchSidebar: () => void; + mainAreaNode: ReactNode; sidebarContextVariant?: ComponentProps["contextVariant"]; currentTopicId: ComponentProps["currentTopicId"]; topics: ComponentProps["topics"]; @@ -87,18 +94,24 @@ export function WorkspaceShellScene({ : null; return ( - + + {isThemeWorkbench ? ( + generalWorkbenchSidebarNode + ) : showChatPanel && showSidebar && chatSidebarProps ? ( + + ) : null} + {showGeneralWorkbenchLeftExpandButton ? ( + + + + ) : null} + + {mainAreaNode} + ); } diff --git a/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts deleted file mode 100644 index 87bb6737b..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { - useEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; -import { getDefaultGuidePromptByTheme } from "../utils/defaultGuidePrompt"; -import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { MessageImage } from "../types"; -import type { ThemeType } from "@/lib/workspace/workbenchContract"; -import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; -import { isCanvasStateEmpty } from "./generalWorkbenchHelpers"; -import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; - -const shouldLogWorkspaceInfo = import.meta.env.MODE !== "test"; - -function logWorkspaceInfo(...args: Parameters) { - if (!shouldLogWorkspaceInfo) { - return; - } - console.log(...args); -} - -interface UseWorkspaceAutoGuideRuntimeParams { - contentId?: string | null; - sessionId?: string | null; - initialUserPrompt?: string; - initialUserImages?: MessageImage[]; - initialAutoSendRequestMetadata?: Record; - autoRunInitialPromptOnMount: boolean; - initialDispatchKey: string | null; - messagesCount: number; - projectReady: boolean; - systemPromptReady: boolean; - isSending: boolean; - canvasState: CanvasStateUnion | null; - isThemeWorkbench: boolean; - mappedTheme: ThemeType; - shouldUseCompactGeneralWorkbench: boolean; - shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt: boolean; - generalWorkbenchEntryCheckPending: boolean; - generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null; - chatToolPreferences: Pick; - setInput: Dispatch>; - handleSend: WorkspaceHandleSend; - triggerAIGuide: () => void; - onInitialUserPromptConsumed?: () => void; - hasTriggeredGuideRef: MutableRefObject; - consumedInitialPromptRef: MutableRefObject; -} - -export function useWorkspaceAutoGuideRuntime({ - contentId, - sessionId, - initialUserPrompt, - initialUserImages, - initialAutoSendRequestMetadata, - autoRunInitialPromptOnMount, - initialDispatchKey, - messagesCount, - projectReady, - systemPromptReady, - isSending, - canvasState, - isThemeWorkbench, - mappedTheme, - shouldUseCompactGeneralWorkbench, - shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt, - generalWorkbenchEntryCheckPending, - generalWorkbenchEntryPrompt, - chatToolPreferences, - setInput, - handleSend, - triggerAIGuide, - onInitialUserPromptConsumed, - hasTriggeredGuideRef, - consumedInitialPromptRef, -}: UseWorkspaceAutoGuideRuntimeParams) { - const triggerAIGuideRef = useRef(triggerAIGuide); - triggerAIGuideRef.current = triggerAIGuide; - - useEffect(() => { - if (shouldUseCompactGeneralWorkbench) { - return; - } - - const canvasEmpty = isCanvasStateEmpty(canvasState); - const pendingInitialPrompt = (initialUserPrompt || "").trim(); - const pendingInitialImages = initialUserImages || []; - const defaultGuidePrompt = - contentId && canvasEmpty && !isThemeWorkbench - ? getDefaultGuidePromptByTheme(mappedTheme) - : undefined; - - if ( - !contentId || - messagesCount > 0 || - !projectReady || - !systemPromptReady || - isSending || - !canvasEmpty - ) { - return; - } - - if (!initialDispatchKey && generalWorkbenchEntryCheckPending) { - return; - } - - if (initialDispatchKey) { - if ( - isThemeWorkbench && - pendingInitialImages.length === 0 && - !autoRunInitialPromptOnMount - ) { - return; - } - if (consumedInitialPromptRef.current === initialDispatchKey) { - return; - } - - let disposed = false; - consumedInitialPromptRef.current = initialDispatchKey; - hasTriggeredGuideRef.current = true; - logWorkspaceInfo("[AgentChatPage] 自动发送首条创作意图消息"); - - void (async () => { - const started = await handleSend( - pendingInitialImages, - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - pendingInitialPrompt, - undefined, - undefined, - initialAutoSendRequestMetadata - ? { - requestMetadata: initialAutoSendRequestMetadata, - } - : undefined, - ); - if (disposed) { - return; - } - if (!started) { - consumedInitialPromptRef.current = null; - return; - } - onInitialUserPromptConsumed?.(); - })(); - - return () => { - disposed = true; - }; - } - - if (hasTriggeredGuideRef.current) { - return; - } - - if (generalWorkbenchEntryPrompt?.kind === "resume") { - return; - } - - if (defaultGuidePrompt) { - hasTriggeredGuideRef.current = true; - setInput((previous) => previous.trim() || defaultGuidePrompt); - return; - } - - if (isThemeWorkbench) { - if (shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt) { - return; - } - - hasTriggeredGuideRef.current = true; - logWorkspaceInfo("[AgentChatPage] 工作区上下文:触发 AI 引导"); - - triggerAIGuideRef.current(); - return; - } - - hasTriggeredGuideRef.current = true; - logWorkspaceInfo("[AgentChatPage] 自动触发 AI 创作引导"); - triggerAIGuideRef.current(); - }, [ - canvasState, - chatToolPreferences.thinking, - chatToolPreferences.webSearch, - contentId, - handleSend, - initialDispatchKey, - initialAutoSendRequestMetadata, - initialUserImages, - initialUserPrompt, - autoRunInitialPromptOnMount, - isSending, - isThemeWorkbench, - mappedTheme, - messagesCount, - onInitialUserPromptConsumed, - projectReady, - setInput, - shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt, - shouldUseCompactGeneralWorkbench, - systemPromptReady, - generalWorkbenchEntryCheckPending, - generalWorkbenchEntryPrompt, - consumedInitialPromptRef, - hasTriggeredGuideRef, - ]); - - useEffect(() => { - const pendingInitialPrompt = (initialUserPrompt || "").trim(); - const pendingInitialImages = initialUserImages || []; - - if ( - shouldUseCompactGeneralWorkbench || - !initialDispatchKey || - contentId || - !sessionId || - messagesCount > 0 || - isSending - ) { - return; - } - - if (consumedInitialPromptRef.current === initialDispatchKey) { - return; - } - - let disposed = false; - consumedInitialPromptRef.current = initialDispatchKey; - - void (async () => { - const started = await handleSend( - pendingInitialImages, - chatToolPreferences.webSearch, - chatToolPreferences.thinking, - pendingInitialPrompt, - undefined, - undefined, - initialAutoSendRequestMetadata - ? { - requestMetadata: initialAutoSendRequestMetadata, - } - : undefined, - ); - if (disposed) { - return; - } - if (!started) { - consumedInitialPromptRef.current = null; - return; - } - onInitialUserPromptConsumed?.(); - })(); - - return () => { - disposed = true; - }; - }, [ - chatToolPreferences.thinking, - chatToolPreferences.webSearch, - contentId, - handleSend, - initialDispatchKey, - initialAutoSendRequestMetadata, - initialUserImages, - initialUserPrompt, - isSending, - messagesCount, - onInitialUserPromptConsumed, - sessionId, - shouldUseCompactGeneralWorkbench, - consumedInitialPromptRef, - ]); - - useEffect(() => { - hasTriggeredGuideRef.current = false; - consumedInitialPromptRef.current = null; - }, [contentId, consumedInitialPromptRef, hasTriggeredGuideRef]); -} diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index 8cc1cf953..22fec9384 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -25,6 +25,7 @@ import type { Artifact } from "@/lib/artifact/types"; import type { Character } from "@/lib/api/memory"; import type { TaskFile } from "../components/TaskFiles"; import type { WorkspacePathMissingState } from "../hooks/agentChatShared"; +import type { SyncStatus } from "../hooks/useContentSync"; import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; import { buildStepProgressProps, @@ -271,7 +272,7 @@ interface UseWorkspaceConversationSceneRuntimeParams { harnessToggleLabel: ConversationScenePresentationParams["scene"]["harnessToggleLabel"]; isAutoRestoringSession: boolean; sessionId: string | null | undefined; - syncStatus: ConversationScenePresentationParams["scene"]["syncStatus"]; + syncStatus: SyncStatus; pendingA2UIForm: ConversationScenePresentationParams["scene"]["pendingA2UIForm"]; pendingA2UISource: PendingA2UISource | null; a2uiSubmissionNotice: ConversationScenePresentationParams["scene"]["a2uiSubmissionNotice"]; diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx index b2ed57a20..dacf3da30 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -6,8 +6,9 @@ import { type ReactNode, type SetStateAction, } from "react"; +import { Info } from "lucide-react"; +import styled from "styled-components"; import type { Character } from "@/lib/api/memory"; -import { GeneralWorkbenchEntryPromptAccessory } from "../components/GeneralWorkbenchEntryPromptAccessory"; import { Inputbar } from "../components/Inputbar"; import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; @@ -23,8 +24,142 @@ import { isRenderableTaskFile } from "./generalWorkbenchHelpers"; import { GeneralWorkbenchDialogSection, } from "./WorkspaceHarnessDialogs"; -import { WorkspaceInputbar } from "./WorkspaceInputbar"; import type { TeamWorkbenchSurfaceProps } from "./chatSurfaceProps"; +import type { GeneralWorkbenchEntryPromptState } from "./workspaceSendHelpers"; + +interface GeneralWorkbenchEntryPromptAccessoryProps { + prompt: GeneralWorkbenchEntryPromptState; + onRestart: () => void; + onContinue: () => Promise | void; +} + +const GeneralWorkbenchEntryPromptCard = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + min-width: min(360px, calc(100vw - 48px)); + max-width: min(420px, calc(100vw - 48px)); + padding: 12px 14px; + border-radius: 18px; + border: 1px solid rgba(191, 219, 254, 0.92); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(239, 246, 255, 0.96) 100% + ); + color: #0f172a; + box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26); +`; + +const GeneralWorkbenchEntryPromptHeader = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; +`; + +const GeneralWorkbenchEntryPromptTitleWrap = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +`; + +const GeneralWorkbenchEntryPromptTitle = styled.span` + font-size: 13px; + font-weight: 700; + line-height: 1.4; +`; + +const GeneralWorkbenchEntryPromptDescription = styled.span` + font-size: 12px; + line-height: 1.5; + color: #475569; +`; + +const GeneralWorkbenchEntryPromptActions = styled.div` + display: flex; + justify-content: flex-end; + gap: 8px; +`; + +const GeneralWorkbenchEntryPromptButton = styled.button<{ + $variant?: "primary" | "ghost"; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 88px; + height: 32px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid + ${({ $variant }) => + $variant === "ghost" + ? "rgba(191, 219, 254, 0.92)" + : "rgba(59, 130, 246, 0.94)"}; + background: ${({ $variant }) => + $variant === "ghost" + ? "rgba(255, 255, 255, 0.92)" + : "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"}; + color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")}; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + transform 0.16s ease, + box-shadow 0.2s ease, + background 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46); + background: ${({ $variant }) => + $variant === "ghost" + ? "rgba(239, 246, 255, 0.98)" + : "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"}; + } +`; + +function renderGeneralWorkbenchEntryPromptAccessory({ + prompt, + onRestart, + onContinue, +}: GeneralWorkbenchEntryPromptAccessoryProps): ReactNode { + return ( + + + + + + {prompt.title} + + + {prompt.description} + + + + + + 重新开始 + + { + void onContinue(); + }} + > + {prompt.actionLabel} + + + + ); +} type WorkspaceInputbarBuilderParams = Omit< ComponentProps, @@ -84,9 +219,7 @@ interface UseWorkspaceInputbarScenePresentationRuntimeParams { | "onSelectCharacter" >; floatingTeamWorkspaceDock: FloatingTeamWorkspaceDockParams; - generalWorkbenchEntryPrompt: - | ComponentProps["prompt"] - | null; + generalWorkbenchEntryPrompt: GeneralWorkbenchEntryPromptState | null; onRestartGeneralWorkbenchEntryPrompt: () => void; onContinueGeneralWorkbenchEntryPrompt: () => Promise | void; generalWorkbenchDialog: ComponentProps; @@ -209,11 +342,12 @@ function useWorkspaceInputbarScenePresentationRuntime({ const generalWorkbenchEntryPromptAccessory = useMemo( () => inputbarPresentation.generalWorkbenchEntryPrompt ? ( - + renderGeneralWorkbenchEntryPromptAccessory({ + prompt: inputbarPresentation.generalWorkbenchEntryPrompt, + onRestart: inputbarPresentation.onRestartGeneralWorkbenchEntryPrompt, + onContinue: + inputbarPresentation.onContinueGeneralWorkbenchEntryPrompt, + }) ) : null, [ inputbarPresentation.generalWorkbenchEntryPrompt, @@ -259,11 +393,19 @@ function useWorkspaceInputbarScenePresentationRuntime({ ], ); + const overlayAccessory = + generalWorkbenchEntryPromptAccessory || floatingTeamWorkspaceDockProps ? ( + <> + {generalWorkbenchEntryPromptAccessory} + {floatingTeamWorkspaceDockProps ? ( + + ) : null} + + ) : undefined; const inputbarNode = ( - ); const generalWorkbenchDialog = ( diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index d942698fd..3f890e107 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { Message } from "../types"; +import type { InitialDispatchPreviewSnapshot } from "./workspaceSendHelpers"; import { listMentionEntryUsage, recordMentionEntryUsage, @@ -316,6 +317,16 @@ function createExistingMessages(count: number): Message[] { })); } +function createBootstrapDispatchSnapshot( + prompt = "请开始处理这个任务", +): InitialDispatchPreviewSnapshot { + return { + key: "bootstrap-dispatch-1", + prompt, + images: [], + }; +} + function mountHook(initialProps?: Partial): HookHarness { const container = document.createElement("div"); document.body.appendChild(container); @@ -356,7 +367,7 @@ function mountHook(initialProps?: Partial): HookHarness { contentId: null, workspaceRequestMetadataBase: undefined, messages: [], - bootstrapDispatchPreviewMessages: [], + bootstrapDispatchPreview: null, sendMessage: mockSendMessage, resolveSendBoundary: (({ sourceText }) => ({ sourceText, @@ -473,15 +484,21 @@ describe("useWorkspaceSendActions", () => { }); it("无真实消息时应透传 bootstrap 预览消息", () => { - const bootstrapPreviewMessages = createExistingMessages(2); const harness = mountHook({ - bootstrapDispatchPreviewMessages: bootstrapPreviewMessages, + bootstrapDispatchPreview: createBootstrapDispatchSnapshot(), }); try { - expect(harness.getValue().displayMessages).toEqual( - bootstrapPreviewMessages, - ); + expect(harness.getValue().displayMessages).toHaveLength(2); + expect(harness.getValue().displayMessages[0]).toMatchObject({ + role: "user", + content: "请开始处理这个任务", + }); + expect(harness.getValue().displayMessages[1]).toMatchObject({ + role: "assistant", + content: "正在开始处理任务…", + isThinking: true, + }); } finally { harness.unmount(); } diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 6098eec31..f16e2c64e 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -62,16 +62,18 @@ import { type ChatToolPreferences, } from "../utils/chatToolPreferences"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; -import type { GeneralWorkbenchSendBoundaryState } from "../hooks/useGeneralWorkbenchSendBoundary"; import type { UseRuntimeTeamFormationResult } from "../hooks/useRuntimeTeamFormation"; import type { SendMessageFn } from "../hooks/agentChatShared"; import type { Message, MessageImage } from "../types"; import type { TeamDefinition } from "../utils/teamDefinitions"; import type { AgentAccessMode } from "../hooks/agentChatStorage"; import { + buildInitialDispatchPreviewMessages, buildRuntimeTeamDispatchPreview, buildRuntimeTeamDispatchPreviewMessages, buildSubmissionPreviewMessages, + type GeneralWorkbenchSendBoundaryState, + type InitialDispatchPreviewSnapshot, resolveRuntimeTeamDispatchPreviewState, type RuntimeTeamDispatchPreviewSnapshot, createSubmissionPreviewSnapshot, @@ -2323,7 +2325,7 @@ interface UseWorkspaceSendActionsParams { browserAssistAutoLaunch?: boolean | null; workspaceRequestMetadataBase?: Record; messages: Message[]; - bootstrapDispatchPreviewMessages: Message[]; + bootstrapDispatchPreview?: InitialDispatchPreviewSnapshot | null; sendMessage: SendMessageFn; resolveSendBoundary: (input: { sourceText: string; @@ -2430,7 +2432,7 @@ export function useWorkspaceSendActions({ browserAssistAutoLaunch, workspaceRequestMetadataBase, messages, - bootstrapDispatchPreviewMessages, + bootstrapDispatchPreview, sendMessage, resolveSendBoundary, finalizeAfterSendSuccess, @@ -2471,6 +2473,13 @@ export function useWorkspaceSendActions({ : [], [messagesCount, submissionPreview], ); + const bootstrapDispatchPreviewMessages = useMemo( + () => + bootstrapDispatchPreview + ? buildInitialDispatchPreviewMessages(bootstrapDispatchPreview) + : [], + [bootstrapDispatchPreview], + ); const displayMessages = useMemo(() => { if (runtimeTeamDispatchPreviewMessages.length > 0) { return [...messages, ...runtimeTeamDispatchPreviewMessages]; diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts index 9fce91ea7..805ae6e1f 100644 --- a/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { + buildGeneralWorkbenchSendBoundaryState, + buildGeneralWorkbenchResumePromptFromRunState, + buildInitialDispatchKey, + buildInitialDispatchPreviewMessages, buildRuntimeTeamDispatchPreviewMessages, buildSubmissionPreviewMessages, createSubmissionPreviewSnapshot, @@ -7,6 +11,107 @@ import { } from "./workspaceSendHelpers"; describe("workspaceSendHelpers runtime team preview", () => { + it("initialDispatchKey 应稳定编码首轮 prompt 与图片签名", () => { + expect( + buildInitialDispatchKey("写一篇文章", [ + { data: "abcdef1234567890", mediaType: "image/png" }, + ]), + ).toContain("写一篇文章"); + }); + + it("bootstrap 预览消息应使用统一 initial-dispatch 结构", () => { + const messages = buildInitialDispatchPreviewMessages({ + key: "initial-dispatch-1", + prompt: "请开始处理这个任务", + images: [], + }); + + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + id: "initial-dispatch:initial-dispatch-1:user", + role: "user", + content: "请开始处理这个任务", + }); + expect(messages[1]).toMatchObject({ + id: "initial-dispatch:initial-dispatch-1:assistant", + role: "assistant", + content: "正在开始处理任务…", + isThinking: true, + }); + }); + + it("工作区首条创作意图应包装成 current send boundary", () => { + const boundary = buildGeneralWorkbenchSendBoundaryState({ + isThemeWorkbench: true, + contentId: "content-1", + initialDispatchKey: "dispatch-1", + consumedInitialPromptKey: null, + initialUserImages: [], + mappedTheme: "general", + socialArticleSkillKey: "content_post_with_cover", + sourceText: "请生成今天的社媒主稿", + }); + + expect(boundary).toMatchObject({ + sourceText: "/content_post_with_cover 请生成今天的社媒主稿", + shouldConsumePendingGeneralWorkbenchInitialPrompt: true, + shouldDismissGeneralWorkbenchEntryPrompt: true, + browserRequirementMatch: null, + }); + }); + + it("浏览器任务应在 current send boundary 中保留 requirement 检测", () => { + const boundary = buildGeneralWorkbenchSendBoundaryState({ + isThemeWorkbench: true, + contentId: "content-1", + initialDispatchKey: "dispatch-1", + consumedInitialPromptKey: null, + initialUserImages: [], + mappedTheme: "general", + socialArticleSkillKey: "content_post_with_cover", + sourceText: "帮我把这篇文章发布到微信公众号后台", + }); + + expect(boundary.sourceText).toBe( + "/content_post_with_cover 帮我把这篇文章发布到微信公众号后台", + ); + expect(boundary.browserRequirementMatch).toEqual( + expect.objectContaining({ + requirement: "required_with_user_step", + launchUrl: "https://mp.weixin.qq.com/", + platformLabel: "微信公众号后台", + }), + ); + }); + + it("run-state 应生成 resume prompt", () => { + const prompt = buildGeneralWorkbenchResumePromptFromRunState({ + run_state: "auto_running", + current_gate_key: "write_mode", + queue_items: [ + { + run_id: "run-1", + title: "撰写主稿", + gate_key: "write_mode", + status: "running", + source: "skill", + source_ref: null, + started_at: new Date().toISOString(), + }, + ], + latest_terminal: null, + recent_terminals: [], + updated_at: new Date().toISOString(), + }); + + expect(prompt).toMatchObject({ + kind: "resume", + title: "发现上次未完成任务", + actionLabel: "继续上次任务", + description: expect.stringContaining("撰写主稿"), + }); + }); + it("应在失败预览中覆盖 formationState 的错误信息", () => { const state = resolveRuntimeTeamDispatchPreviewState({ key: "runtime-team-failed", diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.ts index 4c23e840a..d9728a543 100644 --- a/src/components/agent/chat/workspace/workspaceSendHelpers.ts +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.ts @@ -1,4 +1,13 @@ import { preheatBrowserAssistInBackground } from "../utils/browserAssistPreheat"; +import { + detectBrowserTaskRequirement, + type BrowserTaskRequirementMatch, +} from "../utils/browserTaskRequirement"; +import type { + GeneralWorkbenchRunState, + GeneralWorkbenchRunTerminalItem, + GeneralWorkbenchRunTodoItem, +} from "@/lib/api/executionRun"; import { buildHarnessRequestMetadata, extractExistingHarnessMetadata, @@ -52,6 +61,33 @@ export interface RuntimeTeamDispatchPreviewSnapshot { failureMessage?: string | null; } +export interface InitialDispatchPreviewSnapshot { + key: string; + prompt?: string; + images: MessageImage[]; +} + +export function buildInitialDispatchKey( + prompt?: string, + images?: MessageImage[], +): string | null { + const normalizedPrompt = (prompt || "").trim(); + const normalizedImages = images || []; + + if (!normalizedPrompt && normalizedImages.length === 0) { + return null; + } + + const imageSignature = normalizedImages + .map( + (image, index) => + `${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`, + ) + .join("|"); + + return `${normalizedPrompt}::${imageSignature}`; +} + export interface SubmissionPreviewSnapshot { key: string; prompt: string; @@ -66,6 +102,34 @@ export interface ContextWorkspaceSummary { prepareActiveContextPrompt: () => Promise; } +export interface GeneralWorkbenchEntryPromptState { + kind: "initial_prompt" | "resume"; + signature: string; + title: string; + description: string; + actionLabel: string; + prompt: string; +} + +interface BuildGeneralWorkbenchSendBoundaryStateOptions { + isThemeWorkbench: boolean; + contentId?: string; + initialDispatchKey: string | null; + consumedInitialPromptKey: string | null; + initialUserImages?: MessageImage[]; + mappedTheme: string; + socialArticleSkillKey: string; + sourceText: string; + sendOptions?: HandleSendOptions; +} + +export interface GeneralWorkbenchSendBoundaryState { + sourceText: string; + browserRequirementMatch: BrowserTaskRequirementMatch | null; + shouldConsumePendingGeneralWorkbenchInitialPrompt: boolean; + shouldDismissGeneralWorkbenchEntryPrompt: boolean; +} + type PreparedActiveContextPromptResult = | { ok: true; @@ -76,6 +140,71 @@ type PreparedActiveContextPromptResult = error: unknown; }; +function resolveGeneralWorkbenchGateLabel( + gateKey?: GeneralWorkbenchRunTodoItem["gate_key"], +): string | null { + switch (gateKey) { + case "topic_select": + return "选题确认"; + case "write_mode": + return "写作推进"; + case "publish_confirm": + return "发布确认"; + case null: + case undefined: + default: + return null; + } +} + +function resolveGeneralWorkbenchPendingRunCandidate( + state: GeneralWorkbenchRunState | null, +): GeneralWorkbenchRunTodoItem | GeneralWorkbenchRunTerminalItem | null { + if (!state) { + return null; + } + + const activeQueueItem = (state.queue_items || []).find((item) => + ["queued", "running", "error", "timeout"].includes(item.status), + ); + if (activeQueueItem) { + return activeQueueItem; + } + + if ( + state.latest_terminal && + ["queued", "running", "error", "timeout"].includes( + state.latest_terminal.status, + ) + ) { + return state.latest_terminal; + } + + return null; +} + +export function buildGeneralWorkbenchResumePromptFromRunState( + state: GeneralWorkbenchRunState | null, +): GeneralWorkbenchEntryPromptState | null { + const pendingRun = resolveGeneralWorkbenchPendingRunCandidate(state); + if (!pendingRun) { + return null; + } + + const runTitle = pendingRun.title?.trim() || "最近一次创作任务"; + const gateLabel = resolveGeneralWorkbenchGateLabel(pendingRun.gate_key); + const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : ""; + + return { + kind: "resume", + signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`, + title: "发现上次未完成任务", + description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`, + actionLabel: "继续上次任务", + prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`, + }; +} + function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; @@ -206,6 +335,55 @@ function readExistingTeamMemoryShadow( }; } +export function buildGeneralWorkbenchSendBoundaryState({ + isThemeWorkbench, + contentId, + initialDispatchKey, + consumedInitialPromptKey, + initialUserImages, + mappedTheme, + socialArticleSkillKey, + sourceText, + sendOptions, +}: BuildGeneralWorkbenchSendBoundaryStateOptions): GeneralWorkbenchSendBoundaryState { + const shouldConsumePendingGeneralWorkbenchInitialPrompt = + isThemeWorkbench && + Boolean(contentId) && + Boolean(initialDispatchKey) && + consumedInitialPromptKey !== initialDispatchKey && + (initialUserImages || []).length === 0 && + !sendOptions?.purpose; + const shouldDismissGeneralWorkbenchEntryPrompt = + isThemeWorkbench && !sendOptions?.purpose; + + const trimmedSourceText = sourceText.trim(); + const shouldWrapWithGeneralWorkbenchSkill = + isThemeWorkbench && + mappedTheme === "general" && + !sendOptions?.purpose && + trimmedSourceText.length > 0 && + !trimmedSourceText.startsWith("/") && + !trimmedSourceText.startsWith("@"); + const nextSourceText = shouldWrapWithGeneralWorkbenchSkill + ? `/${socialArticleSkillKey} ${trimmedSourceText}` + : sourceText; + const browserRequirementSourceText = shouldWrapWithGeneralWorkbenchSkill + ? trimmedSourceText + : nextSourceText; + + const browserRequirementMatch = + mappedTheme === "general" && !sendOptions?.purpose + ? detectBrowserTaskRequirement(browserRequirementSourceText) + : null; + + return { + sourceText: nextSourceText, + browserRequirementMatch, + shouldConsumePendingGeneralWorkbenchInitialPrompt, + shouldDismissGeneralWorkbenchEntryPrompt, + }; +} + export interface EnsureBrowserAssistCanvasOptions { silent?: boolean; navigationMode?: "none" | "explicit-url" | "best-effort"; @@ -784,6 +962,41 @@ export function buildRuntimeTeamDispatchPreviewMessages( ]; } +export function buildInitialDispatchPreviewMessages( + snapshot: InitialDispatchPreviewSnapshot, + assistantPreviewText?: string, +): Message[] { + const normalizedPrompt = (snapshot.prompt || "").trim(); + const normalizedImages = snapshot.images || []; + + if (!normalizedPrompt && normalizedImages.length === 0) { + return []; + } + + const timestamp = new Date(); + const normalizedAssistantPreviewText = + assistantPreviewText?.trim() || "正在开始处理任务…"; + const isAssistantThinking = + normalizedAssistantPreviewText === "正在开始处理任务…"; + + return [ + { + id: `initial-dispatch:${snapshot.key}:user`, + role: "user", + content: normalizedPrompt, + images: normalizedImages.length > 0 ? normalizedImages : undefined, + timestamp, + }, + { + id: `initial-dispatch:${snapshot.key}:assistant`, + role: "assistant", + content: normalizedAssistantPreviewText, + timestamp: new Date(timestamp.getTime() + 1), + isThinking: isAssistantThinking, + }, + ]; +} + interface CreateSubmissionPreviewSnapshotOptions { key: string; prompt: string; diff --git a/src/components/agent/chat/workspace/workspaceTreeVisibility.ts b/src/components/agent/chat/workspace/workspaceTreeVisibility.ts index ca2248862..712d58a65 100644 --- a/src/components/agent/chat/workspace/workspaceTreeVisibility.ts +++ b/src/components/agent/chat/workspace/workspaceTreeVisibility.ts @@ -1,5 +1,4 @@ -import type { FileEntry } from "@/components/terminal/widgets/types"; -import type { DirectoryListing } from "@/lib/api/fileBrowser"; +import type { DirectoryListing, FileEntry } from "@/lib/api/fileBrowser"; const ROOT_HIDDEN_DIRECTORY_NAMES = new Set([".lime", "output"]); const GLOBAL_HIDDEN_ENTRY_NAMES = new Set([".DS_Store", "Thumbs.db"]); diff --git a/src/components/image-gen/ImageGenPage.test.tsx b/src/components/image-gen/ImageGenPage.test.tsx deleted file mode 100644 index 4f68249ea..000000000 --- a/src/components/image-gen/ImageGenPage.test.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { act } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Page, PageParams } from "@/types/page"; -import type { GeneratedImage } from "./types"; -import { - cleanupMountedRoots, - flushEffects, - renderIntoDom, - setReactActEnvironment, - silenceConsole, - type MountedRoot, -} from "./test-utils"; - -const { - mockBackfillImagesToResource, - mockGenerateImage, - mockDeleteImage, - mockNewImage, - mockToast, -} = vi.hoisted(() => ({ - mockBackfillImagesToResource: vi.fn(), - mockGenerateImage: vi.fn(), - mockDeleteImage: vi.fn(), - mockNewImage: vi.fn(), - mockToast: { - success: vi.fn(), - error: vi.fn(), - info: vi.fn(), - }, -})); - -vi.mock("sonner", () => ({ - toast: mockToast, -})); - -vi.mock("@/hooks/useProjects", () => ({ - useProjects: () => { - const defaultProject = { - id: "project-default", - name: "默认项目", - workspaceType: "persistent", - rootPath: "/tmp/default", - isDefault: true, - icon: undefined, - color: undefined, - isFavorite: false, - isArchived: false, - tags: [], - createdAt: Date.now(), - updatedAt: Date.now(), - }; - return { - projects: [defaultProject], - filteredProjects: [defaultProject], - defaultProject, - loading: false, - error: null, - filter: {}, - setFilter: vi.fn(), - refresh: vi.fn(), - create: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - getOrCreateDefault: vi.fn(), - }; - }, -})); - -vi.mock("@/hooks/useProject", () => ({ - useProject: () => ({ - project: { - id: "project-default", - name: "默认项目", - workspaceType: "persistent", - rootPath: "/tmp/default", - isDefault: true, - settings: {}, - isFavorite: false, - isArchived: false, - tags: [], - createdAt: Date.now(), - updatedAt: Date.now(), - }, - loading: false, - error: null, - refresh: vi.fn(), - update: vi.fn(), - archive: vi.fn(), - unarchive: vi.fn(), - toggleFavorite: vi.fn(), - }), -})); - -vi.mock("./useImageGen", async () => { - const React = await import("react"); - - const images: GeneratedImage[] = [ - { - id: "img-1", - url: "https://example.com/1.png", - prompt: "第一张提示词", - model: "fal-ai/nano-banana-pro", - size: "1024x1024", - providerId: "fal", - providerName: "Fal", - createdAt: 1700000000000, - status: "complete", - }, - { - id: "img-2", - url: "https://example.com/2.png", - prompt: "第二张提示词", - model: "fal-ai/nano-banana-pro", - size: "1024x1024", - providerId: "fal", - providerName: "Fal", - createdAt: 1700000001000, - status: "complete", - }, - ]; - - return { - useImageGen: () => { - const [selectedImageId, setSelectedImageId] = React.useState< - string | null - >(images[0].id); - const selectedImage = - images.find((image) => image.id === selectedImageId) ?? images[0]; - - return { - availableProviders: [ - { - id: "fal", - type: "fal", - name: "Fal", - enabled: true, - api_key_count: 1, - api_host: "https://fal.run", - }, - ], - selectedProvider: { - id: "fal", - type: "fal", - name: "Fal", - enabled: true, - api_key_count: 1, - api_host: "https://fal.run", - }, - selectedProviderId: "fal", - setSelectedProviderId: vi.fn(), - providersLoading: false, - availableModels: [ - { - id: "fal-ai/nano-banana-pro", - name: "Nano Banana Pro", - supportedSizes: ["1024x1024"], - }, - ], - selectedModel: { - id: "fal-ai/nano-banana-pro", - name: "Nano Banana Pro", - supportedSizes: ["1024x1024"], - }, - selectedModelId: "fal-ai/nano-banana-pro", - setSelectedModelId: vi.fn(), - selectedSize: "1024x1024", - setSelectedSize: vi.fn(), - images, - selectedImage, - selectedImageId, - setSelectedImageId, - generating: false, - savingToResource: false, - generateImage: mockGenerateImage, - backfillImagesToResource: mockBackfillImagesToResource, - deleteImage: mockDeleteImage, - newImage: mockNewImage, - }; - }, - }; -}); - -import { ImageGenPage } from "./ImageGenPage"; - -const mountedRoots: MountedRoot[] = []; - -function renderPage( - onNavigate?: (page: Page, params?: PageParams) => void, -): HTMLDivElement { - return renderIntoDom(, mountedRoots) - .container; -} - -function findButtonByText( - container: HTMLElement, - text: string, -): HTMLButtonElement { - const target = Array.from(container.querySelectorAll("button")).find((node) => - node.textContent?.includes(text), - ); - if (!target) { - throw new Error(`未找到按钮: ${text}`); - } - return target as HTMLButtonElement; -} - -function findButtonByAriaLabel( - container: HTMLElement, - ariaLabel: string, -): HTMLButtonElement { - const target = container.querySelector( - `button[aria-label='${ariaLabel}']`, - ) as HTMLButtonElement | null; - if (!target) { - throw new Error(`未找到 tips 按钮: ${ariaLabel}`); - } - return target; -} - -async function hoverElement(element: HTMLElement) { - await act(async () => { - element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - await Promise.resolve(); - }); -} - -async function leaveElement(element: HTMLElement) { - await act(async () => { - element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); - await Promise.resolve(); - }); -} - -function getPromptChip(container: HTMLElement): HTMLButtonElement { - const label = Array.from(container.querySelectorAll("div")).find( - (node) => node.textContent === "当前图片提示词", - ); - if (!label || !label.parentElement) { - throw new Error("未找到提示词历史区域"); - } - - const chip = label.parentElement.querySelector("button"); - if (!chip) { - throw new Error("未找到提示词历史按钮"); - } - return chip as HTMLButtonElement; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -beforeEach(() => { - setReactActEnvironment(); - - localStorage.clear(); - vi.clearAllMocks(); - silenceConsole(); - mockBackfillImagesToResource.mockResolvedValue({ - total: 2, - saved: 2, - failed: 0, - skipped: 0, - errors: [], - }); -}); - -afterEach(() => { - cleanupMountedRoots(mountedRoots); - vi.restoreAllMocks(); - localStorage.clear(); -}); - -describe("ImageGenPage", () => { - it("AI 生图区应把静态说明文案收进 tips", async () => { - const container = renderPage(); - - await flushEffects(); - - expect(container.textContent).not.toContain( - "左侧集中管理模型、参考图与输出规格;主画布负责预览结果与继续迭代。", - ); - expect(container.textContent).not.toContain("当前服务商:Fal"); - - const introTip = findButtonByAriaLabel(container, "插图生成参数说明"); - await hoverElement(introTip); - expect(document.body.textContent).toContain( - "左侧集中管理模型、参考图与输出规格;主画布负责预览结果与继续迭代。", - ); - await leaveElement(introTip); - - const modelTip = findButtonByAriaLabel(container, "图片模型说明"); - await hoverElement(modelTip); - expect(document.body.textContent).toContain( - "当前服务商:Fal。模型切换会影响尺寸支持范围与图片编辑链路。", - ); - await leaveElement(modelTip); - }); - - it("点击左上角返回按钮应回到新建任务页", async () => { - const onNavigate = vi.fn(); - const container = renderPage(onNavigate); - - await flushEffects(); - - const backButton = container.querySelector( - 'button[title="返回新建任务"]', - ); - expect(backButton).not.toBeNull(); - - act(() => { - backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(onNavigate).toHaveBeenCalledTimes(1); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - theme: "general", - lockTheme: false, - newChatAt: expect.any(Number), - }), - ); - }); - - it("应仅显示当前选中图片的提示词历史", async () => { - const container = renderPage(); - - await flushEffects(); - - const chipBefore = getPromptChip(container); - expect(chipBefore.textContent).toContain("第一张提示词"); - - const secondHistoryItem = container.querySelector( - '[role="button"][title="第二张提示词"]', - ); - expect(secondHistoryItem).not.toBeNull(); - - act(() => { - secondHistoryItem?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - }); - - const chipAfter = getPromptChip(container); - expect(chipAfter.textContent).toContain("第二张提示词"); - }); - - it("点击补录按钮应使用目标项目触发历史补录", async () => { - const container = renderPage(); - await flushEffects(); - - const backfillButton = findButtonByText(container, "补录历史到资源库"); - expect(backfillButton.disabled).toBe(false); - - await act(async () => { - backfillButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await Promise.resolve(); - }); - - expect(mockBackfillImagesToResource).toHaveBeenCalledTimes(1); - expect(mockBackfillImagesToResource).toHaveBeenCalledWith( - "project-default", - ); - expect(mockToast.success).toHaveBeenCalled(); - }); - - it("AI 生图布局应锁定父级高度避免小屏裁切输入区", async () => { - const mounted = renderIntoDom( -
- -
, - mountedRoots, - ); - - await flushEffects(); - - const layout = mounted.container.querySelector( - '[data-testid="ai-image-gen-layout"]', - ); - expect(layout).not.toBeNull(); - - const styles = Array.from(document.head.querySelectorAll("style")) - .map((node) => node.textContent || "") - .join("\n"); - - const hasExpectedRule = Array.from(layout?.classList || []).some( - (className) => - new RegExp( - `\\.${escapeRegExp(className)}\\{[^}]*height:100%;[^}]*overflow:hidden;`, - ).test(styles), - ); - - expect(hasExpectedRule).toBe(true); - }); -}); diff --git a/src/components/image-gen/ImageGenPage.tsx b/src/components/image-gen/ImageGenPage.tsx deleted file mode 100644 index ead21c046..000000000 --- a/src/components/image-gen/ImageGenPage.tsx +++ /dev/null @@ -1,369 +0,0 @@ -/** - * @file 图片生成页面 - * @description 插图功能 - 包含图片搜索、AI生图、本地图片、我的图片库四个 Tab - * @module components/image-gen/ImageGenPage - */ - -import { useEffect, useMemo, useState } from "react"; -import styled, { keyframes } from "styled-components"; -import { ChevronDown } from "lucide-react"; -import { useProjects } from "@/hooks/useProjects"; -import { - getStoredResourceProjectId, - onResourceProjectChange, - setStoredResourceProjectId, -} from "@/lib/resourceProjectSelection"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; -import { CanvasBreadcrumbHeader } from "@/lib/workspace/workbenchUi"; -import type { Page, PageParams } from "@/types/page"; -import { AiImageGenTab } from "./tabs/AiImageGenTab"; -import { ImageSearchTab } from "./tabs/ImageSearchTab"; -import { LocalImageTab } from "./tabs/LocalImageTab"; -import { MyGalleryTab } from "./tabs/MyGalleryTab"; - -type PageNavigate = (page: Page, params?: PageParams) => void; - -interface ImageGenPageProps { - onNavigate?: PageNavigate; -} - -const PageLayout = styled.div` - position: relative; - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%); -`; - -const PageChrome = styled.div` - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - padding: 8px 10px 0; - flex-shrink: 0; - - @media (max-width: 960px) { - padding: 8px 8px 0; - } -`; - -const HeaderBar = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 10px; - border-radius: 18px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--background) / 0.82); - box-shadow: - 0 12px 28px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.74); - backdrop-filter: blur(16px); - - @media (max-width: 960px) { - flex-direction: column; - align-items: stretch; - gap: 8px; - } -`; - -const HeaderLead = styled.div` - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - flex: 1; - - @media (max-width: 960px) { - width: 100%; - } -`; - -const ProjectSelectorWrapper = styled.div` - position: relative; - display: inline-flex; - align-items: center; - min-width: 180px; - - @media (max-width: 960px) { - width: 100%; - } -`; - -const ProjectSelector = styled.select` - appearance: none; - width: 100%; - height: 36px; - padding: 0 34px 0 12px; - border: 1px solid hsl(var(--border)); - border-radius: 12px; - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(var(--muted) / 0.12) - ); - font-size: 12px; - font-weight: 600; - color: hsl(var(--foreground)); - cursor: pointer; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; - - &:hover { - border-color: hsl(214 68% 38% / 0.32); - transform: translateY(-1px); - } - - &:focus { - outline: none; - border-color: hsl(214 68% 38% / 0.34); - box-shadow: 0 0 0 4px hsl(211 100% 96%); - } -`; - -const SelectorIcon = styled.div` - position: absolute; - right: 12px; - pointer-events: none; - color: hsl(var(--muted-foreground)); -`; - -const MainContainer = styled.div` - position: relative; - z-index: 1; - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - padding: 0 10px 10px; - - @media (max-width: 960px) { - padding: 0 8px 8px; - } -`; - -const TabsBar = styled.div` - display: flex; - align-items: center; - flex: 1; - min-width: 0; - gap: 4px; - padding: 4px; - border-radius: 14px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--muted) / 0.18); - overflow-x: auto; -`; - -const pulseIn = keyframes` - from { - opacity: 0.4; - transform: scale(0.96); - } - - to { - opacity: 1; - transform: scale(1); - } -`; - -const TabButton = styled.button<{ $active: boolean }>` - position: relative; - flex-shrink: 0; - height: 32px; - padding: 0 12px; - border: 1px solid - ${({ $active }) => ($active ? "hsl(214 68% 38% / 0.18)" : "transparent")}; - border-radius: 10px; - background: ${({ $active }) => - $active - ? "linear-gradient(180deg, hsl(var(--background)), hsl(203 100% 97%))" - : "transparent"}; - font-size: 13px; - font-weight: ${({ $active }) => ($active ? 700 : 500)}; - color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; - cursor: pointer; - transition: - color 0.2s ease, - background 0.2s ease, - border-color 0.2s ease, - transform 0.2s ease; - animation: ${({ $active }) => ($active ? pulseIn : "none")} 0.24s ease; - - &:hover { - color: hsl(var(--foreground)); - background: hsl(var(--muted) / 0.18); - } -`; - -const TabContent = styled.div` - flex: 1; - min-height: 0; - overflow: hidden; - margin-top: 8px; - border-radius: 22px; - border: 1px solid hsl(var(--border) / 0.72); - background: hsl(var(--background) / 0.52); - box-shadow: - 0 12px 30px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.64); - backdrop-filter: blur(10px); -`; - -const TABS = [ - { key: "ai-gen", label: "AI生图" }, - { key: "search", label: "图片搜索" }, - { key: "local", label: "本地图片" }, - { key: "gallery", label: "我的图片库" }, -] as const; - -export function ImageGenPage({ onNavigate }: ImageGenPageProps) { - const [activeTab, setActiveTab] = useState("ai-gen"); - const [selectedProjectId, setSelectedProjectId] = useState( - null, - ); - - const { projects, defaultProject, loading: projectsLoading } = useProjects(); - - const handleBackHome = () => { - onNavigate?.("agent", buildHomeAgentParams()); - }; - - const availableProjects = useMemo( - () => projects.filter((project) => !project.isArchived), - [projects], - ); - - useEffect(() => { - if (projectsLoading) { - return; - } - - setSelectedProjectId((current) => { - if ( - current && - availableProjects.some((project) => project.id === current) - ) { - return current; - } - - const storedProjectId = getStoredResourceProjectId({ - includeLegacy: true, - }); - if ( - storedProjectId && - availableProjects.some((project) => project.id === storedProjectId) - ) { - return storedProjectId; - } - - const preferredProject = - (defaultProject && !defaultProject.isArchived - ? defaultProject - : null) ?? availableProjects[0]; - - return preferredProject?.id || null; - }); - }, [projectsLoading, availableProjects, defaultProject]); - - useEffect(() => { - if (selectedProjectId) { - setStoredResourceProjectId(selectedProjectId, { - source: "image-gen-target", - syncLegacy: true, - emitEvent: true, - }); - } - }, [selectedProjectId]); - - useEffect(() => { - return onResourceProjectChange((detail) => { - if (detail.source !== "resources") { - return; - } - - const nextProjectId = detail.projectId; - if (!nextProjectId || nextProjectId === selectedProjectId) { - return; - } - - if (!availableProjects.some((project) => project.id === nextProjectId)) { - return; - } - - setSelectedProjectId(nextProjectId); - }); - }, [availableProjects, selectedProjectId]); - - return ( - - - - - - - {TABS.map((tab) => ( - setActiveTab(tab.key)} - > - {tab.label} - - ))} - - - - setSelectedProjectId(e.target.value || null)} - disabled={projectsLoading} - > - - {availableProjects.map((project) => ( - - ))} - - - - - - - - - - - {activeTab === "search" && ( - - )} - {activeTab === "ai-gen" && ( - - )} - {activeTab === "local" && ( - - )} - {activeTab === "gallery" && ( - - )} - - - - ); -} diff --git a/src/components/image-gen/README.md b/src/components/image-gen/README.md index 47815be12..0ea4a5f07 100644 --- a/src/components/image-gen/README.md +++ b/src/components/image-gen/README.md @@ -1,41 +1,28 @@ # 图片生成模块 -AI 图片生成功能,支持多个提供商和模型。 +这里现在只保留供现役链路复用的 AI 生图 runtime、插图浮层与测试辅助。 -## 功能特性 +## 当前事实源 -- 支持多个图片生成提供商(智谱、AiHubMix、硅基流动等) -- 支持多种图片尺寸选择 -- 历史记录管理 -- 提供商配置管理 +- `useImageGen.ts`: AI 生图主 Hook,负责 provider、任务、保存与插图联动 +- `RecentImageInsertFloating.tsx`: 最近插图浮层 +- `types.ts`: 共享类型 +- `test-utils.ts`: 相关测试辅助 -## 文件结构 +## 导入约束 -| 文件 | 说明 | -|------|------| -| `ImageGenPage.tsx` | 主页面组件 | -| `ProviderConfigModal.tsx` | 提供商配置弹窗 | -| `useImageGen.ts` | 状态管理 Hook | -| `types.ts` | 类型定义 | -| `index.ts` | 模块导出 | +- 不再保留 `@/components/image-gen` 目录级 barrel 导出 +- 现役代码必须直连子路径,例如 `@/components/image-gen/useImageGen` +- 共享类型与测试辅助分别走 `@/components/image-gen/types`、`@/components/image-gen/test-utils` -## 支持的提供商 +## 已收口的旧 surface -- 智谱开放平台 (CogView-3-Flash, CogView-4) -- AiHubMix (DALL-E 3) -- 硅基流动 (FLUX.1-schnell) -- DMXAPI (DALL-E 3) -- TokenFlux (DALL-E 3) -- New API (DALL-E 3) -- CherryIN (DALL-E 3) +- 独立“插图”页面已经下线,不再作为产品入口 +- 联网图片搜索已经迁到 Claw `@素材` +- 本地图片与“我的图片库”已经迁到资料库图片视图 ## 使用方式 -1. 点击左侧导航栏的"图片生成"图标 -2. 点击设置按钮添加提供商 -3. 选择提供商、模型和尺寸 -4. 输入描述文字,点击发送生成图片 - -## API 接口 - -使用 OpenAI 兼容的 `/v1/images/generations` 接口。 +1. 在 Claw 工作台触发 AI 生图能力 +2. 由 `useImageGen` 统一管理生成、保存与插图动作 +3. 本地图片上传与图库浏览统一走资料库 diff --git a/src/components/image-gen/hooks/useImageSearch.test.tsx b/src/components/image-gen/hooks/useImageSearch.test.tsx deleted file mode 100644 index 3a915768b..000000000 --- a/src/components/image-gen/hooks/useImageSearch.test.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { act } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - cleanupMountedRoots, - flushEffects, - renderIntoDom, - setReactActEnvironment, - waitForCondition, - type MountedRoot, -} from "../test-utils"; -import { useImageSearch } from "./useImageSearch"; - -const { mockSearchPixabayImages, mockSearchWebImages } = vi.hoisted(() => ({ - mockSearchPixabayImages: vi.fn(), - mockSearchWebImages: vi.fn(), -})); - -vi.mock("@/lib/api/imageSearch", () => ({ - searchPixabayImages: mockSearchPixabayImages, - searchWebImages: mockSearchWebImages, -})); - -interface HookHarness { - getValue: () => ReturnType; -} - -const mountedRoots: MountedRoot[] = []; - -function mountHook(): HookHarness { - let hookValue: ReturnType | null = null; - - function TestComponent() { - hookValue = useImageSearch(); - return null; - } - - renderIntoDom(, mountedRoots); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - }; -} - -beforeEach(() => { - setReactActEnvironment(); - vi.clearAllMocks(); -}); - -afterEach(() => { - cleanupMountedRoots(mountedRoots); - vi.restoreAllMocks(); -}); - -describe("useImageSearch", () => { - it("应正确映射 Pixabay 结果", async () => { - mockSearchPixabayImages.mockResolvedValue({ - total: 100, - total_hits: 2, - hits: [ - { - id: 1, - preview_url: "https://pixabay.example/preview.jpg", - large_image_url: "https://pixabay.example/large.jpg", - image_width: 1200, - image_height: 800, - tags: "forest,tree", - page_url: "https://pixabay.com/photos/forest", - user: "pixabay-user", - }, - ], - }); - - const harness = mountHook(); - await act(async () => { - await harness.getValue().search("pixabay", "forest", true); - }); - - const state = harness.getValue().sourceStates.pixabay; - expect(state.total).toBe(2); - expect(state.results).toHaveLength(1); - expect(state.results[0]).toMatchObject({ - id: "1", - previewUrl: "https://pixabay.example/preview.jpg", - largeUrl: "https://pixabay.example/large.jpg", - provider: "pixabay", - }); - }); - - it("应正确映射联网(Pexels)结果", async () => { - mockSearchWebImages.mockResolvedValue({ - total: 1, - provider: "pexels", - hits: [ - { - id: "pex-1", - thumbnail_url: "https://pexels.example/thumb.jpg", - content_url: "https://pexels.example/original.jpg", - width: 1080, - height: 1920, - name: "city night", - host_page_url: "https://www.pexels.com/photo/city-night", - }, - ], - }); - - const harness = mountHook(); - await act(async () => { - await harness.getValue().search("web", "city", true); - }); - - const state = harness.getValue().sourceStates.web; - expect(state.total).toBe(1); - expect(state.results).toHaveLength(1); - expect(state.results[0]).toMatchObject({ - id: "pex-1", - previewUrl: "https://pexels.example/thumb.jpg", - largeUrl: "https://pexels.example/original.jpg", - provider: "pexels", - tags: "city night", - }); - }); - - it("应兼容联网(Pexels)camelCase 字段", async () => { - mockSearchWebImages.mockResolvedValue({ - total: 2, - provider: "pexels", - hits: [ - { - id: "pex-camel-1", - thumbnailUrl: "https://pexels.example/camel-thumb.jpg", - contentUrl: "https://pexels.example/camel-original.jpg", - width: 1200, - height: 800, - name: "camel city", - hostPageUrl: "https://www.pexels.com/photo/camel-city", - }, - ], - }); - - const harness = mountHook(); - await act(async () => { - await harness.getValue().search("web", "city", true); - }); - - const state = harness.getValue().sourceStates.web; - expect(state.total).toBe(2); - expect(state.results).toHaveLength(1); - expect(state.results[0]).toMatchObject({ - id: "pex-camel-1", - previewUrl: "https://pexels.example/camel-thumb.jpg", - largeUrl: "https://pexels.example/camel-original.jpg", - pageUrl: "https://www.pexels.com/photo/camel-city", - provider: "pexels", - }); - }); - - it("loadMore 应使用下一页请求并追加结果", async () => { - mockSearchPixabayImages.mockImplementation(async (req) => { - const page = req.page; - return { - total: 40, - total_hits: 40, - hits: [ - { - id: page, - preview_url: `https://pixabay.example/${page}-preview.jpg`, - large_image_url: `https://pixabay.example/${page}-large.jpg`, - image_width: 1200, - image_height: 800, - tags: `tag-${page}`, - page_url: `https://pixabay.com/photos/${page}`, - user: "pixabay-user", - }, - ], - }; - }); - - const harness = mountHook(); - await act(async () => { - await harness.getValue().search("pixabay", "mountain", true); - }); - - await act(async () => { - harness.getValue().loadMore("pixabay"); - await flushEffects(); - }); - - await waitForCondition( - () => harness.getValue().sourceStates.pixabay.results.length === 2, - 50, - "分页加载未完成", - ); - - const state = harness.getValue().sourceStates.pixabay; - expect(state.page).toBe(2); - expect(state.results.map((item) => item.id)).toEqual(["1", "2"]); - - const pageCalls = mockSearchPixabayImages.mock.calls.map( - ([req]) => req.page, - ); - expect(pageCalls).toEqual([1, 2]); - }); - - it("应维护来源独立缓存(互不污染)", async () => { - mockSearchPixabayImages.mockResolvedValue({ - total: 1, - total_hits: 1, - hits: [ - { - id: 100, - preview_url: "https://pixabay.example/p.jpg", - large_image_url: "https://pixabay.example/l.jpg", - image_width: 1000, - image_height: 700, - tags: "pixabay-only", - page_url: "https://pixabay.com/photos/pixabay-only", - user: "pix-user", - }, - ], - }); - mockSearchWebImages.mockResolvedValue({ - total: 1, - provider: "pexels", - hits: [ - { - id: "w-1", - thumbnail_url: "https://pexels.example/w-thumb.jpg", - content_url: "https://pexels.example/w.jpg", - width: 700, - height: 1000, - name: "web-only", - host_page_url: "https://www.pexels.com/photo/web-only", - }, - ], - }); - - const harness = mountHook(); - await act(async () => { - await harness.getValue().search("pixabay", "pixabay-query", true); - }); - await act(async () => { - await harness.getValue().search("web", "web-query", true); - }); - - const pixabayState = harness.getValue().sourceStates.pixabay; - const webState = harness.getValue().sourceStates.web; - - expect(pixabayState.results).toHaveLength(1); - expect(pixabayState.results[0].provider).toBe("pixabay"); - expect(pixabayState.results[0].id).toBe("100"); - - expect(webState.results).toHaveLength(1); - expect(webState.results[0].provider).toBe("pexels"); - expect(webState.results[0].id).toBe("w-1"); - }); -}); diff --git a/src/components/image-gen/hooks/useImageSearch.ts b/src/components/image-gen/hooks/useImageSearch.ts deleted file mode 100644 index 2789d083f..000000000 --- a/src/components/image-gen/hooks/useImageSearch.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * @file 图片搜索 Hook - * @description 调用后端 Pixabay / 联网图片搜索命令,支持分源缓存 - * @module components/image-gen/hooks/useImageSearch - */ - -import { useCallback, useState } from "react"; -import { - searchPixabayImages, - searchWebImages, - type AspectRatioFilter, - type PixabaySearchRequest, - type WebImageSearchRequest, -} from "@/lib/api/imageSearch"; - -export type { AspectRatioFilter } from "@/lib/api/imageSearch"; - -export type SearchSource = "web" | "pixabay"; - -export interface SearchImageResult { - id: string; - previewUrl: string; - largeUrl: string; - width: number; - height: number; - tags: string; - pageUrl: string; - user: string; - provider: "pixabay" | "pexels"; -} - -interface SourceSearchState { - results: SearchImageResult[]; - loading: boolean; - total: number; - page: number; - error: string | null; - lastQuery: string; -} - -type SourceSearchStateMap = Record; - -const DEFAULT_PER_PAGE = 20; - -function pickFirstString(...values: Array): string { - for (const value of values) { - if (typeof value === "string" && value.trim().length > 0) { - return value; - } - } - return ""; -} - -function pickFirstNumber(...values: Array): number { - for (const value of values) { - if (typeof value === "number" && Number.isFinite(value) && value > 0) { - return value; - } - } - return 0; -} - -function createInitialSourceState(): SourceSearchState { - return { - results: [], - loading: false, - total: 0, - page: 1, - error: null, - lastQuery: "", - }; -} - -function mapPixabayOrientation( - aspectRatio: AspectRatioFilter, -): string | undefined { - if (aspectRatio === "landscape") return "horizontal"; - if (aspectRatio === "portrait") return "vertical"; - return undefined; -} - -function mapWebAspect( - aspectRatio: AspectRatioFilter, -): AspectRatioFilter | undefined { - return aspectRatio === "all" ? undefined : aspectRatio; -} - -function filterSquareIfNeeded( - results: SearchImageResult[], - aspectRatio: AspectRatioFilter, -): SearchImageResult[] { - if (aspectRatio !== "square") { - return results; - } - - return results.filter((img) => { - const ratio = img.width / img.height; - return ratio > 0.9 && ratio < 1.1; - }); -} - -export function useImageSearch() { - const [query, setQuery] = useState(""); - const [aspectRatio, setAspectRatio] = useState("all"); - const [sourceStates, setSourceStates] = useState({ - web: createInitialSourceState(), - pixabay: createInitialSourceState(), - }); - - const search = useCallback( - async ( - source: SearchSource, - newQuery?: string, - resetPage = true, - targetPage?: number, - ) => { - const searchQuery = (newQuery ?? query).trim(); - if (!searchQuery) { - setSourceStates((prev) => ({ - ...prev, - [source]: { - ...prev[source], - results: [], - total: 0, - page: 1, - error: null, - lastQuery: "", - }, - })); - return; - } - - const page = targetPage ?? (resetPage ? 1 : sourceStates[source].page); - - setSourceStates((prev) => ({ - ...prev, - [source]: { - ...prev[source], - loading: true, - error: null, - page, - lastQuery: searchQuery, - }, - })); - - try { - if (source === "pixabay") { - const req: PixabaySearchRequest = { - query: searchQuery, - page, - perPage: DEFAULT_PER_PAGE, - orientation: mapPixabayOrientation(aspectRatio), - }; - - const response = await searchPixabayImages(req); - - const mapped = response.hits.map((hit) => ({ - id: String(hit.id), - previewUrl: pickFirstString(hit.preview_url, hit.previewUrl), - largeUrl: pickFirstString(hit.large_image_url, hit.largeImageUrl), - width: pickFirstNumber(hit.image_width, hit.imageWidth), - height: pickFirstNumber(hit.image_height, hit.imageHeight), - tags: hit.tags, - pageUrl: pickFirstString(hit.page_url, hit.pageUrl), - user: hit.user, - provider: "pixabay" as const, - })); - - const filtered = filterSquareIfNeeded(mapped, aspectRatio); - - setSourceStates((prev) => { - const nextResults = resetPage - ? filtered - : [...prev[source].results, ...filtered]; - return { - ...prev, - [source]: { - ...prev[source], - results: nextResults, - total: - response.total_hits ?? response.totalHits ?? response.total, - page, - loading: false, - error: null, - lastQuery: searchQuery, - }, - }; - }); - return; - } - - const req: WebImageSearchRequest = { - query: searchQuery, - page, - perPage: DEFAULT_PER_PAGE, - aspect: mapWebAspect(aspectRatio), - }; - const response = await searchWebImages(req); - - const normalizedHits = - response.hits?.length > 0 - ? response.hits.map((hit) => ({ - id: hit.id, - previewUrl: pickFirstString( - hit.thumbnail_url, - hit.thumbnailUrl, - ), - largeUrl: pickFirstString(hit.content_url, hit.contentUrl), - width: pickFirstNumber(hit.width), - height: pickFirstNumber(hit.height), - tags: hit.name, - pageUrl: pickFirstString(hit.host_page_url, hit.hostPageUrl), - user: response.provider || "pexels", - provider: "pexels" as const, - })) - : (response.photos || []).map((photo) => { - const contentUrl = pickFirstString( - photo.src.large2x, - photo.src.large, - photo.src.original, - photo.src.landscape, - photo.src.portrait, - photo.src.medium, - photo.src.small, - photo.src.tiny, - ); - const previewUrl = pickFirstString( - photo.src.medium, - photo.src.small, - photo.src.tiny, - photo.src.landscape, - photo.src.portrait, - contentUrl, - ); - return { - id: String(photo.id), - previewUrl, - largeUrl: contentUrl, - width: pickFirstNumber(photo.width), - height: pickFirstNumber(photo.height), - tags: photo.alt || "Pexels Image", - pageUrl: photo.url, - user: response.provider || "pexels", - provider: "pexels" as const, - }; - }); - - const mapped = normalizedHits.filter( - (hit) => - hit.previewUrl && hit.largeUrl && hit.width > 0 && hit.height > 0, - ); - const filtered = filterSquareIfNeeded(mapped, aspectRatio); - setSourceStates((prev) => { - const nextResults = resetPage - ? filtered - : [...prev[source].results, ...filtered]; - return { - ...prev, - [source]: { - ...prev[source], - results: nextResults, - total: response.total || response.totalResults || filtered.length, - page, - loading: false, - error: null, - lastQuery: searchQuery, - }, - }; - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`${source} 搜索失败:`, error); - setSourceStates((prev) => ({ - ...prev, - [source]: { - ...prev[source], - loading: false, - error: message, - ...(resetPage - ? { - results: [], - total: 0, - page: 1, - } - : {}), - }, - })); - } - }, - [aspectRatio, query, sourceStates], - ); - - const loadMore = useCallback( - (source: SearchSource) => { - const current = sourceStates[source]; - if (current.loading || current.results.length >= current.total) { - return; - } - - const nextPage = current.page + 1; - const nextQuery = current.lastQuery || query.trim(); - if (!nextQuery) { - return; - } - void search(source, nextQuery, false, nextPage); - }, - [query, search, sourceStates], - ); - - const clear = useCallback((source?: SearchSource) => { - if (source) { - setSourceStates((prev) => ({ - ...prev, - [source]: createInitialSourceState(), - })); - return; - } - - setSourceStates({ - web: createInitialSourceState(), - pixabay: createInitialSourceState(), - }); - setQuery(""); - }, []); - - return { - query, - setQuery, - aspectRatio, - setAspectRatio, - sourceStates, - search, - loadMore, - clear, - }; -} diff --git a/src/components/image-gen/index.ts b/src/components/image-gen/index.ts deleted file mode 100644 index c9f06d9d0..000000000 --- a/src/components/image-gen/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file 图片生成模块导出 - * @description AI 图片生成功能组件,复用凭证管理中的 API Key Provider - * @module components/image-gen - */ - -export { ImageGenPage } from "./ImageGenPage"; -export { useImageGen } from "./useImageGen"; -export * from "./types"; diff --git a/src/components/image-gen/tabs/AiImageGenTab.tsx b/src/components/image-gen/tabs/AiImageGenTab.tsx deleted file mode 100644 index a862c020d..000000000 --- a/src/components/image-gen/tabs/AiImageGenTab.tsx +++ /dev/null @@ -1,2030 +0,0 @@ -/** - * @file AI 生图 Tab - * @description 从原 ImageGenPage 提取的 AI 图片生成功能 - * @module components/image-gen/tabs/AiImageGenTab - */ - -import React, { useEffect, useMemo, useRef, useState } from "react"; -import styled from "styled-components"; -import { - Image as ImageIcon, - ImagePlus, - Loader2, - Plus, - Send, - Settings, - Sparkles, - Trash2, - ExternalLink, - X, -} from "lucide-react"; -import { toast } from "sonner"; -import { IMAGE_GENERATION_CANCELED_MESSAGE, useImageGen } from "../useImageGen"; -import type { GeneratedImage } from "../types"; -import { useProject } from "@/hooks/useProject"; -import { useProjects } from "@/hooks/useProjects"; -import { - getStoredResourceProjectId, - onResourceProjectChange, - setStoredResourceProjectId, -} from "@/lib/resourceProjectSelection"; -import { CharacterMention } from "@/components/agent/chat/skill-selection/CharacterMention"; -import { SkillBadge } from "@/components/agent/chat/skill-selection/SkillBadge"; -import { useActiveSkill } from "@/components/agent/chat/skill-selection/useActiveSkill"; -import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; -import { skillsApi, type Skill } from "@/lib/api/skills"; -import { useGlobalMediaGenerationDefaults } from "@/hooks/useGlobalMediaGenerationDefaults"; -import { resolveMediaGenerationPreference } from "@/lib/mediaGeneration"; -import type { Page, PageParams } from "@/types/page"; -import { SettingsTabs } from "@/types/settings"; - -export interface AiImageGenTabProps { - /** 目标项目 ID(可选) */ - projectId?: string | null; - /** 导航回调 */ - onNavigate?: (page: Page, params?: PageParams) => void; -} - -type ResolutionPreset = "1k" | "2k" | "4k"; - -interface ReferenceImageItem { - id: string; - name: string; - url: string; -} - -const RESOLUTION_OPTIONS: Array<{ - label: string; - value: ResolutionPreset; - longEdge: number; -}> = [ - { label: "1K", value: "1k", longEdge: 1024 }, - { label: "2K", value: "2k", longEdge: 2048 }, - { label: "4K", value: "4k", longEdge: 4096 }, -]; - -const ASPECT_RATIO_OPTIONS = [ - "1:1", - "2:3", - "3:2", - "3:4", - "4:3", - "9:16", - "5:4", - "4:5", - "16:9", - "21:9", -]; - -const IMAGE_COUNT_PRESETS = [1, 2, 4, 8]; - -const FALLBACK_SUPPORTED_SIZES = [ - "1024x1024", - "768x1344", - "864x1152", - "1344x768", - "1152x864", -]; - -function parseSize(size: string): { width: number; height: number } | null { - const [rawWidth, rawHeight] = size.split("x"); - const width = Number(rawWidth); - const height = Number(rawHeight); - - if (!Number.isFinite(width) || !Number.isFinite(height)) { - return null; - } - - return { width, height }; -} - -function parseAspectRatio(ratio: string): number { - const [rawWidth, rawHeight] = ratio.split(":"); - const width = Number(rawWidth); - const height = Number(rawHeight); - - if ( - !Number.isFinite(width) || - !Number.isFinite(height) || - width <= 0 || - height <= 0 - ) { - return 1; - } - - return width / height; -} - -function chooseClosestSize( - supportedSizes: string[], - aspectRatio: string, - resolutionPreset: ResolutionPreset, -): string { - const candidates = supportedSizes - .map((size) => ({ - raw: size, - parsed: parseSize(size), - })) - .filter( - ( - candidate, - ): candidate is { - raw: string; - parsed: { width: number; height: number }; - } => candidate.parsed !== null, - ); - - if (candidates.length === 0) { - return FALLBACK_SUPPORTED_SIZES[0]; - } - - const ratioValue = parseAspectRatio(aspectRatio); - const longEdge = - RESOLUTION_OPTIONS.find((option) => option.value === resolutionPreset) - ?.longEdge || 1024; - - const targetWidth = - ratioValue >= 1 ? longEdge : Math.max(1, Math.round(longEdge * ratioValue)); - const targetHeight = - ratioValue >= 1 ? Math.max(1, Math.round(longEdge / ratioValue)) : longEdge; - - const targetArea = targetWidth * targetHeight; - - const best = candidates.reduce( - (current, candidate) => { - const candidateRatio = candidate.parsed.width / candidate.parsed.height; - const candidateArea = candidate.parsed.width * candidate.parsed.height; - - const ratioScore = Math.abs(Math.log(candidateRatio / ratioValue)); - const areaScore = Math.abs(candidateArea - targetArea) / targetArea; - const totalScore = ratioScore * 3 + areaScore; - - if (totalScore < current.score) { - return { score: totalScore, size: candidate.raw }; - } - - return current; - }, - { score: Number.POSITIVE_INFINITY, size: candidates[0].raw }, - ); - - return best.size; -} - -function fileToDataUrl(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - - reject(new Error("文件读取失败")); - }; - - reader.onerror = () => reject(new Error("文件读取失败")); - reader.readAsDataURL(file); - }); -} - -function resolveBatchImages( - images: GeneratedImage[], - selectedImageId: string | null, -): GeneratedImage[] { - if (!selectedImageId) { - return []; - } - - const batchMatch = selectedImageId.match(/^img-(\d+)-\d+$/); - if (!batchMatch) { - const single = images.find((item) => item.id === selectedImageId); - return single ? [single] : []; - } - - const batchPrefix = `img-${batchMatch[1]}-`; - return images - .filter((item) => item.id.startsWith(batchPrefix)) - .sort((left, right) => left.createdAt - right.createdAt); -} - -function getStatusText(status: GeneratedImage["status"]): string { - switch (status) { - case "complete": - return "已完成"; - case "error": - return "失败"; - case "generating": - return "生成中"; - default: - return "待生成"; - } -} - -// ==================== Styled Components ==================== - -const Container = styled.div` - height: 100%; - flex: 1; - min-height: 0; - display: flex; - gap: 10px; - overflow: hidden; - padding: 10px; - background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%); - color: hsl(var(--foreground)); - - @media (max-width: 1180px) { - gap: 8px; - padding: 10px; - } - - @media (max-width: 980px) { - flex-direction: column; - overflow: auto; - } -`; - -const ControlPanel = styled.aside` - width: 272px; - min-width: 272px; - padding: 12px; - border-radius: 24px; - border: 1px solid hsl(var(--border) / 0.78); - background: linear-gradient( - 180deg, - hsl(var(--background) / 0.84), - hsl(201 42% 98% / 0.72) - ); - box-shadow: - 0 16px 40px hsl(215 32% 12% / 0.06), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 10px; - - @media (max-width: 1180px) { - width: 252px; - min-width: 252px; - } - - @media (max-width: 980px) { - width: 100%; - min-width: 0; - max-height: none; - } -`; - -const PanelIntro = styled.div` - border-radius: 24px; - border: 1px solid hsl(152 30% 86%); - background: linear-gradient( - 135deg, - hsl(154 48% 96%) 0%, - hsl(0 0% 100%) 48%, - hsl(201 62% 97%) 100% - ); - padding: 18px; - box-shadow: - 0 14px 32px hsl(200 38% 16% / 0.06), - inset 0 1px 0 hsl(0 0% 100% / 0.78); -`; - -const PanelEyebrow = styled.span` - display: inline-flex; - align-items: center; - width: fit-content; - border-radius: 999px; - border: 1px solid hsl(154 36% 82%); - background: hsl(0 0% 100% / 0.8); - padding: 5px 10px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.14em; - color: hsl(154 50% 28%); -`; - -const PanelTitle = styled.h2` - margin: 0; - font-size: 22px; - line-height: 1.2; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const PanelTitleRow = styled.div` - margin: 10px 0 6px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -`; - -const PanelMetaGrid = styled.div` - margin-top: 14px; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -`; - -const PanelMetaCard = styled.div` - border-radius: 18px; - border: 1px solid hsl(var(--border) / 0.8); - background: hsl(var(--background) / 0.86); - padding: 12px; - display: flex; - flex-direction: column; - gap: 5px; -`; - -const PanelMetaLabel = styled.span` - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - color: hsl(var(--muted-foreground)); -`; - -const PanelMetaValue = styled.span` - font-size: 14px; - line-height: 1.45; - font-weight: 600; - color: hsl(var(--foreground)); - word-break: break-word; -`; - -const Section = styled.section` - display: flex; - flex-direction: column; - gap: 12px; - border-radius: 22px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--background) / 0.86); - padding: 14px; - box-shadow: - 0 10px 28px hsl(215 30% 14% / 0.04), - inset 0 1px 0 hsl(0 0% 100% / 0.75); -`; - -const SectionTitle = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - font-size: 14px; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const SectionTitleGroup = styled.div` - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -`; - -const SectionTitleText = styled.span` - display: inline-flex; - align-items: center; -`; - -const Select = styled.select` - width: 100%; - height: 42px; - border: 1px solid hsl(var(--border)); - border-radius: 14px; - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(var(--muted) / 0.12) - ); - padding: 0 12px; - font-size: 13px; - font-weight: 600; - color: hsl(var(--foreground)); - transition: - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; - - &:hover { - border-color: hsl(214 68% 38% / 0.28); - transform: translateY(-1px); - } - - &:focus { - outline: none; - border-color: hsl(214 68% 38% / 0.32); - box-shadow: 0 0 0 4px hsl(211 100% 96%); - } -`; - -const FullButton = styled.button<{ $disabled?: boolean }>` - width: 100%; - height: 38px; - border-radius: 14px; - border: 1px solid hsl(var(--border)); - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(var(--muted) / 0.12) - ); - color: hsl(var(--foreground)); - font-size: 13px; - font-weight: 700; - cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; - opacity: ${({ $disabled }) => ($disabled ? 0.65 : 1)}; - transition: - border-color 0.2s ease, - transform 0.2s ease, - box-shadow 0.2s ease; - - &:hover { - border-color: ${({ $disabled }) => - $disabled ? "hsl(var(--border))" : "hsl(214 68% 38% / 0.3)"}; - background: ${({ $disabled }) => - $disabled - ? "linear-gradient(180deg, hsl(var(--background)), hsl(var(--muted) / 0.12))" - : "hsl(var(--background))"}; - transform: ${({ $disabled }) => ($disabled ? "none" : "translateY(-1px)")}; - box-shadow: ${({ $disabled }) => - $disabled ? "none" : "0 12px 24px hsl(215 30% 14% / 0.08)"}; - } -`; - -const SmallButton = styled.button` - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border: 1px solid hsl(var(--border)); - border-radius: 10px; - background: hsl(var(--background)); - color: hsl(var(--muted-foreground)); - cursor: pointer; - transition: - border-color 0.2s ease, - transform 0.2s ease, - color 0.2s ease; - - &:hover { - color: hsl(var(--foreground)); - border-color: hsl(214 68% 38% / 0.32); - transform: translateY(-1px); - } -`; - -const UploadBox = styled.div<{ $dragging: boolean }>` - border: 1px dashed - ${({ $dragging }) => - $dragging ? "hsl(214 68% 38% / 0.42)" : "hsl(var(--border))"}; - border-radius: 18px; - min-height: 126px; - background: ${({ $dragging }) => - $dragging - ? "hsl(211 100% 96%)" - : "linear-gradient(180deg, hsl(var(--muted) / 0.18), hsl(var(--background)))"}; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 12px; - cursor: pointer; - transition: - border-color 0.2s ease, - background 0.2s ease, - transform 0.2s ease; - - &:hover { - border-color: hsl(214 68% 38% / 0.3); - transform: translateY(-1px); - } -`; - -const UploadText = styled.div` - font-size: 12px; - line-height: 1.6; - color: hsl(var(--muted-foreground)); -`; - -const Thumbs = styled.div` - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -`; - -const ThumbItem = styled.div` - position: relative; - border-radius: 14px; - overflow: hidden; - border: 1px solid hsl(var(--border)); - aspect-ratio: 1; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } -`; - -const RemoveThumb = styled.button` - position: absolute; - top: 6px; - right: 6px; - width: 22px; - height: 22px; - border: none; - border-radius: 999px; - background: hsl(var(--background) / 0.9); - color: hsl(var(--destructive)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -`; - -const Segment = styled.div` - display: flex; - gap: 6px; -`; - -const SegmentButton = styled.button<{ $active: boolean }>` - flex: 1; - height: 36px; - border-radius: 14px; - border: 1px solid - ${({ $active }) => - $active ? "hsl(221 39% 16%)" : "hsl(var(--border) / 0.8)"}; - background: ${({ $active }) => - $active - ? "linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%))" - : "hsl(var(--muted) / 0.18)"}; - color: ${({ $active }) => - $active ? "hsl(var(--background))" : "hsl(var(--muted-foreground))"}; - font-size: 13px; - font-weight: 700; - cursor: pointer; - transition: - transform 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease; - - &:hover { - transform: translateY(-1px); - border-color: hsl(214 68% 38% / 0.28); - } -`; - -const RatioGrid = styled.div` - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 8px; -`; - -const RatioButton = styled.button<{ $active: boolean }>` - height: 46px; - border-radius: 14px; - border: 1px solid - ${({ $active }) => - $active ? "hsl(214 68% 38% / 0.3)" : "hsl(var(--border) / 0.82)"}; - background: ${({ $active }) => - $active ? "hsl(211 100% 96%)" : "hsl(var(--background))"}; - color: ${({ $active }) => - $active ? "hsl(211 58% 38%)" : "hsl(var(--muted-foreground))"}; - font-size: 12px; - font-weight: 700; - cursor: pointer; - transition: - transform 0.2s ease, - border-color 0.2s ease, - background 0.2s ease; - - &:hover { - transform: translateY(-1px); - border-color: hsl(214 68% 38% / 0.25); - } -`; - -const CountRow = styled.div` - display: flex; - gap: 6px; -`; - -const CountButton = styled.button<{ $active: boolean }>` - flex: 1; - height: 36px; - border-radius: 14px; - border: 1px solid - ${({ $active }) => - $active ? "hsl(214 68% 38% / 0.3)" : "hsl(var(--border) / 0.8)"}; - background: ${({ $active }) => - $active ? "hsl(211 100% 96%)" : "hsl(var(--muted) / 0.18)"}; - color: ${({ $active }) => - $active ? "hsl(211 58% 38%)" : "hsl(var(--muted-foreground))"}; - font-size: 13px; - font-weight: 700; - cursor: pointer; - transition: - transform 0.2s ease, - border-color 0.2s ease; - - &:hover { - transform: translateY(-1px); - border-color: hsl(214 68% 38% / 0.25); - } -`; - -const CountInput = styled.input` - width: 100%; - height: 40px; - border: 1px solid hsl(var(--border)); - border-radius: 14px; - background: hsl(var(--background)); - padding: 0 12px; - font-size: 13px; - - &:focus { - outline: none; - border-color: hsl(214 68% 38% / 0.32); - box-shadow: 0 0 0 4px hsl(211 100% 96%); - } -`; - -const Workspace = styled.main` - flex: 1; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - gap: 8px; - min-height: 0; - min-width: 0; -`; - -const Eyebrow = styled.span` - display: inline-flex; - align-items: center; - width: fit-content; - border-radius: 999px; - border: 1px solid hsl(203 82% 88%); - background: hsl(200 100% 97%); - padding: 4px 8px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.12em; - color: hsl(211 58% 38%); -`; - -const CanvasPanel = styled.section` - min-height: 0; - overflow: hidden; - min-height: 0; - display: flex; - flex-direction: column; - gap: 4px; - border-radius: 24px; - border: 1px solid hsl(var(--border) / 0.78); - background: linear-gradient( - 180deg, - hsl(var(--background) / 0.96), - hsl(201 46% 98% / 0.96) - ); - padding: 6px; - box-shadow: - 0 18px 42px hsl(215 32% 12% / 0.06), - inset 0 1px 0 hsl(0 0% 100% / 0.72); -`; - -const CanvasHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; - - @media (max-width: 960px) { - align-items: flex-start; - } -`; - -const CanvasHeaderCopy = styled.div` - display: flex; - flex-direction: column; - gap: 2px; - max-width: 560px; -`; - -const CanvasLabel = styled.h2` - margin: 0; - font-size: clamp(18px, 2vw, 24px); - line-height: 1.1; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const CanvasMetaRow = styled.div` - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -`; - -const CanvasMetaChip = styled.span` - display: inline-flex; - align-items: center; - height: 26px; - border-radius: 999px; - border: 1px solid hsl(var(--border) / 0.86); - background: hsl(var(--background) / 0.84); - padding: 0 10px; - font-size: 11px; - font-weight: 600; - color: hsl(var(--muted-foreground)); -`; - -const Canvas = styled.div` - flex: 1; - min-height: 260px; - border: 1px solid hsl(var(--border) / 0.8); - border-radius: 24px; - background: - radial-gradient(circle at top, hsl(200 100% 97%), transparent 34%), - linear-gradient(180deg, hsl(0 0% 100%), hsl(210 20% 98%)); - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; -`; - -const PreviewStage = styled.div` - width: 100%; - height: 100%; - padding: 6px; - box-sizing: border-box; - display: flex; - align-items: center; - justify-content: center; -`; - -const Empty = styled.div` - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - text-align: center; - color: hsl(var(--muted-foreground)); - - h2 { - margin: 0; - font-size: 30px; - font-weight: 700; - letter-spacing: 0; - color: hsl(var(--foreground)); - } - - div { - max-width: 420px; - line-height: 1.65; - } -`; - -const PreviewImage = styled.img` - display: block; - width: 100%; - height: 100%; - object-fit: contain; - border-radius: 14px; -`; - -const BatchGrid = styled.div` - width: 100%; - height: 100%; - padding: 8px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - align-content: start; - overflow: auto; -`; - -const BatchItem = styled.button<{ $active: boolean }>` - border: 1px solid - ${({ $active }) => - $active ? "hsl(214 68% 38% / 0.32)" : "hsl(var(--border) / 0.84)"}; - border-radius: 18px; - background: ${({ $active }) => - $active ? "hsl(203 100% 97%)" : "hsl(var(--background))"}; - cursor: pointer; - display: flex; - flex-direction: column; - padding: 10px; - gap: 8px; - transition: - transform 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease; - - &:hover { - transform: translateY(-1px); - border-color: hsl(214 68% 38% / 0.28); - box-shadow: 0 12px 24px hsl(215 30% 14% / 0.08); - } -`; - -const BatchPreviewWrap = styled.div` - border-radius: 14px; - background: hsl(var(--muted) / 0.18); - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - - img { - width: 100%; - height: 100%; - object-fit: contain; - } -`; - -const BatchPlaceholder = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - gap: 8px; - color: hsl(var(--muted-foreground)); -`; - -const BatchMeta = styled.div` - display: flex; - justify-content: space-between; - gap: 8px; - font-size: 12px; - color: hsl(var(--muted-foreground)); - font-weight: 600; -`; - -const CanvasActions = styled.div` - position: absolute; - top: 12px; - right: 12px; - display: flex; - gap: 6px; -`; - -const CanvasActionButton = styled.button` - width: 36px; - height: 36px; - border-radius: 12px; - border: 1px solid hsl(var(--border) / 0.88); - background: hsl(var(--background) / 0.92); - display: inline-flex; - align-items: center; - justify-content: center; - color: hsl(var(--muted-foreground)); - cursor: pointer; - box-shadow: 0 10px 24px hsl(215 30% 14% / 0.08); - transition: - transform 0.2s ease, - border-color 0.2s ease, - color 0.2s ease; - - &:hover { - color: hsl(var(--foreground)); - border-color: hsl(214 68% 38% / 0.28); - transform: translateY(-1px); - } -`; - -const SkillRow = styled.div` - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -`; - -const PromptDock = styled.div` - flex-shrink: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 6px; -`; - -const PromptSurface = styled.div` - position: relative; - border-radius: 18px; - border: 1px solid hsl(var(--border) / 0.8); - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(var(--muted) / 0.12) - ); - padding: 9px 50px 8px 12px; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease; - - &:focus-within { - border-color: hsl(214 68% 38% / 0.34); - box-shadow: 0 0 0 4px hsl(211 100% 96%); - } -`; - -const PromptInput = styled.textarea` - width: 100%; - min-height: 42px; - max-height: 92px; - border: none; - resize: none; - background: transparent; - font-size: 13px; - line-height: 1.5; - color: hsl(var(--foreground)); - padding: 0; - font-family: inherit; - - &:focus { - outline: none; - } - - &::placeholder { - color: hsl(var(--muted-foreground)); - } -`; - -const GenerateButton = styled.button<{ $disabled: boolean }>` - position: absolute; - right: 8px; - bottom: 8px; - width: 34px; - height: 34px; - border: 1px solid - ${({ $disabled }) => - $disabled ? "hsl(var(--border))" : "hsl(215 28% 17% / 0.92)"}; - border-radius: 12px; - background: ${({ $disabled }) => - $disabled - ? "hsl(var(--muted) / 0.75)" - : "linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%))"}; - color: ${({ $disabled }) => - $disabled ? "hsl(var(--muted-foreground))" : "hsl(var(--background))"}; - cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; - display: inline-flex; - align-items: center; - justify-content: center; - transition: - transform 0.2s ease, - box-shadow 0.2s ease, - opacity 0.2s ease; - box-shadow: ${({ $disabled }) => - $disabled ? "none" : "0 16px 32px hsl(220 40% 12% / 0.16)"}; - - &:hover { - transform: ${({ $disabled }) => ($disabled ? "none" : "translateY(-1px)")}; - box-shadow: ${({ $disabled }) => - $disabled ? "none" : "0 18px 36px hsl(220 40% 12% / 0.2)"}; - } -`; - -const PromptHistoryDock = styled.div` - display: none; - align-items: center; - gap: 6px; - font-size: 11px; - flex-wrap: wrap; -`; - -const PromptHistoryLabel = styled.div` - color: hsl(var(--muted-foreground)); - white-space: nowrap; - font-weight: 600; -`; - -const PromptHistoryChip = styled.button<{ $active: boolean }>` - max-width: min(100%, 520px); - border: 1px solid - ${({ $active }) => - $active ? "hsl(214 68% 38% / 0.3)" : "hsl(var(--border))"}; - border-radius: 999px; - background: ${({ $active }) => - $active ? "hsl(211 100% 96%)" : "hsl(var(--muted) / 0.2)"}; - color: ${({ $active }) => - $active ? "hsl(211 58% 38%)" : "hsl(var(--muted-foreground))"}; - padding: 4px 10px; - font-size: 11px; - line-height: 1.4; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; - - &:hover { - border-color: hsl(214 68% 38% / 0.3); - color: hsl(211 58% 38%); - } -`; - -const Status = styled.div` - border-radius: 18px; - border: 1px solid hsl(var(--border) / 0.82); - background: hsl(var(--background) / 0.82); - padding: 10px 12px; - font-size: 12px; - line-height: 1.6; - color: hsl(var(--muted-foreground)); -`; - -const HistorySidebar = styled.aside` - width: 80px; - min-width: 80px; - border-radius: 24px; - border: 1px solid hsl(var(--border) / 0.78); - background: linear-gradient( - 180deg, - hsl(var(--background) / 0.84), - hsl(201 42% 98% / 0.72) - ); - box-shadow: - 0 16px 40px hsl(215 32% 12% / 0.06), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - padding: 10px 8px; - display: flex; - flex-direction: column; - gap: 10px; - - @media (max-width: 980px) { - width: 100%; - min-width: 0; - } -`; - -const HistoryHeader = styled.div` - display: flex; - flex-direction: column; - gap: 4px; -`; - -const HistoryTitle = styled.div` - font-size: 12px; - font-weight: 700; - text-align: center; - color: hsl(var(--muted-foreground)); - letter-spacing: 0.08em; -`; - -const HistoryNewButton = styled.button` - width: 100%; - height: 46px; - border: 1px dashed hsl(var(--border)); - border-radius: 16px; - background: hsl(var(--background) / 0.72); - color: hsl(var(--muted-foreground)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: - transform 0.2s ease, - border-color 0.2s ease, - background 0.2s ease; - - &:hover { - border-color: hsl(214 68% 38% / 0.3); - color: hsl(211 58% 38%); - background: hsl(211 100% 96%); - transform: translateY(-1px); - } -`; - -const HistoryList = styled.div` - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 8px; - padding-right: 2px; - - @media (max-width: 980px) { - flex-direction: row; - overflow-x: auto; - overflow-y: hidden; - padding-right: 0; - padding-bottom: 2px; - } -`; - -const HistoryItem = styled.div<{ $active: boolean }>` - width: 100%; - aspect-ratio: 1; - border-radius: 16px; - border: 1px solid - ${({ $active }) => - $active ? "hsl(214 68% 38% / 0.3)" : "hsl(var(--border))"}; - background: ${({ $active }) => - $active ? "hsl(211 100% 96%)" : "hsl(var(--background))"}; - overflow: hidden; - cursor: pointer; - position: relative; - transition: - transform 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease; - - &:hover { - border-color: hsl(214 68% 38% / 0.3); - transform: translateY(-1px); - box-shadow: 0 12px 24px hsl(215 30% 14% / 0.08); - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - @media (max-width: 980px) { - width: 84px; - min-width: 84px; - } -`; - -const HistoryPlaceholder = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: hsl(var(--muted-foreground)); -`; - -const HistoryDeleteButton = styled.button` - position: absolute; - top: 6px; - right: 6px; - width: 22px; - height: 22px; - border: 1px solid hsl(var(--destructive) / 0.35); - border-radius: 50%; - background: hsl(var(--background) / 0.92); - color: hsl(var(--destructive)); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - opacity: 0; - transition: all 0.15s; - - ${HistoryItem}:hover & { - opacity: 1; - } - - &:hover { - background: hsl(var(--destructive)); - color: hsl(var(--destructive-foreground)); - } -`; - -const HistoryEmpty = styled.div` - margin-top: 10px; - font-size: 12px; - line-height: 1.6; - color: hsl(var(--muted-foreground)); - text-align: center; -`; - -// ==================== Component ==================== - -export function AiImageGenTab({ projectId, onNavigate }: AiImageGenTabProps) { - const { project } = useProject(projectId ?? null); - const { mediaDefaults } = useGlobalMediaGenerationDefaults(); - const effectiveImagePreference = useMemo( - () => - resolveMediaGenerationPreference( - project?.settings?.imageGeneration, - mediaDefaults.image, - ), - [mediaDefaults.image, project?.settings?.imageGeneration], - ); - const { - availableProviders, - selectedProvider, - selectedProviderId, - setSelectedProviderId, - providersLoading, - availableModels, - selectedModel, - selectedModelId, - setSelectedModelId, - selectedSize, - setSelectedSize, - images, - selectedImage, - selectedImageId, - setSelectedImageId, - generating, - savingToResource, - generateImage, - cancelGeneration, - backfillImagesToResource, - deleteImage, - newImage, - } = useImageGen({ - preferredProviderId: effectiveImagePreference.preferredProviderId, - preferredModelId: effectiveImagePreference.preferredModelId, - allowFallback: effectiveImagePreference.allowFallback, - }); - - const { projects, defaultProject, loading: projectsLoading } = useProjects(); - - const [prompt, setPrompt] = useState(""); - const [skills, setSkills] = useState([]); - const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } = - useActiveSkill(); - const promptRef = useRef(null); - const [resolutionPreset, setResolutionPreset] = - useState("1k"); - const [aspectRatio, setAspectRatio] = useState("1:1"); - const [imageCount, setImageCount] = useState(1); - const [isEditingCustomCount, setIsEditingCustomCount] = useState(false); - const [customCountInput, setCustomCountInput] = useState(""); - const [referenceImages, setReferenceImages] = useState( - [], - ); - const [isDraggingUpload, setIsDraggingUpload] = useState(false); - const [targetProjectId, setTargetProjectId] = useState(projectId || ""); - - const fileInputRef = useRef(null); - - // 加载技能列表 - useEffect(() => { - skillsApi - .getAll("lime") - .then(setSkills) - .catch((err) => console.error("加载技能列表失败:", err)); - }, []); - - const availableProjects = useMemo( - () => projects.filter((project) => !project.isArchived), - [projects], - ); - - const selectedTargetProject = useMemo( - () => availableProjects.find((project) => project.id === targetProjectId), - [availableProjects, targetProjectId], - ); - - const supportedSizes = useMemo(() => { - return selectedModel?.supportedSizes || FALLBACK_SUPPORTED_SIZES; - }, [selectedModel]); - - const resolvedSize = useMemo(() => { - return chooseClosestSize(supportedSizes, aspectRatio, resolutionPreset); - }, [supportedSizes, aspectRatio, resolutionPreset]); - - useEffect(() => { - if (resolvedSize !== selectedSize) { - setSelectedSize(resolvedSize); - } - }, [resolvedSize, selectedSize, setSelectedSize]); - - // 初始化或更新目标项目 ID - useEffect(() => { - if (projectsLoading) { - return; - } - - setTargetProjectId((current) => { - // 如果外部传入了 projectId 且不同于当前值,使用外部值 - if (projectId && projectId !== current) { - return projectId; - } - - // 如果当前值有效且在可用项目中,保持不变 - if ( - current && - availableProjects.some((project) => project.id === current) - ) { - return current; - } - - // 尝试从存储中获取 - const storedProjectId = getStoredResourceProjectId({ - includeLegacy: true, - }); - if ( - storedProjectId && - availableProjects.some((project) => project.id === storedProjectId) - ) { - return storedProjectId; - } - - // 使用默认项目 - const preferredProject = - (defaultProject && !defaultProject.isArchived - ? defaultProject - : null) ?? availableProjects[0]; - - return preferredProject?.id || ""; - }); - }, [projectsLoading, availableProjects, defaultProject, projectId]); - - useEffect(() => { - setStoredResourceProjectId(targetProjectId, { - source: "image-gen-target", - syncLegacy: true, - emitEvent: true, - }); - }, [targetProjectId]); - - useEffect(() => { - return onResourceProjectChange((detail) => { - if (detail.source !== "resources") { - return; - } - - const nextProjectId = detail.projectId; - if (!nextProjectId || nextProjectId === targetProjectId) { - return; - } - - if (!availableProjects.some((project) => project.id === nextProjectId)) { - return; - } - - setTargetProjectId(nextProjectId); - }); - }, [availableProjects, targetProjectId]); - - const canGenerate = - !!prompt.trim() && !!selectedProvider && !!selectedModelId && !generating; - - const selectedBatchImages = useMemo(() => { - return resolveBatchImages(images, selectedImageId); - }, [images, selectedImageId]); - - const selectedPromptHistory = useMemo(() => { - return selectedImage?.prompt.trim() || ""; - }, [selectedImage]); - - const isFalProvider = - selectedProvider?.id === "fal" || selectedProvider?.type === "fal"; - - const shouldShowBatchGrid = selectedBatchImages.length > 1; - const completedImageCount = useMemo( - () => images.filter((image) => image.status === "complete").length, - [images], - ); - const generatingImageCount = useMemo( - () => images.filter((image) => image.status === "generating").length, - [images], - ); - const handleCountSelect = (count: number) => { - setImageCount(count); - setIsEditingCustomCount(false); - }; - - const handleCustomCountConfirm = () => { - const next = Number(customCountInput); - if (!Number.isFinite(next)) return; - const normalized = Math.max(1, Math.min(8, Math.floor(next))); - setImageCount(normalized); - setIsEditingCustomCount(false); - setCustomCountInput(""); - }; - - const handleReferenceFiles = async (files: FileList | null) => { - if (!files || files.length === 0) return; - - const remain = Math.max(0, 3 - referenceImages.length); - if (remain === 0) return; - - const selectedFiles = Array.from(files) - .filter((file) => file.type.startsWith("image/")) - .slice(0, remain); - - if (selectedFiles.length === 0) return; - - const loaded = await Promise.all( - selectedFiles.map(async (file) => ({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: file.name, - url: await fileToDataUrl(file), - })), - ); - - setReferenceImages((prev) => [...prev, ...loaded].slice(0, 3)); - }; - - const handleUploadChange = async ( - event: React.ChangeEvent, - ) => { - await handleReferenceFiles(event.target.files); - event.target.value = ""; - }; - - const handleGenerate = async () => { - if (!canGenerate) return; - - const finalPrompt = activeSkill - ? wrapTextWithSkill(prompt.trim()) - : prompt.trim(); - - try { - await generateImage(finalPrompt, { - imageCount, - referenceImages: referenceImages.map((item) => item.url), - size: resolvedSize, - targetProjectId: targetProjectId || undefined, - }); - setPrompt(""); - clearActiveSkill(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - if (errorMessage !== IMAGE_GENERATION_CANCELED_MESSAGE) { - console.error("图片生成失败:", error); - } - } - }; - - const handleCancelGeneration = () => { - if (!generating) { - return; - } - cancelGeneration(); - toast.info(IMAGE_GENERATION_CANCELED_MESSAGE); - }; - - const handleBackfillToResource = async () => { - if (!targetProjectId) { - toast.error("请先选择目标资源库"); - return; - } - - try { - const result = await backfillImagesToResource(targetProjectId); - if (result.failed > 0) { - toast.error(`补录完成:成功 ${result.saved},失败 ${result.failed}`); - } else { - toast.success(`补录完成:新增 ${result.saved},跳过 ${result.skipped}`); - } - - if (result.errors.length > 0) { - console.warn("[ImageGen] 历史补录失败详情:", result.errors); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`补录失败: ${message}`); - } - }; - - const handlePromptKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleGenerate(); - } - }; - - const goCredentialManagement = () => { - onNavigate?.("settings", { tab: SettingsTabs.Providers }); - }; - - return ( - - - - IMAGE STUDIO - - 生成参数 - - - - - 当前服务 - - {selectedProvider?.name || "待配置"} - - - - 当前模型 - {selectedModel?.name || "待选择"} - - - 目标资源库 - - {selectedTargetProject?.name || "不自动入库"} - - - - 输出规格 - {resolvedSize} - - - - - {availableProviders.length > 1 && ( -
- 服务商 - -
- )} - -
- - - 模型 - - - - - - - -
- -
- - - 目标资源库 - - - - - { - void handleBackfillToResource(); - }} - $disabled={ - savingToResource || !targetProjectId || images.length === 0 - } - disabled={ - savingToResource || !targetProjectId || images.length === 0 - } - > - {savingToResource ? "补录中..." : "补录历史到资源库"} - -
- -
- - - 参考图 - - - - {referenceImages.length > 0 ? ( - - {referenceImages.map((item) => ( - - {item.name} - { - setReferenceImages((prev) => - prev.filter((current) => current.id !== item.id), - ); - }} - > - - - - ))} - - ) : ( - fileInputRef.current?.click()} - onDragOver={(event) => { - event.preventDefault(); - setIsDraggingUpload(true); - }} - onDragLeave={(event) => { - event.preventDefault(); - setIsDraggingUpload(false); - }} - onDrop={async (event) => { - event.preventDefault(); - setIsDraggingUpload(false); - await handleReferenceFiles(event.dataTransfer.files); - }} - > - - -
点击或拖拽上传图片
-
支持最多 3 张图片
-
-
- )} - -
- -
- 分辨率 - - {RESOLUTION_OPTIONS.map((option) => ( - setResolutionPreset(option.value)} - > - {option.label} - - ))} - -
- -
- 宽高比 - - {ASPECT_RATIO_OPTIONS.map((ratio) => ( - setAspectRatio(ratio)} - > - {ratio} - - ))} - -
- -
- 图片数量 - {isEditingCustomCount ? ( - setCustomCountInput(event.target.value)} - onBlur={handleCustomCountConfirm} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleCustomCountConfirm(); - } - }} - autoFocus - /> - ) : ( - - {IMAGE_COUNT_PRESETS.map((count) => ( - handleCountSelect(count)} - > - {count} - - ))} - { - setCustomCountInput(String(imageCount)); - setIsEditingCustomCount(true); - }} - > - + - - - )} -
- - 实际输出尺寸:{resolvedSize} - {selectedImage?.status === "complete" && targetProjectId && ( - - {selectedImage.resourceMaterialId && - selectedImage.resourceProjectId === targetProjectId - ? "当前图片已同步到资源库" - : selectedImage.resourceSaveError - ? `当前图片入库失败:${selectedImage.resourceSaveError}` - : savingToResource - ? "当前图片正在同步到资源库..." - : "当前图片尚未同步到资源库"} - - )} -
- - - - - - AI IMAGE - 生成结果 - - - {completedImageCount} 张已完成 - {generatingImageCount > 0 && ( - {generatingImageCount} 张生成中 - )} - - {selectedProvider?.name || "未配置服务"} - - - - - - {shouldShowBatchGrid ? ( - - {selectedBatchImages.map((item, index) => { - const parsedSize = parseSize(item.size); - const previewStyle = parsedSize - ? { - aspectRatio: `${parsedSize.width}/${parsedSize.height}`, - } - : undefined; - - return ( - setSelectedImageId(item.id)} - > - - {item.status === "complete" && item.url ? ( - {item.prompt - ) : ( - - {item.status === "error" ? ( - - ) : ( - - )} - {getStatusText(item.status)} - - )} - - - - 第 {index + 1} 张 - {getStatusText(item.status)} - - - ); - })} - - ) : selectedImage?.status === "complete" && selectedImage.url ? ( - <> - - - - - window.open(selectedImage.url, "_blank")} - > - - - deleteImage(selectedImage.id)} - > - - - - - ) : selectedImage?.status === "error" ? ( - - -

生成失败

-
{selectedImage.error || "请重试"}
-
- ) : ( - - {generating || selectedImage?.status === "generating" ? ( - - ) : ( - - )} -

- {generating || selectedImage?.status === "generating" - ? "正在生成图片" - : "等待生成结果"} -

-
- {generating || selectedImage?.status === "generating" - ? "图片生成完成后会自动出现在这里,你可以继续修改提示词准备下一轮。" - : "提交图片任务后,最新结果会优先显示在这里。"} -
-
- )} - - {shouldShowBatchGrid && - selectedImage?.status === "complete" && - selectedImage.url && ( - - window.open(selectedImage.url, "_blank")} - > - - - deleteImage(selectedImage.id)} - > - - - - )} -
-
- - - {selectedPromptHistory && ( - - 当前图片提示词 - setPrompt(selectedPromptHistory)} - > - {selectedPromptHistory} - - - )} - - {skills.length > 0 && ( - - )} - - {activeSkill && ( - - - - )} - - - setPrompt(event.target.value)} - onKeyDown={handlePromptKeyDown} - placeholder="描述你想要生成的内容" - disabled={!selectedProvider || !selectedModelId} - /> - - {generating ? : } - - - - - {!selectedProvider && ( - - 当前没有可用绘画服务,请先到凭证管理添加可用 Provider。 - - )} -
- - - - 历史 - - - { - newImage(); - }} - > - - - - - {images.map((image) => ( - setSelectedImageId(image.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setSelectedImageId(image.id); - } - }} - > - {image.status === "complete" && image.url ? ( - {image.prompt - ) : ( - - {image.status === "generating" ? ( - - ) : ( - - )} - - )} - - {image.status !== "generating" && ( - { - event.stopPropagation(); - deleteImage(image.id); - }} - > - - - )} - - ))} - - {images.length === 0 && 暂无历史} - - -
- ); -} - -export default AiImageGenTab; diff --git a/src/components/image-gen/tabs/ImageSearchTab.test.tsx b/src/components/image-gen/tabs/ImageSearchTab.test.tsx deleted file mode 100644 index 3d9b497b0..000000000 --- a/src/components/image-gen/tabs/ImageSearchTab.test.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import { act } from "react"; -import type { ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - cleanupMountedRoots, - flushEffects, - renderIntoDom, - setReactActEnvironment, - waitForCondition, - type MountedRoot, -} from "../test-utils"; - -const { mockInvoke, mockToastSuccess, mockToastError } = vi.hoisted(() => ({ - mockInvoke: vi.fn(), - mockToastSuccess: vi.fn(), - mockToastError: vi.fn(), -})); - -const { - mockEmitCanvasImageInsertRequest, - mockOnCanvasImageInsertAck, - mockGetActiveContentTarget, -} = vi.hoisted(() => ({ - mockEmitCanvasImageInsertRequest: vi.fn(), - mockOnCanvasImageInsertAck: vi.fn(), - mockGetActiveContentTarget: vi.fn(), -})); - -vi.mock("@tauri-apps/api/core", () => ({ - invoke: mockInvoke, -})); - -vi.mock("@/components/ui/scroll-area", () => ({ - ScrollArea: ({ children }: { children: ReactNode }) => ( -
{children}
- ), -})); - -vi.mock("sonner", () => ({ - toast: { - success: mockToastSuccess, - error: mockToastError, - }, -})); - -vi.mock("@/lib/canvasImageInsertBus", () => ({ - emitCanvasImageInsertRequest: mockEmitCanvasImageInsertRequest, - onCanvasImageInsertAck: mockOnCanvasImageInsertAck, -})); - -vi.mock("@/lib/activeContentTarget", () => ({ - getActiveContentTarget: mockGetActiveContentTarget, -})); - -import { ImageSearchTab } from "./ImageSearchTab"; - -const mountedRoots: MountedRoot[] = []; - -function renderTab(projectId = "project-1"): HTMLDivElement { - const mounted = renderIntoDom( - , - mountedRoots, - ); - return mounted.container; -} - -function findTextarea(container: HTMLElement): HTMLTextAreaElement { - const node = container.querySelector("textarea"); - if (!node) { - throw new Error("未找到搜索输入框"); - } - return node as HTMLTextAreaElement; -} - -function findButton(container: HTMLElement, text: string): HTMLButtonElement { - const target = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes(text), - ); - if (!target) { - throw new Error(`未找到按钮: ${text}`); - } - return target as HTMLButtonElement; -} - -async function setInputValue(input: HTMLTextAreaElement, value: string) { - const nativeSetter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )?.set; - if (!nativeSetter) { - throw new Error("未找到 textarea value setter"); - } - - await act(async () => { - nativeSetter.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - await flushEffects(); - }); -} - -beforeEach(() => { - setReactActEnvironment(); - vi.clearAllMocks(); - vi.stubGlobal("open", vi.fn()); - mockGetActiveContentTarget.mockReturnValue({ - projectId: "project-1", - contentId: "content-1", - canvasType: "document", - }); - mockOnCanvasImageInsertAck.mockReturnValue(() => undefined); - mockEmitCanvasImageInsertRequest.mockReturnValue({ - requestId: "insert-1", - }); - - mockInvoke.mockImplementation((command, payload) => { - if (command === "search_pixabay_images") { - const page = payload.req.page; - return Promise.resolve({ - total: 40, - total_hits: 40, - hits: [ - { - id: page, - preview_url: `https://pixabay.example/${page}-preview.jpg`, - large_image_url: `https://pixabay.example/${page}-large.jpg`, - image_width: 1200, - image_height: 800, - tags: `pixabay-${page}`, - page_url: `https://pixabay.com/photos/${page}`, - user: "pixabay-user", - }, - ], - }); - } - - if (command === "search_web_images") { - const page = payload.req.page; - return Promise.resolve({ - total: 40, - provider: "pexels", - hits: [ - { - id: `w-${page}`, - thumbnail_url: `https://pexels.example/${page}-thumb.jpg`, - content_url: `https://pexels.example/${page}-image.jpg`, - width: 1080, - height: 1920, - name: `pexels-${page}`, - host_page_url: `https://www.pexels.com/photo/${page}`, - }, - ], - }); - } - - if (command === "import_material_from_url") { - return Promise.resolve({ id: "mock-material-id" }); - } - - return Promise.reject(new Error(`unexpected command: ${command}`)); - }); -}); - -afterEach(() => { - cleanupMountedRoots(mountedRoots); - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -describe("ImageSearchTab", () => { - it("应支持切换来源并展示对应 attribution", async () => { - const container = renderTab(); - await setInputValue(findTextarea(container), "city"); - - await act(async () => { - findButton(container, "搜索图片").click(); - await flushEffects(); - }); - - await waitForCondition( - () => container.textContent?.includes("图片来源: Pexels") ?? false, - 50, - "未展示 Pexels 来源", - ); - - await act(async () => { - findButton(container, "Pixabay图库").click(); - }); - - await act(async () => { - findButton(container, "搜索图片").click(); - await flushEffects(); - }); - - await waitForCondition( - () => container.textContent?.includes("图片来源: Pixabay") ?? false, - 50, - "未展示 Pixabay 来源", - ); - - expect(mockInvoke).toHaveBeenCalledWith( - "search_web_images", - expect.objectContaining({ - req: expect.objectContaining({ - query: "city", - page: 1, - }), - }), - ); - }); - - it("在联网来源保存图片时应使用 pexels 标签", async () => { - const container = renderTab(); - await setInputValue(findTextarea(container), "city"); - - await act(async () => { - findButton(container, "联网搜索").click(); - }); - - await act(async () => { - findButton(container, "搜索图片").click(); - await flushEffects(); - }); - - await waitForCondition( - () => container.textContent?.includes("保存") ?? false, - 50, - "搜索结果未渲染保存按钮", - ); - - await act(async () => { - findButton(container, "保存").click(); - await flushEffects(); - }); - - expect(mockInvoke).toHaveBeenCalledWith( - "import_material_from_url", - expect.objectContaining({ - req: expect.objectContaining({ - projectId: "project-1", - type: "image", - tags: ["pexels"], - }), - }), - ); - expect(mockToastSuccess).toHaveBeenCalled(); - }); - - it("当前来源加载更多应请求下一页", async () => { - const container = renderTab(); - await setInputValue(findTextarea(container), "beach"); - - await act(async () => { - findButton(container, "联网搜索").click(); - await flushEffects(); - }); - - await act(async () => { - findButton(container, "搜索图片").click(); - await flushEffects(); - }); - - await waitForCondition( - () => container.textContent?.includes("加载更多") ?? false, - 50, - "未出现加载更多按钮", - ); - - await act(async () => { - findButton(container, "加载更多").click(); - await flushEffects(); - }); - - const webSearchPages = mockInvoke.mock.calls - .filter(([command]) => command === "search_web_images") - .map(([, payload]) => payload.req.page); - expect(webSearchPages).toEqual([1, 2]); - }); - - it("应支持将搜索图片插入当前画布", async () => { - const container = renderTab(); - await setInputValue(findTextarea(container), "city"); - - await act(async () => { - findButton(container, "搜索图片").click(); - await flushEffects(); - }); - - await waitForCondition( - () => container.textContent?.includes("插入当前画布") ?? false, - 50, - "搜索结果未渲染插入按钮", - ); - - await act(async () => { - findButton(container, "插入当前画布").click(); - await flushEffects(); - }); - - expect(mockEmitCanvasImageInsertRequest).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - contentId: "content-1", - canvasType: "document", - source: "pexels", - image: expect.objectContaining({ - contentUrl: "https://pexels.example/1-image.jpg", - attributionName: "Pexels", - }), - }), - ); - expect(mockToastSuccess).toHaveBeenCalled(); - }); -}); diff --git a/src/components/image-gen/tabs/ImageSearchTab.tsx b/src/components/image-gen/tabs/ImageSearchTab.tsx deleted file mode 100644 index 189fa848d..000000000 --- a/src/components/image-gen/tabs/ImageSearchTab.tsx +++ /dev/null @@ -1,1166 +0,0 @@ -/** - * @file 图片搜索 Tab - * @description 使用 Pixabay API 搜索在线图片,参考 turbodesk 排版优化 - * @module components/image-gen/tabs/ImageSearchTab - */ - -import { useEffect, useRef, useState, type KeyboardEvent } from "react"; -import { open as openExternal } from "@tauri-apps/plugin-shell"; -import { - Search, - Loader2, - Image as ImageIcon, - ExternalLink, - Download, - ChevronDown, - Globe, - ImagePlus, - FilePlus2, -} from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { toast } from "sonner"; -import styled, { keyframes } from "styled-components"; -import { - useImageSearch, - type AspectRatioFilter, - type SearchSource, -} from "../hooks/useImageSearch"; -import { getActiveContentTarget } from "@/lib/activeContentTarget"; -import { - emitCanvasImageInsertRequest, - onCanvasImageInsertAck, - type CanvasImageInsertAck, - type CanvasImageTargetType, -} from "@/lib/canvasImageInsertBus"; -import { - addCanvasImageInsertHistory, - getCanvasImageInsertHistory, - type CanvasImageInsertHistoryEntry, -} from "@/lib/canvasImageInsertHistory"; -import { importMaterialFromUrl } from "@/lib/api/materials"; -import type { Page, PageParams } from "@/types/page"; - -export interface ImageSearchTabProps { - /** 目标项目 ID */ - projectId?: string | null; - /** 页面跳转 */ - onNavigate?: (page: Page, params?: PageParams) => void; -} - -const CANVAS_DISPLAY_NAME: Record = { - auto: "当前画布", - document: "文档", - video: "视频", -}; - -function normalizeCanvasType( - value: string | null | undefined, -): CanvasImageTargetType { - if (value === "document" || value === "video") { - return value; - } - if (value === "script") { - return "video"; - } - return "document"; -} - -function mapCanvasTypeToTheme(canvasType: CanvasImageTargetType): string { - switch (canvasType) { - case "video": - return "video"; - case "document": - case "auto": - default: - return "document"; - } -} - -function getVisibleInsertHistory( - projectId?: string | null, -): CanvasImageInsertHistoryEntry[] { - const history = getCanvasImageInsertHistory(); - const filtered = projectId - ? history.filter((entry) => entry.projectId === projectId) - : history; - return filtered.slice(0, 5); -} - -// ==================== Animations ==================== - -const fadeIn = keyframes` - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -`; - -const shimmer = keyframes` - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -`; - -// ==================== Styled Components ==================== - -const Container = styled.div` - height: 100%; - display: flex; - flex-direction: column; - gap: 10px; - padding: 10px; - background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%); -`; - -const SearchPanel = styled.div` - position: relative; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 10px; - border: 1px solid hsl(var(--border) / 0.78); - border-radius: 24px; - background: hsl(var(--background) / 0.84); - box-shadow: - 0 16px 38px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - overflow: visible; -`; - -const ComposerRow = styled.div` - display: grid; - grid-template-columns: minmax(0, 1fr) 148px; - gap: 10px; - - @media (max-width: 900px) { - grid-template-columns: 1fr; - } -`; - -const SearchToolsRow = styled.div` - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -`; - -const PromptArea = styled.textarea` - width: 100%; - min-height: 76px; - max-height: 128px; - padding: 14px 16px; - border: 1px solid hsl(var(--border)); - border-radius: 18px; - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(var(--muted) / 0.12) - ); - color: hsl(var(--foreground)); - font-size: 14px; - line-height: 1.6; - resize: none; - font-family: inherit; - transition: all 0.2s ease; - - &:focus { - outline: none; - border-color: hsl(214 68% 38% / 0.34); - background: hsl(var(--background)); - box-shadow: 0 0 0 4px hsl(211 100% 96%); - } - - &::placeholder { - color: hsl(var(--muted-foreground)); - } -`; - -const FiltersRow = styled.div` - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -`; - -const RatioDropdown = styled.button` - display: inline-flex; - align-items: center; - gap: 6px; - height: 38px; - padding: 0 14px; - border: 1px solid hsl(var(--border)); - border-radius: 14px; - background: hsl(var(--background) / 0.92); - color: hsl(var(--foreground)); - font-size: 13px; - font-weight: 600; - cursor: pointer; - position: relative; - transition: all 0.2s; - - &:hover { - border-color: hsl(214 68% 38% / 0.28); - background: hsl(var(--background)); - } -`; - -const RatioOptions = styled.div<{ $open: boolean }>` - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 50; - min-width: 140px; - padding: 6px; - border: 1px solid hsl(var(--border)); - border-radius: 14px; - background: hsl(var(--popover)); - box-shadow: 0 16px 36px hsl(215 32% 12% / 0.14); - display: ${({ $open }) => ($open ? "flex" : "none")}; - flex-direction: column; - gap: 2px; - backdrop-filter: blur(20px); -`; - -const RatioOption = styled.div<{ $active: boolean }>` - padding: 8px 12px; - border: none; - border-radius: 8px; - background: ${({ $active }) => - $active ? "hsl(var(--primary) / 0.12)" : "transparent"}; - color: ${({ $active }) => - $active ? "hsl(var(--primary))" : "hsl(var(--foreground))"}; - font-size: 13px; - font-weight: ${({ $active }) => ($active ? 600 : 400)}; - cursor: pointer; - text-align: left; - transition: all 0.15s; - user-select: none; - - &:hover { - background: ${({ $active }) => - $active ? "hsl(var(--primary) / 0.15)" : "hsl(var(--accent))"}; - } -`; - -const SearchButton = styled.button<{ $loading: boolean }>` - width: 100%; - min-height: 76px; - border: 1px solid hsl(215 28% 17% / 0.92); - border-radius: 18px; - background: linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%)); - color: hsl(var(--background)); - font-size: 14px; - font-weight: 700; - cursor: ${({ $loading }) => ($loading ? "wait" : "pointer")}; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: all 0.25s ease; - position: relative; - overflow: hidden; - - &:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 18px 36px hsl(220 40% 12% / 0.18); - } - - &:active:not(:disabled) { - transform: translateY(0); - } - - &:disabled { - opacity: 0.65; - cursor: not-allowed; - } - - &::before { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 90deg, - transparent, - hsl(0 0% 100% / 0.15), - transparent - ); - background-size: 200% 100%; - animation: ${({ $loading }) => ($loading ? shimmer : "none")} 1.5s infinite; - } -`; - -const SourceTabs = styled.div` - display: flex; - flex: 1; - min-width: 280px; - gap: 4px; - padding: 4px; - border: 1px solid hsl(var(--border) / 0.82); - border-radius: 16px; - overflow: hidden; - background: hsl(var(--muted) / 0.18); -`; - -const SourceTab = styled.button<{ $active: boolean }>` - flex: 1; - min-height: 38px; - padding: 0 16px; - border: 1px solid - ${({ $active }) => ($active ? "hsl(214 68% 38% / 0.18)" : "transparent")}; - border-radius: 12px; - background: ${({ $active }) => - $active - ? "linear-gradient(180deg, hsl(var(--background)), hsl(203 100% 97%))" - : "transparent"}; - color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; - font-size: 13px; - font-weight: ${({ $active }) => ($active ? 700 : 600)}; - cursor: pointer; - transition: all 0.2s ease; - position: relative; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - - &:hover { - color: ${({ $active }) => - $active ? "hsl(var(--foreground))" : "hsl(var(--foreground))"}; - background: ${({ $active }) => - $active - ? "linear-gradient(180deg, hsl(var(--background)), hsl(203 100% 97%))" - : "hsl(var(--background) / 0.72)"}; - } -`; - -const ResultsArea = styled.div` - flex: 1; - min-height: 0; - border-radius: 28px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--background) / 0.84); - box-shadow: - 0 18px 42px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - overflow: hidden; -`; - -const ImageGrid = styled.div` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 14px; - padding: 14px 16px 18px; -`; - -const ImageCard = styled.div<{ $aspectRatio?: number }>` - position: relative; - aspect-ratio: ${({ $aspectRatio }) => - $aspectRatio ? Math.max(0.82, Math.min(1.5, $aspectRatio)) : 4 / 3}; - min-height: 180px; - max-height: 340px; - border-radius: 20px; - border: 1px solid hsl(var(--border) / 0.82); - overflow: hidden; - cursor: pointer; - animation: ${fadeIn} 0.35s ease both; - transition: all 0.25s ease; - background: hsl(var(--background)); - box-shadow: - 0 14px 34px hsl(215 32% 12% / 0.06), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - - &:hover { - transform: translateY(-3px); - box-shadow: 0 20px 44px hsl(215 32% 12% / 0.1); - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform 0.4s ease; - } - - &:hover img { - transform: scale(1.06); - } -`; - -const Overlay = styled.div` - position: absolute; - inset: 0; - background: linear-gradient( - 180deg, - transparent 34%, - hsl(220 40% 12% / 0.78) 100% - ); - opacity: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - padding: 16px; - gap: 8px; - transition: opacity 0.25s ease; - - ${ImageCard}:hover & { - opacity: 1; - } -`; - -const OverlayActions = styled.div` - display: flex; - gap: 8px; - width: 100%; -`; - -const ActionButton = styled.button<{ $primary?: boolean }>` - flex: 1; - height: 36px; - border: ${({ $primary }) => - $primary ? "none" : "1px solid hsl(0 0% 100% / 0.2)"}; - border-radius: 12px; - background: ${({ $primary }) => - $primary - ? "linear-gradient(180deg, hsl(221 39% 18%), hsl(216 34% 14%))" - : "hsl(0 0% 100% / 0.12)"}; - color: ${({ $primary }) => - $primary ? "hsl(var(--background))" : "hsl(var(--background))"}; - font-size: 12px; - font-weight: 600; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - backdrop-filter: blur(12px); - transition: all 0.2s ease; - - &:hover { - transform: scale(1.03); - box-shadow: 0 8px 18px hsl(215 32% 12% / 0.22); - } - - &:disabled { - opacity: 0.5; - cursor: wait; - } -`; - -const MetaBadge = styled.div` - position: absolute; - top: 8px; - right: 8px; - padding: 3px 8px; - border-radius: 999px; - background: hsl(var(--background) / 0.84); - backdrop-filter: blur(8px); - font-size: 10px; - color: hsl(var(--foreground) / 0.8); - opacity: 0; - transition: opacity 0.2s; - - ${ImageCard}:hover & { - opacity: 1; - } -`; - -const ProviderBadge = styled.div` - position: absolute; - bottom: 8px; - left: 8px; - padding: 3px 8px; - border-radius: 999px; - background: hsl(221 39% 16% / 0.92); - backdrop-filter: blur(8px); - font-size: 10px; - color: hsl(var(--background)); - display: flex; - align-items: center; - gap: 4px; - opacity: 0; - transition: opacity 0.2s; - - ${ImageCard}:hover & { - opacity: 1; - } -`; - -const EmptyState = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100%; - padding: 72px 48px; - gap: 16px; - color: hsl(var(--muted-foreground)); - text-align: center; -`; - -const EmptyIcon = styled.div` - width: 80px; - height: 80px; - border-radius: 24px; - background: linear-gradient( - 135deg, - hsl(203 100% 97%), - hsl(201 52% 94% / 0.86) - ); - display: flex; - align-items: center; - justify-content: center; - color: hsl(211 58% 38%); -`; - -const EmptyTitle = styled.h3` - margin: 0; - font-size: 22px; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const EmptyHint = styled.p` - margin: 0; - font-size: 13px; - color: hsl(var(--muted-foreground)); -`; - -const Footer = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - gap: 12px; - flex-wrap: wrap; - font-size: 12px; - color: hsl(var(--muted-foreground)); - border-top: 1px solid hsl(var(--border) / 0.42); -`; - -const Attribution = styled.a` - color: hsl(211 58% 38%); - text-decoration: none; - font-weight: 600; - transition: color 0.2s; - - &:hover { - color: hsl(var(--primary)); - text-decoration: underline; - } -`; - -const LoadMoreButton = styled.button<{ $loading: boolean }>` - width: calc(100% - 32px); - margin: 0 16px 16px; - height: 40px; - border: 1px solid hsl(var(--border)); - border-radius: 14px; - background: hsl(var(--background) / 0.9); - color: hsl(var(--foreground)); - font-size: 13px; - font-weight: 600; - cursor: ${({ $loading }) => ($loading ? "wait" : "pointer")}; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: all 0.2s; - - &:hover:not(:disabled) { - border-color: hsl(214 68% 38% / 0.28); - background: hsl(var(--background)); - transform: translateY(-1px); - } - - &:disabled { - opacity: 0.5; - } -`; - -const ResultCount = styled.span` - font-size: 13px; - color: hsl(var(--muted-foreground)); - padding: 14px 16px 0; - display: block; -`; - -const RecentInsertPanel = styled.div` - border: 1px solid hsl(var(--border) / 0.55); - border-radius: 18px; - background: hsl(var(--background) / 0.86); - padding: 10px 12px; - display: flex; - flex-direction: column; - gap: 8px; -`; - -const RecentInsertHeader = styled.div` - font-size: 12px; - font-weight: 600; - color: hsl(var(--muted-foreground)); -`; - -const RecentInsertList = styled.div` - display: flex; - flex-wrap: wrap; - gap: 8px; -`; - -const RecentInsertItem = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 260px; - padding: 8px 10px; - border-radius: 14px; - border: 1px solid hsl(var(--border) / 0.8); - background: hsl(var(--background) / 0.88); -`; - -const RecentInsertMeta = styled.div` - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -`; - -const RecentInsertTitle = styled.div` - font-size: 12px; - color: hsl(var(--foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const RecentInsertHint = styled.div` - font-size: 11px; - color: hsl(var(--muted-foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const RelocateButton = styled.button` - border: 1px solid hsl(var(--border)); - border-radius: 12px; - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-size: 11px; - padding: 6px 10px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - flex-shrink: 0; - - &:hover { - border-color: hsl(214 68% 38% / 0.32); - color: hsl(211 58% 38%); - } -`; - -// ==================== Constants ==================== - -const SOURCE_TABS = [ - { key: "web", label: "联网搜索", icon: Globe }, - { key: "pixabay", label: "Pixabay图库", icon: ImagePlus }, -] as const; - -const SOURCE_HINTS: Record< - SearchSource, - { - resultLabel: string; - attributionName: string; - attributionUrl: string; - } -> = { - web: { - resultLabel: "Pexels 图库", - attributionName: "Pexels", - attributionUrl: "https://www.pexels.com", - }, - pixabay: { - resultLabel: "Pixabay 图库", - attributionName: "Pixabay", - attributionUrl: "https://pixabay.com", - }, -}; - -const RATIO_OPTIONS: Array<{ - value: AspectRatioFilter; - label: string; - icon: string; -}> = [ - { value: "all", label: "不限比例", icon: "⬜" }, - { value: "landscape", label: "横向", icon: "▬" }, - { value: "portrait", label: "纵向", icon: "▮" }, - { value: "square", label: "方形", icon: "◻" }, -]; - -// ==================== Component ==================== - -export function ImageSearchTab({ projectId, onNavigate }: ImageSearchTabProps) { - const { - query, - setQuery, - aspectRatio, - setAspectRatio, - sourceStates, - search, - loadMore, - } = useImageSearch(); - - const [savingId, setSavingId] = useState(null); - const [ratioOpen, setRatioOpen] = useState(false); - const [searchSource, setSearchSource] = useState("web"); - const pendingInsertRequestMetaRef = useRef< - Map< - string, - { - projectId: string; - contentId: string | null; - canvasType: CanvasImageTargetType; - theme: string; - imageTitle?: string; - } - > - >(new Map()); - const [recentInsertHistory, setRecentInsertHistory] = useState< - CanvasImageInsertHistoryEntry[] - >(() => getVisibleInsertHistory(projectId)); - - const currentState = sourceStates[searchSource]; - const results = currentState.results; - const loading = currentState.loading; - const total = currentState.total; - const error = currentState.error; - const lastQuery = currentState.lastQuery; - const hasMore = results.length < total; - const sourceHint = SOURCE_HINTS[searchSource]; - - const currentRatioLabel = - RATIO_OPTIONS.find((opt) => opt.value === aspectRatio)?.label || "不限比例"; - - useEffect(() => { - setRecentInsertHistory(getVisibleInsertHistory(projectId)); - }, [projectId]); - - useEffect(() => { - const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => { - const pendingMeta = pendingInsertRequestMetaRef.current.get( - ack.requestId, - ); - if (!pendingMeta) { - return; - } - pendingInsertRequestMetaRef.current.delete(ack.requestId); - - if (ack.success) { - const canvasLabel = CANVAS_DISPLAY_NAME[ack.canvasType] || "目标画布"; - const locationLabel = ack.locationLabel - ? ` · ${ack.locationLabel}` - : ""; - toast.success(`已插入到${canvasLabel}${locationLabel}`); - - const nextHistory = addCanvasImageInsertHistory({ - requestId: ack.requestId, - projectId: pendingMeta.projectId, - contentId: pendingMeta.contentId, - canvasType: pendingMeta.canvasType, - theme: pendingMeta.theme, - imageTitle: pendingMeta.imageTitle, - locationLabel: ack.locationLabel, - }); - setRecentInsertHistory( - (projectId - ? nextHistory.filter((entry) => entry.projectId === projectId) - : nextHistory - ).slice(0, 5), - ); - } else { - toast.error("插入失败,请返回创作区重试"); - } - }); - - return unsubscribe; - }, [projectId]); - - const handleSearch = () => { - if (!query.trim()) { - toast.error("请输入搜索关键词"); - return; - } - void search(searchSource, query, true); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSearch(); - } - }; - - const handleSaveImage = async ( - imageUrl: string, - imageName: string, - provider: "pixabay" | "pexels", - ) => { - if (!projectId) { - toast.error("请先选择项目"); - return; - } - - setSavingId(imageUrl); - try { - await importMaterialFromUrl({ - projectId, - name: imageName, - type: "image", - url: imageUrl, - tags: [provider], - }); - toast.success("已保存到图片库"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`保存失败: ${message}`); - } finally { - setSavingId(null); - } - }; - - const handleInsertImageToCanvas = (image: { - id: string; - previewUrl: string; - largeUrl: string; - pageUrl: string; - tags: string; - width: number; - height: number; - provider: "pixabay" | "pexels"; - }) => { - if (!projectId) { - toast.error("请先选择项目"); - return; - } - - const target = getActiveContentTarget(); - const sameProjectTarget = target?.projectId === projectId ? target : null; - const targetContentId = sameProjectTarget?.contentId ?? null; - const targetCanvasType = normalizeCanvasType(sameProjectTarget?.canvasType); - const targetTheme = mapCanvasTypeToTheme(targetCanvasType); - const request = emitCanvasImageInsertRequest({ - projectId, - contentId: targetContentId, - canvasType: targetCanvasType, - anchorHint: - targetCanvasType === "video" ? "video_start_frame" : "section_end", - source: image.provider === "pexels" ? "pexels" : "pixabay", - image: { - id: image.id, - previewUrl: image.previewUrl, - contentUrl: image.largeUrl || image.previewUrl, - pageUrl: image.pageUrl, - title: image.tags, - width: image.width, - height: image.height, - attributionName: image.provider === "pexels" ? "Pexels" : "Pixabay", - provider: image.provider, - }, - }); - pendingInsertRequestMetaRef.current.set(request.requestId, { - projectId, - contentId: targetContentId, - canvasType: targetCanvasType, - theme: targetTheme, - imageTitle: image.tags, - }); - - onNavigate?.("agent", { - projectId, - contentId: targetContentId ?? undefined, - theme: targetTheme, - lockTheme: false, - }); - - const canvasLabel = CANVAS_DISPLAY_NAME[targetCanvasType] || "当前画布"; - toast.success(`已发送到${canvasLabel},正在自动定位`); - }; - - const handleRelocateToInsert = (entry: CanvasImageInsertHistoryEntry) => { - onNavigate?.("agent", { - projectId: entry.projectId, - contentId: entry.contentId ?? undefined, - theme: entry.theme, - lockTheme: false, - }); - const canvasLabel = CANVAS_DISPLAY_NAME[entry.canvasType] || "目标画布"; - toast.success(`正在定位到${canvasLabel}`); - }; - - const openPreviewWindow = async (url: string) => { - if (!url) return; - try { - await openExternal(url); - } catch (error) { - console.error("打开预览窗口失败:", error); - window.open(url, "_blank"); - } - }; - - return ( - - - - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - rows={3} - /> - - - {loading ? ( - <> - - 搜索中... - - ) : ( - <> - - 搜索图片 - - )} - - - - - - setRatioOpen(!ratioOpen)} - onBlur={() => setTimeout(() => setRatioOpen(false), 150)} - > - - {currentRatioLabel} - - - {RATIO_OPTIONS.map((option) => ( - { - e.preventDefault(); - setAspectRatio(option.value); - setRatioOpen(false); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setAspectRatio(option.value); - setRatioOpen(false); - } - }} - > - {option.icon} {option.label} - - ))} - - - - - - {SOURCE_TABS.map((tab) => ( - setSearchSource(tab.key as SearchSource)} - > - - {tab.label} - - ))} - - - - {recentInsertHistory.length > 0 && ( - - 最近插入记录(可一键定位) - - {recentInsertHistory.map((entry) => { - const canvasLabel = - CANVAS_DISPLAY_NAME[entry.canvasType] || "画布"; - const locationLabel = entry.locationLabel || "已插入"; - return ( - - - - {entry.imageTitle?.trim() || "图片"} · {canvasLabel} - - {locationLabel} - - handleRelocateToInsert(entry)} - > - 再次定位 - - - ); - })} - - - )} - - - - - {results.length === 0 && !loading ? ( - - - - - 搜索海量图片 - - {error - ? `搜索失败:${error}` - : lastQuery - ? `未找到与「${lastQuery}」相关的图片,建议尝试英文关键词或更换来源。` - : `输入关键词搜索图片,结果来自 ${sourceHint.resultLabel}`} - - - ) : ( - <> - {results.length > 0 && ( - - 共找到 {total.toLocaleString()} 张图片 - {results.length < total && `,已加载 ${results.length} 张`} - - )} - - - {results.map((img, index) => { - const ratio = img.width / img.height; - const isSaving = savingId === img.largeUrl; - const displayName = img.tags.split(",")[0]?.trim() || "图片"; - - return ( - { - void openPreviewWindow(img.largeUrl || img.pageUrl); - }} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - void openPreviewWindow(img.largeUrl || img.pageUrl); - } - }} - > - {img.tags} - - {img.width}×{img.height} - - - {img.provider === "pixabay" ? "Pixabay" : "Pexels"} - - - - { - e.stopPropagation(); - handleSaveImage( - img.largeUrl, - displayName, - img.provider, - ); - }} - disabled={isSaving} - > - {isSaving ? ( - - ) : ( - <> - - 保存 - - )} - - { - e.stopPropagation(); - handleInsertImageToCanvas(img); - }} - > - - 插入当前画布 - - { - e.stopPropagation(); - void openPreviewWindow(img.pageUrl); - }} - > - - 预览 - - - - - ); - })} - - - {hasMore && ( - loadMore(searchSource)} - disabled={loading} - > - {loading ? ( - <> - - 加载中... - - ) : ( - "加载更多" - )} - - )} - - )} - - - - {results.length > 0 && ( -
- - 已显示 {results.length} / {total.toLocaleString()} 张 - - - 图片来源: {sourceHint.attributionName} - -
- )} -
- ); -} - -export default ImageSearchTab; diff --git a/src/components/image-gen/tabs/LocalImageTab.tsx b/src/components/image-gen/tabs/LocalImageTab.tsx deleted file mode 100644 index 580f9d0a4..000000000 --- a/src/components/image-gen/tabs/LocalImageTab.tsx +++ /dev/null @@ -1,432 +0,0 @@ -/** - * @file 本地图片 Tab - * @description 从本地文件系统选择图片并保存到图片库 - * @module components/image-gen/tabs/LocalImageTab - */ - -import { useState } from "react"; -import { open } from "@tauri-apps/plugin-dialog"; -import { ImagePlus, Loader2, Upload, Trash2 } from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { toast } from "sonner"; -import styled, { keyframes } from "styled-components"; -import { uploadMaterial } from "@/lib/api/materials"; - -export interface LocalImageTabProps { - /** 目标项目 ID */ - projectId?: string | null; -} - -// ==================== Animations ==================== - -const fadeIn = keyframes` - from { opacity: 0; transform: scale(0.96); } - to { opacity: 1; transform: scale(1); } -`; - -const float = keyframes` - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-8px); } -`; - -// ==================== Styled Components ==================== - -const Container = styled.div` - height: 100%; - display: flex; - flex-direction: column; - padding: 10px; - background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%); -`; - -const Surface = styled.div` - flex: 1; - display: flex; - min-height: 0; - flex-direction: column; - border-radius: 28px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--background) / 0.84); - box-shadow: - 0 18px 42px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - overflow: hidden; -`; - -const Content = styled.div` - flex: 1; - display: flex; - min-height: 0; - align-items: center; - justify-content: center; - padding: 28px; - gap: 18px; -`; - -const DropZone = styled.div<{ $dragging?: boolean }>` - width: 100%; - max-width: 760px; - min-height: 360px; - border: 1.5px dashed - ${({ $dragging }) => - $dragging ? "hsl(214 68% 38% / 0.48)" : "hsl(var(--border) / 0.9)"}; - border-radius: 28px; - padding: 40px 32px; - text-align: center; - cursor: pointer; - transition: all 0.3s ease; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - background: ${({ $dragging }) => - $dragging - ? "hsl(211 100% 96%)" - : "linear-gradient(180deg, hsl(var(--background)), hsl(201 42% 98% / 0.9))"}; - box-shadow: - 0 18px 38px hsl(215 32% 12% / 0.04), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - - &:hover { - border-color: hsl(214 68% 38% / 0.34); - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(203 100% 97% / 0.94) - ); - transform: translateY(-2px); - box-shadow: 0 20px 44px hsl(215 32% 12% / 0.08); - } -`; - -const IconContainer = styled.div` - width: 76px; - height: 76px; - border-radius: 22px; - background: linear-gradient(135deg, hsl(203 100% 97%), hsl(201 52% 94%)); - display: flex; - align-items: center; - justify-content: center; - color: hsl(211 58% 38%); - animation: ${float} 3s ease-in-out infinite; -`; - -const DropTitle = styled.p` - margin: 0; - font-size: 24px; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const DropHint = styled.p` - margin: 0; - max-width: 420px; - font-size: 13px; - color: hsl(var(--muted-foreground)); - line-height: 1.6; -`; - -const DropActionText = styled.div` - display: inline-flex; - align-items: center; - justify-content: center; - height: 38px; - padding: 0 18px; - border-radius: 14px; - border: 1px solid hsl(215 28% 17% / 0.92); - background: linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%)); - color: hsl(var(--background)); - font-size: 13px; - font-weight: 700; - box-shadow: 0 14px 28px hsl(220 40% 12% / 0.12); -`; - -const PreviewContainer = styled.div` - width: 100%; - max-width: 1080px; - display: flex; - flex-direction: column; - align-items: center; - gap: 14px; - animation: ${fadeIn} 0.35s ease; -`; - -const PreviewMeta = styled.div` - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - flex-wrap: wrap; - width: 100%; -`; - -const PreviewChip = styled.div` - display: inline-flex; - align-items: center; - min-height: 30px; - max-width: min(100%, 680px); - padding: 0 12px; - border-radius: 999px; - border: 1px solid hsl(var(--border) / 0.84); - background: hsl(var(--background) / 0.86); - font-size: 12px; - font-weight: 600; - color: hsl(var(--muted-foreground)); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const PreviewCard = styled.div` - position: relative; - width: 100%; - border-radius: 24px; - overflow: hidden; - border: 1px solid hsl(var(--border) / 0.82); - background: linear-gradient( - 180deg, - hsl(var(--background)), - hsl(210 20% 98% / 0.96) - ); - box-shadow: - 0 20px 46px hsl(215 32% 12% / 0.08), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - padding: 14px; - - img { - display: block; - width: 100%; - max-height: min(62vh, 640px); - object-fit: contain; - border-radius: 18px; - background: hsl(210 40% 99%); - } -`; - -const Actions = styled.div` - display: flex; - gap: 12px; - flex-wrap: wrap; - justify-content: center; -`; - -const ActionButton = styled.button<{ $primary?: boolean; $danger?: boolean }>` - height: 42px; - padding: 0 18px; - border: ${({ $primary }) => - $primary ? "none" : "1px solid hsl(var(--border))"}; - border-radius: 14px; - background: ${({ $primary, $danger }) => - $primary - ? "linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%))" - : $danger - ? "transparent" - : "hsl(var(--background) / 0.9)"}; - color: ${({ $primary, $danger }) => - $primary - ? "hsl(var(--background))" - : $danger - ? "hsl(var(--destructive))" - : "hsl(var(--foreground))"}; - font-size: 13px; - font-weight: 700; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 8px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: ${({ $primary }) => - $primary - ? "0 16px 32px hsl(220 40% 12% / 0.16)" - : "0 10px 20px hsl(215 32% 12% / 0.08)"}; - } - - &:disabled { - opacity: 0.5; - cursor: wait; - } -`; - -const NoProjectState = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - gap: 14px; - color: hsl(var(--muted-foreground)); - text-align: center; - padding: 48px; -`; - -const NoProjectIcon = styled.div` - width: 72px; - height: 72px; - border-radius: 20px; - background: linear-gradient( - 135deg, - hsl(203 100% 97%), - hsl(201 52% 94% / 0.86) - ); - display: flex; - align-items: center; - justify-content: center; - color: hsl(211 58% 38%); -`; - -// ==================== Component ==================== - -export function LocalImageTab({ projectId }: LocalImageTabProps) { - const [previewUrl, setPreviewUrl] = useState(null); - const [selectedPath, setSelectedPath] = useState(null); - const [saving, setSaving] = useState(false); - - const handleSelectFile = async () => { - try { - const filePath = await open({ - filters: [ - { - name: "图片", - extensions: ["jpg", "jpeg", "png", "webp", "gif", "bmp"], - }, - ], - multiple: false, - }); - - if (filePath && typeof filePath === "string") { - setSelectedPath(filePath); - setPreviewUrl(`asset://localhost/${filePath}`); - } - } catch (error) { - console.error("选择文件失败:", error); - } - }; - - const handleSaveToGallery = async () => { - if (!projectId) { - toast.error("请先选择项目"); - return; - } - - if (!selectedPath) { - toast.error("请先选择图片"); - return; - } - - setSaving(true); - try { - await uploadMaterial({ - projectId, - name: selectedPath.split("/").pop() || "本地图片", - type: "image", - filePath: selectedPath, - tags: ["local"], - }); - toast.success("已保存到图片库"); - - // 清空选择 - setPreviewUrl(null); - setSelectedPath(null); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`保存失败: ${message}`); - } finally { - setSaving(false); - } - }; - - const handleClear = () => { - setPreviewUrl(null); - setSelectedPath(null); - }; - - if (!projectId) { - return ( - - - - - - -
- 请先选择项目 -
-
- 在右上角选择一个项目后即可上传本地图片 -
-
-
-
- ); - } - - return ( - - - - - {previewUrl ? ( - - - - {selectedPath?.split("/").pop() || "本地图片"} - - - - 预览 - - - - - 重新选择 - - - {saving ? ( - <> - - 保存中... - - ) : ( - <> - - 保存到图片库 - - )} - - - - ) : ( - - - - - 选择本地图片 - - 支持 JPG、PNG、WebP、GIF、BMP - 格式,点击后直接选文件并进入预览。 - - 选择文件 - - )} - - - - - ); -} - -export default LocalImageTab; diff --git a/src/components/image-gen/tabs/MyGalleryTab.tsx b/src/components/image-gen/tabs/MyGalleryTab.tsx deleted file mode 100644 index c53e85c2b..000000000 --- a/src/components/image-gen/tabs/MyGalleryTab.tsx +++ /dev/null @@ -1,496 +0,0 @@ -/** - * @file 我的图片库 Tab - * @description 显示用户已保存的图片素材库 - * @module components/image-gen/tabs/MyGalleryTab - */ - -import { convertLocalFileSrc } from "@/lib/api/fileSystem"; -import type { GalleryMaterial } from "@/types/gallery-material"; -import { toast } from "sonner"; -import { Images } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { getActiveContentTarget } from "@/lib/activeContentTarget"; -import { - emitCanvasImageInsertRequest, - onCanvasImageInsertAck, - type CanvasImageInsertAck, - type CanvasImageTargetType, -} from "@/lib/canvasImageInsertBus"; -import { - addCanvasImageInsertHistory, - getCanvasImageInsertHistory, - type CanvasImageInsertHistoryEntry, -} from "@/lib/canvasImageInsertHistory"; -import { ImageGallery } from "@/lib/workspace/workbenchUi"; -import type { Page, PageParams } from "@/types/page"; - -export interface MyGalleryTabProps { - /** 项目 ID */ - projectId?: string | null; - /** 页面跳转 */ - onNavigate?: (page: Page, params?: PageParams) => void; -} - -const Container = styled.div` - height: 100%; - display: flex; - flex-direction: column; - gap: 10px; - padding: 10px; - background: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(0 0% 100%) 100%); -`; - -const ActionBar = styled.div` - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 12px; - padding: 14px 16px; - border: 1px solid hsl(var(--border) / 0.78); - border-radius: 24px; - background: hsl(var(--background) / 0.84); - box-shadow: - 0 16px 38px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.72); -`; - -const ActionRow = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; -`; - -const ActionCopy = styled.div` - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -`; - -const ActionEyebrow = styled.span` - display: inline-flex; - align-items: center; - width: fit-content; - border-radius: 999px; - border: 1px solid hsl(203 82% 88%); - background: hsl(200 100% 97%); - padding: 4px 8px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.12em; - color: hsl(211 58% 38%); -`; - -const ActionTitle = styled.div` - font-size: 22px; - line-height: 1.1; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const ActionHint = styled.div` - font-size: 13px; - line-height: 1.6; - color: hsl(var(--muted-foreground)); -`; - -const ActionButtons = styled.div` - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -`; - -const InsertButton = styled.button` - height: 40px; - border: 1px solid hsl(215 28% 17% / 0.92); - background: linear-gradient(180deg, hsl(221 39% 16%), hsl(216 34% 12%)); - color: hsl(var(--background)); - border-radius: 14px; - font-size: 13px; - font-weight: 700; - padding: 0 16px; - cursor: pointer; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - transform: translateY(-1px); - box-shadow: 0 16px 32px hsl(220 40% 12% / 0.14); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -const RelocateButton = styled(InsertButton)` - border: 1px solid hsl(var(--border)); - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-size: 12px; - font-weight: 600; - padding: 0 14px; - box-shadow: none; -`; - -const RecentList = styled.div` - display: flex; - flex-wrap: wrap; - gap: 8px; -`; - -const RecentCard = styled.div` - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - padding: 8px 10px; - border-radius: 16px; - border: 1px solid hsl(var(--border) / 0.82); - background: hsl(var(--background) / 0.84); -`; - -const RecentMeta = styled.div` - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -`; - -const RecentTitle = styled.div` - font-size: 12px; - font-weight: 600; - color: hsl(var(--foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const RecentHint = styled.div` - font-size: 11px; - color: hsl(var(--muted-foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const GalleryPanel = styled.div` - flex: 1; - min-height: 0; - padding: 14px; - border-radius: 28px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--background) / 0.84); - box-shadow: - 0 18px 42px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.72); - overflow: hidden; -`; - -const GalleryContent = styled(ImageGallery)` - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; - - > div:last-child { - flex: 1; - min-height: 0; - } -`; - -const EmptyState = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - gap: 16px; - color: hsl(var(--muted-foreground)); - text-align: center; - padding: 48px 24px; - border-radius: 28px; - border: 1px solid hsl(var(--border) / 0.78); - background: hsl(var(--background) / 0.84); - box-shadow: - 0 18px 42px hsl(215 32% 12% / 0.05), - inset 0 1px 0 hsl(0 0% 100% / 0.72); -`; - -const EmptyIcon = styled.div` - width: 72px; - height: 72px; - border-radius: 20px; - background: linear-gradient( - 135deg, - hsl(203 100% 97%), - hsl(201 52% 94% / 0.86) - ); - display: flex; - align-items: center; - justify-content: center; - color: hsl(211 58% 38%); -`; - -const EmptyTitle = styled.p` - margin: 0; - font-size: 18px; - font-weight: 700; - color: hsl(var(--foreground)); -`; - -const EmptyHint = styled.p` - margin: 0; - font-size: 13px; - line-height: 1.6; -`; - -function normalizeCanvasType( - value: string | null | undefined, -): CanvasImageTargetType { - if (value === "document" || value === "video") { - return value; - } - if (value === "script") { - return "video"; - } - return "document"; -} - -function mapCanvasTypeToTheme(canvasType: CanvasImageTargetType): string { - switch (canvasType) { - case "video": - return "video"; - case "document": - case "auto": - default: - return "document"; - } -} - -function getVisibleInsertHistory( - projectId?: string | null, -): CanvasImageInsertHistoryEntry[] { - const history = getCanvasImageInsertHistory(); - const filtered = projectId - ? history.filter((entry) => entry.projectId === projectId) - : history; - return filtered.slice(0, 3); -} - -export function MyGalleryTab({ projectId, onNavigate }: MyGalleryTabProps) { - const [selectedMaterial, setSelectedMaterial] = - useState(null); - const [recentInsertHistory, setRecentInsertHistory] = useState< - CanvasImageInsertHistoryEntry[] - >(() => getVisibleInsertHistory(projectId)); - const pendingInsertRequestMetaRef = useRef< - Map< - string, - { - projectId: string; - contentId: string | null; - canvasType: CanvasImageTargetType; - theme: string; - imageTitle?: string; - } - > - >(new Map()); - - useEffect(() => { - setRecentInsertHistory(getVisibleInsertHistory(projectId)); - }, [projectId]); - - useEffect(() => { - const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => { - const pendingMeta = pendingInsertRequestMetaRef.current.get( - ack.requestId, - ); - if (!pendingMeta) { - return; - } - pendingInsertRequestMetaRef.current.delete(ack.requestId); - - if (!ack.success) { - toast.error("插图失败,请返回创作区重试"); - return; - } - - const nextHistory = addCanvasImageInsertHistory({ - requestId: ack.requestId, - projectId: pendingMeta.projectId, - contentId: pendingMeta.contentId, - canvasType: pendingMeta.canvasType, - theme: pendingMeta.theme, - imageTitle: pendingMeta.imageTitle, - locationLabel: ack.locationLabel, - }); - setRecentInsertHistory( - (projectId - ? nextHistory.filter((entry) => entry.projectId === projectId) - : nextHistory - ).slice(0, 3), - ); - }); - - return unsubscribe; - }, [projectId]); - - const handleInsertFromGallery = (material: GalleryMaterial) => { - if (!projectId) { - toast.error("请先选择项目"); - return; - } - - const imageUrl = material.filePath - ? convertLocalFileSrc(material.filePath) - : material.metadata?.thumbnail || ""; - if (!imageUrl) { - toast.error("该素材缺少可用图片地址,无法插入"); - return; - } - - const target = getActiveContentTarget(); - const sameProjectTarget = target?.projectId === projectId ? target : null; - const targetContentId = sameProjectTarget?.contentId ?? null; - const targetCanvasType = normalizeCanvasType(sameProjectTarget?.canvasType); - const targetTheme = mapCanvasTypeToTheme(targetCanvasType); - - const request = emitCanvasImageInsertRequest({ - projectId, - contentId: targetContentId, - canvasType: targetCanvasType, - anchorHint: - targetCanvasType === "video" ? "video_start_frame" : "section_end", - source: "gallery", - image: { - id: material.id, - previewUrl: material.metadata?.thumbnail || imageUrl, - contentUrl: imageUrl, - title: material.name, - width: material.metadata?.width, - height: material.metadata?.height, - attributionName: "项目素材库", - provider: "gallery", - }, - }); - pendingInsertRequestMetaRef.current.set(request.requestId, { - projectId, - contentId: targetContentId, - canvasType: targetCanvasType, - theme: targetTheme, - imageTitle: material.name, - }); - - onNavigate?.("agent", { - projectId, - contentId: targetContentId ?? undefined, - theme: targetTheme, - lockTheme: false, - }); - toast.success("已发送到当前画布,正在自动定位"); - }; - - const handleRelocate = (entry: CanvasImageInsertHistoryEntry) => { - onNavigate?.("agent", { - projectId: entry.projectId, - contentId: entry.contentId ?? undefined, - theme: entry.theme, - lockTheme: false, - }); - toast.success("正在定位到插图位置"); - }; - - if (!projectId) { - return ( - - - - - - 请先选择项目 - 在右上角选择一个项目后即可查看图片库 - - - ); - } - - return ( - - - - - GALLERY - 我的图片库 - - {selectedMaterial - ? `已选中:${selectedMaterial.name}` - : "双击图片可直接插入当前画布,或先单击选中后再执行插入。"} - - - - {recentInsertHistory[0] && ( - handleRelocate(recentInsertHistory[0])} - > - 再次定位 - - )} - { - if (!selectedMaterial) { - return; - } - handleInsertFromGallery(selectedMaterial); - }} - > - 插入选中图片到当前画布 - - - - - {recentInsertHistory.length > 0 && ( - - {recentInsertHistory.map((entry) => ( - - - - {entry.imageTitle?.trim() || "图片"} - - {entry.locationLabel || "已插入"} - - handleRelocate(entry)} - > - 定位 - - - ))} - - )} - - - { - setSelectedMaterial(materials[0] || null); - }} - onDoubleClick={handleInsertFromGallery} - /> - - - ); -} - -export default MyGalleryTab; diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx index 517033cef..88f6f1678 100644 --- a/src/components/input-kit/ModelSelector.test.tsx +++ b/src/components/input-kit/ModelSelector.test.tsx @@ -311,7 +311,7 @@ describe("ModelSelector", () => { expect(pageText).toContain("无多模态"); }); - it("anthropic-compatible Provider 应在选择器中展示显式缓存提示", () => { + it("未知 anthropic-compatible Provider 应在选择器中展示显式缓存提示", () => { mockUseConfiguredProviders.mockReturnValue({ providers: [ { @@ -321,7 +321,7 @@ describe("ModelSelector", () => { fallbackRegistryId: "anthropic", type: "anthropic-compatible", providerId: "custom-anthropic-compatible", - apiHost: "https://open.bigmodel.cn/api/anthropic", + apiHost: "https://api.example.com/anthropic", }, ], loading: false, @@ -367,6 +367,138 @@ describe("ModelSelector", () => { expect(pageText).toContain("cache_control"); }); + it.each([ + { + label: "GLM Anthropic", + apiHost: "https://open.bigmodel.cn/api/anthropic", + model: "glm-5.1", + }, + { + label: "Kimi Anthropic", + apiHost: "https://api.moonshot.cn/anthropic", + model: "kimi-k2.5", + }, + { + label: "MiniMax Anthropic", + apiHost: "https://api.minimaxi.com/anthropic", + model: "minimax-m1", + }, + { + label: "MiMo Anthropic", + apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", + model: "mimo-v2-flash", + }, + ])("$label 不应在选择器中误报显式缓存提示", ({ label, apiHost, model }) => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-anthropic-compatible", + label, + registryId: "custom-anthropic-compatible", + fallbackRegistryId: "anthropic", + type: "anthropic-compatible", + providerId: "custom-anthropic-compatible", + apiHost, + }, + ], + loading: false, + }); + mockUseProviderModels.mockReturnValue({ + modelIds: [model], + models: [ + { + id: model, + capabilities: { + vision: true, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: true, + }, + }, + ], + loading: false, + error: null, + }); + + const { container } = renderModelSelector({ + providerType: "custom-anthropic-compatible", + model, + }); + + const trigger = container.querySelector( + 'button[role="combobox"]', + ) as HTMLButtonElement | null; + if (!trigger) { + throw new Error("未找到模型选择触发器"); + } + + act(() => { + trigger.click(); + }); + + const pageText = document.body.textContent || ""; + expect(pageText).not.toContain("显式缓存"); + expect(pageText).not.toContain("未声明自动 Prompt Cache"); + }); + + it("显式声明 automatic 的 anthropic-compatible Provider 不应在选择器中误报显式缓存提示", () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-anthropic-compatible", + label: "GLM Anthropic Automatic", + registryId: "custom-anthropic-compatible", + fallbackRegistryId: "anthropic", + type: "anthropic-compatible", + providerId: "custom-anthropic-compatible", + apiHost: "https://open.bigmodel.cn/api/anthropic", + promptCacheMode: "automatic", + }, + ], + loading: false, + }); + mockUseProviderModels.mockReturnValue({ + modelIds: ["glm-5.1"], + models: [ + { + id: "glm-5.1", + capabilities: { + vision: true, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: true, + }, + }, + ], + loading: false, + error: null, + }); + + const { container } = renderModelSelector({ + providerType: "custom-anthropic-compatible", + model: "glm-5.1", + }); + + const trigger = container.querySelector( + 'button[role="combobox"]', + ) as HTMLButtonElement | null; + if (!trigger) { + throw new Error("未找到模型选择触发器"); + } + + act(() => { + trigger.click(); + }); + + const pageText = document.body.textContent || ""; + expect(pageText).not.toContain("显式缓存"); + expect(pageText).not.toContain("未声明自动 Prompt Cache"); + }); + it("无 Provider 引导关闭后应隐藏,并在重新挂载时保持关闭状态", () => { mockUseConfiguredProviders.mockReturnValue({ providers: [], diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index c006e56a6..cb62785f9 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -207,8 +207,15 @@ export const ModelSelector: React.FC = ({ resolvePromptCacheSupportNotice({ providerType, configuredProviderType: selectedProvider?.type, + configuredApiHost: selectedProvider?.apiHost, + configuredPromptCacheMode: selectedProvider?.promptCacheMode, }), - [providerType, selectedProvider?.type], + [ + providerType, + selectedProvider?.apiHost, + selectedProvider?.promptCacheMode, + selectedProvider?.type, + ], ); const incompatibleModelCount = useMemo( @@ -485,6 +492,8 @@ export const ModelSelector: React.FC = ({ const isSelected = selectedProvider?.key === provider.key; const providerPromptCacheMode = getProviderPromptCacheMode( provider.type, + provider.promptCacheMode, + provider.apiHost, ); return ( diff --git a/src/components/provider-pool/README.md b/src/components/provider-pool/README.md index eb8898eba..c8695f777 100644 --- a/src/components/provider-pool/README.md +++ b/src/components/provider-pool/README.md @@ -60,3 +60,20 @@ ProviderPoolPage 支持四种分类: 2. **API Key** - 使用左右分栏布局(ApiKeyProviderSection) 3. **Connect** - 中转商列表,支持浏览和一键获取 API Key 4. **语音服务** - 语音 Provider 管理入口 + +## Prompt Cache 认知边界 + +Provider Pool 页面当前已把 Prompt Cache 能力前置到 Provider UI,而不是等到对话发出后才暴露: + +- `anthropic`:按官方 Anthropic 能力链展示 +- `anthropic-compatible`:先按已知官方 Anthropic 兼容端点识别自动缓存,未知端点才回退为“仅显式缓存” +- 其它 Provider:默认不展示 Prompt Cache 标签或 notice + +当前页面上的主要提示落点: + +- 左侧 Provider 列表:`显式缓存` badge +- 右侧 Provider 详情头部:`显式缓存` badge +- 新增自定义 Provider:amber Prompt Cache 提示 +- 编辑 Provider 配置:amber Prompt Cache 提示 + +这条语义的当前事实源位于 `src/lib/model/providerPromptCacheSupport.ts`。如果以后要新增新的缓存能力提示,优先扩这份 helper,不要在单个组件里自行判断。 diff --git a/src/components/provider-pool/api-key/AddCustomProviderModal.test.ts b/src/components/provider-pool/api-key/AddCustomProviderModal.test.ts index 157740abd..bca399fe5 100644 --- a/src/components/provider-pool/api-key/AddCustomProviderModal.test.ts +++ b/src/components/provider-pool/api-key/AddCustomProviderModal.test.ts @@ -17,7 +17,10 @@ import { hasRequiredFields, } from "./AddCustomProviderModal"; import { PROVIDER_TYPE_VALUES } from "./ProviderConfigForm.utils"; -import type { ProviderType } from "@/lib/types/provider"; +import type { + ProviderDeclaredPromptCacheMode, + ProviderType, +} from "@/lib/types/provider"; // ============================================================================ // 测试数据生成器 @@ -32,6 +35,8 @@ const VALID_PROVIDER_TYPES: ProviderType[] = PROVIDER_TYPE_VALUES; const providerTypeArbitrary: fc.Arbitrary = fc.constantFrom( ...VALID_PROVIDER_TYPES, ); +const promptCacheModeArbitrary: fc.Arbitrary = + fc.constantFrom("explicit_only", "automatic"); /** * 生成有效的 URL @@ -85,6 +90,7 @@ const invalidUrlArbitrary: fc.Arbitrary = fc.oneof( const validFormStateArbitrary = fc.record({ name: nonEmptyStringArbitrary, type: providerTypeArbitrary, + promptCacheMode: promptCacheModeArbitrary, apiHost: validUrlArbitrary, apiKey: nonEmptyStringArbitrary, apiVersion: fc.string({ maxLength: 30 }), @@ -99,6 +105,7 @@ const validFormStateArbitrary = fc.record({ const formStateMissingNameArbitrary = fc.record({ name: whitespaceStringArbitrary, type: providerTypeArbitrary, + promptCacheMode: promptCacheModeArbitrary, apiHost: validUrlArbitrary, apiKey: nonEmptyStringArbitrary, apiVersion: fc.string({ maxLength: 30 }), @@ -113,6 +120,7 @@ const formStateMissingNameArbitrary = fc.record({ const formStateMissingApiHostArbitrary = fc.record({ name: nonEmptyStringArbitrary, type: providerTypeArbitrary, + promptCacheMode: promptCacheModeArbitrary, apiHost: whitespaceStringArbitrary, apiKey: nonEmptyStringArbitrary, apiVersion: fc.string({ maxLength: 30 }), @@ -127,6 +135,7 @@ const formStateMissingApiHostArbitrary = fc.record({ const formStateMissingApiKeyArbitrary = fc.record({ name: nonEmptyStringArbitrary, type: providerTypeArbitrary, + promptCacheMode: promptCacheModeArbitrary, apiHost: validUrlArbitrary, apiKey: whitespaceStringArbitrary, apiVersion: fc.string({ maxLength: 30 }), @@ -141,6 +150,7 @@ const formStateMissingApiKeyArbitrary = fc.record({ const formStateInvalidApiHostArbitrary = fc.record({ name: nonEmptyStringArbitrary, type: providerTypeArbitrary, + promptCacheMode: promptCacheModeArbitrary, apiHost: invalidUrlArbitrary, apiKey: nonEmptyStringArbitrary, apiVersion: fc.string({ maxLength: 30 }), @@ -297,6 +307,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => { const formState = { name: "a".repeat(51), type: "openai" as ProviderType, + promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode, apiHost: "https://api.example.com", apiKey: "sk-test-key", apiVersion: "", @@ -314,6 +325,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => { const formState = { name: "a".repeat(50), type: "openai" as ProviderType, + promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode, apiHost: "https://api.example.com", apiKey: "sk-test-key", apiVersion: "", @@ -332,6 +344,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => { const formState = { name: "", type: "openai" as ProviderType, + promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode, apiHost: "", apiKey: "", apiVersion: "", @@ -351,6 +364,7 @@ describe("Property 8: 自定义 Provider 表单验证", () => { const formState = { name: "", type: "openai" as ProviderType, + promptCacheMode: "explicit_only" as ProviderDeclaredPromptCacheMode, apiHost: "", apiKey: "", apiVersion: "", diff --git a/src/components/provider-pool/api-key/AddCustomProviderModal.tsx b/src/components/provider-pool/api-key/AddCustomProviderModal.tsx index 85fd17723..c2be615da 100644 --- a/src/components/provider-pool/api-key/AddCustomProviderModal.tsx +++ b/src/components/provider-pool/api-key/AddCustomProviderModal.tsx @@ -30,7 +30,10 @@ import { X, } from "lucide-react"; import { useModelRegistry } from "@/hooks/useModelRegistry"; -import type { ProviderType } from "@/lib/types/provider"; +import type { + ProviderDeclaredPromptCacheMode, + ProviderType, +} from "@/lib/types/provider"; import { resolvePromptCacheSupportNotice } from "@/lib/model/providerPromptCacheSupport"; import { apiKeyProviderApi, @@ -41,8 +44,11 @@ import { getProviderTypeLabel, getSpecialProtocolHint, isSupportedProviderType, + isPromptCacheModeConfigurableProviderType, + PROMPT_CACHE_MODE_OPTIONS, PROVIDER_TYPE_FIELDS, PROVIDER_TYPE_OPTIONS, + resolvePromptCacheModeRequestValue, } from "./ProviderConfigForm.utils"; // ============================================================================ @@ -288,6 +294,7 @@ export interface AddCustomProviderModalProps { interface FormState { name: string; type: ProviderType; + promptCacheMode: ProviderDeclaredPromptCacheMode; apiHost: string; apiKey: string; apiVersion: string; @@ -311,6 +318,7 @@ interface FormErrors { const INITIAL_FORM_STATE: FormState = { name: "", type: "openai", + promptCacheMode: "explicit_only", apiHost: "", apiKey: "", apiVersion: "", @@ -584,6 +592,11 @@ export const AddCustomProviderModal: React.FC = ({ name: formState.name.trim(), type: formState.type, api_host: formState.apiHost.trim(), + prompt_cache_mode: resolvePromptCacheModeRequestValue( + formState.type, + formState.promptCacheMode, + formState.apiHost, + ), }; // 添加额外字段 @@ -636,12 +649,19 @@ export const AddCustomProviderModal: React.FC = ({ () => getSpecialProtocolHint(formState.type), [formState.type], ); + const showPromptCacheModeField = useMemo( + () => + isPromptCacheModeConfigurableProviderType(formState.type, formState.apiHost), + [formState.apiHost, formState.type], + ); const promptCacheSupportNotice = useMemo( () => resolvePromptCacheSupportNotice({ configuredProviderType: formState.type, + configuredApiHost: formState.apiHost, + configuredPromptCacheMode: formState.promptCacheMode, }), - [formState.type], + [formState.apiHost, formState.promptCacheMode, formState.type], ); const visibleProviders = useMemo( @@ -1077,6 +1097,63 @@ export const AddCustomProviderModal: React.FC = ({
+ {showPromptCacheModeField ? ( +
+
+ + +
+ +
+

+ 选择建议 +

+

+ { + PROMPT_CACHE_MODE_OPTIONS.find( + (option) => + option.value === formState.promptCacheMode, + )?.description + } +

+
+
+ ) : null} + {specialProtocolHint ? (
> = {}, +) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + const mergedProps: ComponentProps = { + isOpen: true, + onClose: vi.fn(), + onAdd: vi.fn().mockResolvedValue({ id: "provider-001" }), + onAddApiKey: vi.fn().mockResolvedValue(undefined), + ...props, + }; + act(() => { - root.render( - , - ); + root.render(); }); mountedRoots.push({ container, root }); + return { container, props: mergedProps }; } async function settleModal() { @@ -75,6 +83,16 @@ function findDivByText(text: string): HTMLDivElement { return target; } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + beforeEach(() => { ( globalThis as typeof globalThis & { @@ -237,7 +255,7 @@ describe("AddCustomProviderModal", () => { }); it("切换到 anthropic-compatible 时应展示显式 Prompt Cache 提示", async () => { - renderModal(); + renderModalWithProps(); await settleModal(); @@ -256,5 +274,79 @@ describe("AddCustomProviderModal", () => { ); expect(notice.textContent ?? "").toContain("未声明支持自动 Prompt Cache"); expect(notice.textContent ?? "").toContain("显式 cache_control"); + expect(document.body.textContent ?? "").toContain( + "已知官方 Anthropic 兼容端点", + ); + expect( + document.querySelector('[data-testid="prompt-cache-mode-select"]'), + ).not.toBeNull(); + }); + + it("anthropic-compatible 切换到 automatic 后应隐藏 Prompt Cache 提示", async () => { + renderModal(); + + await settleModal(); + + await act(async () => { + findByTestId("provider-type-select").click(); + }); + + await act(async () => { + findDivByText("Anthropic 兼容").click(); + }); + + await act(async () => { + findByTestId("prompt-cache-mode-select").click(); + }); + + await act(async () => { + findDivByText("已声明自动缓存").click(); + }); + + expect( + document.querySelector('[data-testid="provider-prompt-cache-notice"]'), + ).toBeNull(); + }); + + it("已知官方 Anthropic 兼容 Host 提交时应自动带上 automatic prompt_cache_mode", async () => { + const onAdd = vi.fn().mockResolvedValue({ id: "provider-automatic" }); + renderModalWithProps({ onAdd }); + + await settleModal(); + + await act(async () => { + findByTestId("provider-type-select").click(); + }); + + await act(async () => { + findDivByText("Anthropic 兼容").click(); + }); + + await act(async () => { + setInputValue(findByTestId("provider-name-input"), "GLM"); + setInputValue( + findByTestId("api-host-input"), + "https://open.bigmodel.cn/api/anthropic", + ); + setInputValue(findByTestId("api-key-input"), "test-key"); + }); + + expect( + document.querySelector('[data-testid="prompt-cache-mode-select"]'), + ).toBeNull(); + + await act(async () => { + findByTestId("submit-button").click(); + await Promise.resolve(); + }); + + expect(onAdd).toHaveBeenCalledWith( + expect.objectContaining({ + name: "GLM", + type: "anthropic-compatible", + api_host: "https://open.bigmodel.cn/api/anthropic", + prompt_cache_mode: "automatic", + }), + ); }); }); diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.test.ts b/src/components/provider-pool/api-key/ProviderConfigForm.test.ts index 013f0e2f0..482d76849 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.test.ts +++ b/src/components/provider-pool/api-key/ProviderConfigForm.test.ts @@ -14,6 +14,7 @@ import * as fc from "fast-check"; import { getLatestSelectableModel, getFieldsForProviderType, + getSpecialProtocolHint, parseCustomModelsValue, providerTypeRequiresField, PROVIDER_TYPE_FIELDS, @@ -224,7 +225,7 @@ describe("Property 7: Provider 类型处理正确性", () => { expect(fields).toEqual(["apiHost"]); }); - test("new-api 类型只需要 apiHost", () => { + test("new-api 类型只需要 apiHost", () => { const fields = getFieldsForProviderType("new-api"); expect(fields).toEqual(["apiHost"]); }); @@ -233,6 +234,12 @@ describe("Property 7: Provider 类型处理正确性", () => { const fields = getFieldsForProviderType("gateway"); expect(fields).toEqual(["apiHost"]); }); + + test("anthropic-compatible 协议提示不应再暗示自动 Prompt Cache", () => { + expect(getSpecialProtocolHint("anthropic-compatible")).toContain( + "已知官方 Anthropic 兼容端点", + ); + }); }); // 边界情况测试 diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.tsx b/src/components/provider-pool/api-key/ProviderConfigForm.tsx index d26b9a008..140bab588 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.tsx +++ b/src/components/provider-pool/api-key/ProviderConfigForm.tsx @@ -31,7 +31,10 @@ import type { ProviderWithKeysDisplay, UpdateProviderRequest, } from "@/lib/api/apiKeyProvider"; -import type { ProviderType } from "@/lib/types/provider"; +import type { + ProviderDeclaredPromptCacheMode, + ProviderType, +} from "@/lib/types/provider"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders"; import { useProviderModels } from "@/hooks/useProviderModels"; @@ -43,9 +46,13 @@ import { getSpecialProtocolHint, getLatestSelectableModel, getProviderTypeLabel, + isPromptCacheModeConfigurableProviderType, parseCustomModelsValue, + PROMPT_CACHE_MODE_OPTIONS, PROVIDER_TYPE_FIELDS, PROVIDER_TYPE_OPTIONS, + resolvePromptCacheModeFormValue, + resolvePromptCacheModeRequestValue, serializeCustomModels, } from "./ProviderConfigForm.utils"; import { Plus, Save, Star, X } from "lucide-react"; @@ -105,6 +112,7 @@ export interface ProviderConfigFormRef { interface FormState { providerName: string; providerType: ProviderType; + promptCacheMode: ProviderDeclaredPromptCacheMode; apiHost: string; apiVersion: string; project: string; @@ -160,6 +168,11 @@ export const ProviderConfigForm = forwardRef< const [formState, setFormState] = useState({ providerName: provider.name || "", providerType: (provider.type as ProviderType) || "openai", + promptCacheMode: resolvePromptCacheModeFormValue( + provider.prompt_cache_mode, + provider.type as ProviderType, + provider.api_host, + ), apiHost: provider.api_host || "", apiVersion: provider.api_version || "", project: provider.project || "", @@ -197,10 +210,12 @@ export const ProviderConfigForm = forwardRef< type: formState.providerType, providerId: provider.id, apiHost: formState.apiHost, + promptCacheMode: formState.promptCacheMode, customModels: selectedModels, }), [ formState.apiHost, + formState.promptCacheMode, formState.providerType, provider.id, provider.name, @@ -317,6 +332,11 @@ export const ProviderConfigForm = forwardRef< setFormState({ providerName: provider.name || "", providerType: (provider.type as ProviderType) || "openai", + promptCacheMode: resolvePromptCacheModeFormValue( + provider.prompt_cache_mode, + provider.type as ProviderType, + provider.api_host, + ), apiHost: provider.api_host || "", apiVersion: provider.api_version || "", project: provider.project || "", @@ -330,6 +350,7 @@ export const ProviderConfigForm = forwardRef< provider.id, provider.name, provider.type, + provider.prompt_cache_mode, provider.api_host, provider.api_version, provider.project, @@ -360,6 +381,11 @@ export const ProviderConfigForm = forwardRef< project: state.project, location: state.location, region: state.region, + prompt_cache_mode: resolvePromptCacheModeRequestValue( + state.providerType, + state.promptCacheMode, + state.apiHost, + ), custom_models: customModels, }; @@ -511,13 +537,19 @@ export const ProviderConfigForm = forwardRef< }, [onRecommendedLatestModelChange, recommendedLatestModel]); const extraFields = PROVIDER_TYPE_FIELDS[formState.providerType] || []; + const showPromptCacheModeField = isPromptCacheModeConfigurableProviderType( + formState.providerType, + formState.apiHost, + ); const specialProtocolHint = getSpecialProtocolHint(formState.providerType); const promptCacheSupportNotice = useMemo( () => resolvePromptCacheSupportNotice({ configuredProviderType: formState.providerType, + configuredApiHost: formState.apiHost, + configuredPromptCacheMode: formState.promptCacheMode, }), - [formState.providerType], + [formState.apiHost, formState.promptCacheMode, formState.providerType], ); const defaultModelId = visibleSelectedModels[0] ?? recommendedLatestModel?.id ?? null; @@ -718,6 +750,53 @@ export const ProviderConfigForm = forwardRef<
); })} + + {showPromptCacheModeField ? ( +
+ + +

+ { + PROMPT_CACHE_MODE_OPTIONS.find( + (option) => option.value === formState.promptCacheMode, + )?.description + } +

+
+ ) : null}
{promptCacheSupportNotice ? ( diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx b/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx index 128dbc530..12e492c78 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx +++ b/src/components/provider-pool/api-key/ProviderConfigForm.ui.test.tsx @@ -34,6 +34,7 @@ function createProvider( sort_order: 1, api_key_count: 1, custom_models: ["gpt-4.1"], + prompt_cache_mode: null, created_at: new Date("2026-03-15T00:00:00.000Z").toISOString(), updated_at: new Date("2026-03-15T00:00:00.000Z").toISOString(), api_keys: [], @@ -185,4 +186,91 @@ describe("ProviderConfigForm", () => { expect(notice?.textContent ?? "").toContain("未声明支持自动 Prompt Cache"); expect(notice?.textContent ?? "").toContain("显式 cache_control"); }); + + it("显式声明 automatic 的 anthropic-compatible Provider 不应展示 Prompt Cache 提示", () => { + const provider = createProvider({ + id: "custom-anthropic-compatible-automatic", + name: "Anthropic 兼容自动缓存渠道", + is_system: false, + type: "anthropic-compatible", + prompt_cache_mode: "automatic", + api_host: "https://example.com/anthropic", + }); + const { container } = renderForm(provider); + + expect( + container.querySelector('[data-testid="provider-prompt-cache-notice"]'), + ).toBeNull(); + }); + + it("anthropic-compatible Provider 切换到 automatic 后应带上 prompt_cache_mode 保存", async () => { + const provider = createProvider({ + id: "custom-anthropic-compatible", + name: "Anthropic 兼容渠道", + is_system: false, + type: "anthropic-compatible", + api_host: "https://example.com/anthropic", + }); + const { container, onUpdate } = renderForm(provider); + + const trigger = container.querySelector( + '[data-testid="prompt-cache-mode-select"]', + ); + + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.click(); + }); + + await act(async () => { + findDivByText("已声明自动缓存").click(); + }); + + await act(async () => { + vi.advanceTimersByTime(600); + await Promise.resolve(); + }); + + expect(onUpdate).toHaveBeenCalledWith( + "custom-anthropic-compatible", + expect.objectContaining({ + type: "anthropic-compatible", + prompt_cache_mode: "automatic", + }), + ); + expect( + container.querySelector('[data-testid="provider-prompt-cache-notice"]'), + ).toBeNull(); + }); + + it("anthropic-compatible Provider 的协议说明弹层应明确未默认声明自动 Prompt Cache", async () => { + const provider = createProvider({ + id: "custom-anthropic-compatible", + name: "Anthropic 兼容渠道", + is_system: false, + type: "anthropic-compatible", + api_host: "https://example.com/anthropic", + }); + const { container } = renderForm(provider); + + const infoButton = container.querySelector( + '[data-testid="provider-config-info-button"]', + ); + + expect(infoButton).not.toBeNull(); + + await act(async () => { + infoButton?.click(); + }); + + const specialHint = document.querySelector( + '[data-testid="protocol-special-hint"]', + ); + + expect(specialHint).not.toBeNull(); + expect(specialHint?.textContent ?? "").toContain( + "已知官方 Anthropic 兼容端点", + ); + }); }); diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts b/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts index 5400b76fa..8d87b6940 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts +++ b/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts @@ -4,7 +4,11 @@ * @module components/provider-pool/api-key/ProviderConfigForm.utils */ -import type { ProviderType } from "@/lib/types/provider"; +import type { + ProviderDeclaredPromptCacheMode, + ProviderType, +} from "@/lib/types/provider"; +import { getProviderPromptCacheMode } from "@/lib/model/providerPromptCacheSupport"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; /** 支持的 Provider 类型列表 */ @@ -52,11 +56,30 @@ const SPECIAL_PROVIDER_PROTOCOL_HINTS: Partial> = { anthropic: "Anthropic 继续使用原生协议,不会被收敛到普通 OpenAI 兼容请求格式。", "anthropic-compatible": - "Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,仍按 Anthropic 语义处理请求与模型映射。", + "Anthropic 兼容用于接入实现 Anthropic wire format 的第三方服务,会沿用 Anthropic 请求结构与模型映射。Lime 会自动识别已知官方 Anthropic 兼容端点(如 GLM / Kimi / MiniMax / MiMo);未知端点默认回退为仅显式缓存。", gemini: "Gemini 保留原生协议能力与专属模型映射,不按普通 OpenAI 兼容 Provider 处理。", }; +export const PROMPT_CACHE_MODE_OPTIONS: Array<{ + value: ProviderDeclaredPromptCacheMode; + label: string; + description: string; +}> = [ + { + value: "explicit_only", + label: "仅显式缓存", + description: + "默认选项。只有显式写入 cache_control 时才请求上游复用前缀。", + }, + { + value: "automatic", + label: "已声明自动缓存", + description: + "仅在上游明确声明兼容 Anthropic Automatic Prompt Cache 时使用。", + }, +]; + export function isSupportedProviderType( providerType: string, ): providerType is ProviderType { @@ -78,6 +101,39 @@ export function getSpecialProtocolHint(type: ProviderType): string | null { return SPECIAL_PROVIDER_PROTOCOL_HINTS[type] ?? null; } +export function isPromptCacheModeConfigurableProviderType( + type: ProviderType, + apiHost?: string | null, +): boolean { + return ( + type === "anthropic-compatible" && + getProviderPromptCacheMode(type, null, apiHost) === "explicit_only" + ); +} + +export function resolvePromptCacheModeFormValue( + promptCacheMode?: ProviderDeclaredPromptCacheMode | null, + providerType?: ProviderType, + apiHost?: string | null, +): ProviderDeclaredPromptCacheMode { + return getProviderPromptCacheMode(providerType, promptCacheMode, apiHost) === + "automatic" + ? "automatic" + : "explicit_only"; +} + +export function resolvePromptCacheModeRequestValue( + type: ProviderType, + promptCacheMode: ProviderDeclaredPromptCacheMode, + apiHost?: string | null, +): ProviderDeclaredPromptCacheMode | null { + if (type !== "anthropic-compatible") { + return null; + } + + return resolvePromptCacheModeFormValue(promptCacheMode, type, apiHost); +} + export function dedupeModelIds(modelIds: string[]): string[] { const seen = new Set(); const result: string[] = []; diff --git a/src/components/provider-pool/api-key/ProviderListItem.tsx b/src/components/provider-pool/api-key/ProviderListItem.tsx index 304b63644..4912a090e 100644 --- a/src/components/provider-pool/api-key/ProviderListItem.tsx +++ b/src/components/provider-pool/api-key/ProviderListItem.tsx @@ -57,7 +57,11 @@ export const ProviderListItem: React.FC = ({ const apiKeyCount = provider.api_keys?.length ?? 0; const isEnabled = provider.enabled; const showExplicitPromptCacheBadge = - getProviderPromptCacheMode(provider.type) === "explicit_only"; + getProviderPromptCacheMode( + provider.type, + provider.prompt_cache_mode, + provider.api_host, + ) === "explicit_only"; return (
{ expect(badge?.textContent).toContain("显式缓存"); }); + it("显式声明 automatic 的 anthropic-compatible Provider 不应展示显式缓存标签", () => { + const container = renderItem( + createProvider({ + id: "anthropic-proxy-automatic", + name: "Anthropic Proxy Automatic", + type: "anthropic-compatible", + prompt_cache_mode: "automatic", + }), + ); + + expect( + container.querySelector('[data-testid="provider-prompt-cache-badge"]'), + ).toBeNull(); + }); + + it.each([ + { + id: "glm-anthropic", + name: "GLM Anthropic", + apiHost: "https://open.bigmodel.cn/api/anthropic", + }, + { + id: "kimi-anthropic", + name: "Kimi Anthropic", + apiHost: "https://api.moonshot.cn/anthropic", + }, + { + id: "minimax-anthropic", + name: "MiniMax Anthropic", + apiHost: "https://api.minimaxi.com/anthropic", + }, + { + id: "mimo-anthropic", + name: "MiMo Anthropic", + apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", + }, + ])("$name 官方 Host 不应展示显式缓存标签", ({ id, name, apiHost }) => { + const container = renderItem( + createProvider({ + id, + name, + type: "anthropic-compatible", + api_host: apiHost, + }), + ); + + expect( + container.querySelector('[data-testid="provider-prompt-cache-badge"]'), + ).toBeNull(); + }); + it("非 anthropic-compatible Provider 不应展示显式缓存标签", () => { const container = renderItem(createProvider()); diff --git a/src/components/provider-pool/api-key/ProviderSetting.tsx b/src/components/provider-pool/api-key/ProviderSetting.tsx index ed7033b5a..14c46a384 100644 --- a/src/components/provider-pool/api-key/ProviderSetting.tsx +++ b/src/components/provider-pool/api-key/ProviderSetting.tsx @@ -237,7 +237,11 @@ export const ProviderSetting: React.FC = ({ ? "读取真实模型目录前,不展示旧模型,避免把历史缓存误认为当前可用模型。" : null; const showExplicitPromptCacheBadge = - getProviderPromptCacheMode(provider.type) === "explicit_only"; + getProviderPromptCacheMode( + provider.type, + provider.prompt_cache_mode, + provider.api_host, + ) === "explicit_only"; // 处理启用/禁用切换 const handleToggleEnabled = async (enabled: boolean) => { diff --git a/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx b/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx index b692a36fc..2f66656dd 100644 --- a/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx +++ b/src/components/provider-pool/api-key/ProviderSetting.ui.test.tsx @@ -166,4 +166,55 @@ describe("ProviderSetting", () => { expect(badge).not.toBeNull(); expect(badge?.textContent ?? "").toContain("显式缓存"); }); + + it("显式声明 automatic 的 anthropic-compatible Provider 不应在头部展示显式缓存标签", () => { + const container = renderSetting( + createProvider({ + id: "anthropic-proxy-automatic", + name: "Anthropic Proxy Automatic", + type: "anthropic-compatible", + prompt_cache_mode: "automatic", + }), + ); + + expect( + container.querySelector('[data-testid="provider-prompt-cache-badge"]'), + ).toBeNull(); + }); + + it.each([ + { + id: "glm-anthropic", + name: "GLM Anthropic", + apiHost: "https://open.bigmodel.cn/api/anthropic", + }, + { + id: "kimi-anthropic", + name: "Kimi Anthropic", + apiHost: "https://api.moonshot.cn/anthropic", + }, + { + id: "minimax-anthropic", + name: "MiniMax Anthropic", + apiHost: "https://api.minimaxi.com/anthropic", + }, + { + id: "mimo-anthropic", + name: "MiMo Anthropic", + apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", + }, + ])("$name 官方 Host 不应在头部展示显式缓存标签", ({ id, name, apiHost }) => { + const container = renderSetting( + createProvider({ + id, + name, + type: "anthropic-compatible", + api_host: apiHost, + }), + ); + + expect( + container.querySelector('[data-testid="provider-prompt-cache-badge"]'), + ).toBeNull(); + }); }); diff --git a/src/components/provider-pool/api-key/README.md b/src/components/provider-pool/api-key/README.md index df74eb58f..2f73033d1 100644 --- a/src/components/provider-pool/api-key/README.md +++ b/src/components/provider-pool/api-key/README.md @@ -28,11 +28,14 @@ | `ProviderListItem.ui.test.tsx` | 列表项 UI 回归:显式缓存标签 | | `ProviderList.test.ts` | Property 10, 14 & 15 属性测试 | | `ProviderConfigForm.test.ts` | Property 7 属性测试:Provider 类型处理正确性 | +| `ProviderConfigForm.ui.test.tsx` | 编辑入口 UI 回归:Prompt Cache 提示与协议说明弹层 | | `ProviderSetting.test.ts` | Property 6 属性测试:Provider 设置面板字段完整性 | | `ProviderSetting.ui.test.tsx` | 设置面板 UI 回归:头部状态与显式缓存标签 | | `ApiKeyProviderSection.test.ts` | Property 2 属性测试:Provider 选择同步 | | `AddCustomProviderModal.test.ts` | Property 8 属性测试:自定义 Provider 表单验证 | +| `AddCustomProviderModal.ui.test.tsx` | 创建入口 UI 回归:Prompt Cache 提示与协议特例保留 | | `DeleteProviderDialog.test.ts` | Property 9 属性测试:System Provider 删除保护 | +| `providerTypeMapping.test.ts` | 模型注册表映射契约:目录归一不等于 Prompt Cache 能力 | ## 使用示例 @@ -129,14 +132,22 @@ function ApiKeySection() { 当提供 `validRegistryProviders` 时,会优先选择“真实存在于模型注册表”的候选值。 +这层解析只负责模型目录真相源收敛,不负责 Prompt Cache 等运行时能力判断。 +例如 `anthropic-compatible -> anthropic` 仅表示可复用 Anthropic 模型注册表,不能据此推断官方 Anthropic 自动缓存能力。 + ## Prompt Cache 能力提示(当前事实源) -Provider 池当前把 Prompt Cache 能力视为 **Provider 类型能力**,而不是“请求长得像哪家协议”: +Provider 池当前把 Prompt Cache 能力视为 **Provider 显式声明优先,类型默认兜底**: - `anthropic`:自动缓存能力 -- `anthropic-compatible`:仅显式缓存 +- `anthropic-compatible`:先识别已知官方 Anthropic 兼容端点(如 GLM / Kimi / MiniMax / MiMo),其余端点默认仅显式缓存;自定义 Provider 仍可显式声明为 `automatic` - 其它 Provider:默认不展示 Prompt Cache 能力提示 +另外,前台提示层会对已知官方 Anthropic 兼容 Host 做例外收口: + +- 不再把它们误报成“显式缓存”或“未声明自动 Prompt Cache” +- 运行时也会和这份事实源保持一致,不再只修 UI + 当前前台提示统一复用 `src/lib/model/providerPromptCacheSupport.ts`,不要在组件里各自写一套判断。 ### 当前 UI 落点 @@ -149,5 +160,7 @@ Provider 池当前把 Prompt Cache 能力视为 **Provider 类型能力**,而 ### 语义约束 1. `anthropic-compatible` 只表示 Anthropic wire format 兼容,不等于上游已声明 Automatic Prompt Cache -2. 如需复用前缀,应提示用户使用显式 `cache_control` -3. 若上游未实现 Automatic Prompt Caching,`cached_input_tokens` 为空不应直接归因到 Lime 没发字段 +2. 已知官方 Anthropic 兼容端点可直接按 Automatic Prompt Cache 处理 +3. 只有当上游明确声明支持 Automatic Prompt Cache 时,才应把未知自定义 Provider 标记为 `automatic` +4. 若未声明自动缓存,应提示用户使用显式 `cache_control` +5. 若上游未实现 Automatic Prompt Caching,`cached_input_tokens` 为空不应直接归因到 Lime 没发字段 diff --git a/src/components/provider-pool/api-key/providerTypeMapping.test.ts b/src/components/provider-pool/api-key/providerTypeMapping.test.ts index f9e19b141..e2b52ae67 100644 --- a/src/components/provider-pool/api-key/providerTypeMapping.test.ts +++ b/src/components/provider-pool/api-key/providerTypeMapping.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it } from "vitest"; +import { getProviderPromptCacheMode } from "@/lib/model/providerPromptCacheSupport"; import { buildCatalogAliasMap, resolveRegistryProviderId, @@ -98,4 +99,17 @@ describe("providerTypeMapping", () => { expect(resolved).toBe("openai"); }); + + it("anthropic-compatible 复用 Anthropic 模型目录时不应被当成自动缓存能力", () => { + const resolved = resolveRegistryProviderId("custom-anthropic-gateway", { + providerType: "anthropic-compatible", + catalogAliasMap: null, + validRegistryProviders: ["openai", "anthropic"], + }); + + expect(resolved).toBe("anthropic"); + expect(getProviderPromptCacheMode("anthropic-compatible")).toBe( + "explicit_only", + ); + }); }); diff --git a/src/components/provider-pool/api-key/providerTypeMapping.ts b/src/components/provider-pool/api-key/providerTypeMapping.ts index 662ea8605..0f9cfaae7 100644 --- a/src/components/provider-pool/api-key/providerTypeMapping.ts +++ b/src/components/provider-pool/api-key/providerTypeMapping.ts @@ -40,11 +40,16 @@ const LEGACY_PROVIDER_ID_TO_REGISTRY_ID: Record = { /** * Provider 类型(API 协议)到 model_registry provider_id 的映射 - * 作为 Provider ID 映射的回退 + * 作为 Provider ID 映射的回退。 + * + * 注意: + * - 这里只负责把 Provider 类型归一到模型注册表 provider_id; + * - 该映射不能用于推断 Prompt Cache、工具能力等运行时语义; + * - 例如 `anthropic-compatible -> anthropic` 只表示模型目录复用,不等于官方 Anthropic 自动缓存能力。 */ const PROVIDER_TYPE_TO_REGISTRY_ID: Record = { anthropic: "anthropic", - "anthropic-compatible": "anthropic", // Anthropic 兼容格式 + "anthropic-compatible": "anthropic", // 仅复用 Anthropic 模型目录 openai: "openai", "openai-response": "openai", codex: "codex", diff --git a/src/components/image-gen/tabs/MyGalleryTab.test.tsx b/src/components/resources/ResourcesImageWorkbench.test.tsx similarity index 77% rename from src/components/image-gen/tabs/MyGalleryTab.test.tsx rename to src/components/resources/ResourcesImageWorkbench.test.tsx index e5bec4e43..f954a0e2f 100644 --- a/src/components/image-gen/tabs/MyGalleryTab.test.tsx +++ b/src/components/resources/ResourcesImageWorkbench.test.tsx @@ -6,7 +6,7 @@ import { renderIntoDom, setReactActEnvironment, type MountedRoot, -} from "../test-utils"; +} from "@/components/image-gen/test-utils"; const { mockConvertLocalFileSrc } = vi.hoisted(() => ({ mockConvertLocalFileSrc: vi.fn(), @@ -90,13 +90,21 @@ vi.mock("@/components/workspace/media/ImageGallery", () => ({ }, })); -import { MyGalleryTab } from "./MyGalleryTab"; +import { ResourcesImageWorkbench } from "./ResourcesImageWorkbench"; const mountedRoots: MountedRoot[] = []; -function renderTab(projectId: string | null = "project-1"): HTMLDivElement { +function renderWorkbench( + projectId: string | null = "project-1", + options?: { + onUploadImage?: () => Promise | void; + }, +): HTMLDivElement { const mounted = renderIntoDom( - , + , mountedRoots, ); return mounted.container; @@ -131,14 +139,14 @@ afterEach(() => { cleanupMountedRoots(mountedRoots); }); -describe("MyGalleryTab", () => { - it("未选择项目时应提示先选择项目", () => { - const container = renderTab(null); - expect(container.textContent).toContain("请先选择项目"); +describe("ResourcesImageWorkbench", () => { + it("未选择项目时应提示先选择资料库", () => { + const container = renderWorkbench(null); + expect(container.textContent).toContain("先选择资料库"); }); - it("应支持选中后插入文稿", async () => { - const container = renderTab("project-1"); + it("应支持选中后插入当前画布", async () => { + const container = renderWorkbench("project-1"); await act(async () => { findButton(container, "选择素材").click(); @@ -165,8 +173,8 @@ describe("MyGalleryTab", () => { expect(mockToastSuccess).toHaveBeenCalled(); }); - it("双击素材应直接插入文稿", async () => { - const container = renderTab("project-1"); + it("双击素材应直接插入当前画布", async () => { + const container = renderWorkbench("project-1"); await act(async () => { findButton(container, "双击插入").click(); @@ -175,4 +183,16 @@ describe("MyGalleryTab", () => { expect(mockEmitCanvasImageInsertRequest).toHaveBeenCalledTimes(1); }); + + it("应透传上传本地图片动作", async () => { + const onUploadImage = vi.fn(); + const container = renderWorkbench("project-1", { onUploadImage }); + + await act(async () => { + findButton(container, "上传本地图片").click(); + await flushEffects(); + }); + + expect(onUploadImage).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/resources/ResourcesImageWorkbench.tsx b/src/components/resources/ResourcesImageWorkbench.tsx new file mode 100644 index 000000000..d6a69a2dd --- /dev/null +++ b/src/components/resources/ResourcesImageWorkbench.tsx @@ -0,0 +1,332 @@ +import { useEffect, useRef, useState } from "react"; +import { ImagePlus, Images, LocateFixed } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { convertLocalFileSrc } from "@/lib/api/fileSystem"; +import { getActiveContentTarget } from "@/lib/activeContentTarget"; +import { + emitCanvasImageInsertRequest, + onCanvasImageInsertAck, + type CanvasImageInsertAck, + type CanvasImageTargetType, +} from "@/lib/canvasImageInsertBus"; +import { + addCanvasImageInsertHistory, + getCanvasImageInsertHistory, + type CanvasImageInsertHistoryEntry, +} from "@/lib/canvasImageInsertHistory"; +import { ImageGallery } from "@/components/workspace/media/ImageGallery"; +import type { GalleryMaterial } from "@/types/gallery-material"; +import type { Page, PageParams } from "@/types/page"; + +interface ResourcesImageWorkbenchProps { + projectId?: string | null; + onNavigate?: (page: Page, params?: PageParams) => void; + onUploadImage?: () => Promise | void; +} + +function normalizeCanvasType( + value: string | null | undefined, +): CanvasImageTargetType { + if (value === "document" || value === "video") { + return value; + } + if (value === "script") { + return "video"; + } + return "document"; +} + +function mapCanvasTypeToTheme(canvasType: CanvasImageTargetType): string { + switch (canvasType) { + case "video": + return "video"; + case "document": + case "auto": + default: + return "document"; + } +} + +function getVisibleInsertHistory( + projectId?: string | null, +): CanvasImageInsertHistoryEntry[] { + const history = getCanvasImageInsertHistory(); + const filtered = projectId + ? history.filter((entry) => entry.projectId === projectId) + : history; + return filtered.slice(0, 3); +} + +export function ResourcesImageWorkbench({ + projectId, + onNavigate, + onUploadImage, +}: ResourcesImageWorkbenchProps) { + const [selectedMaterial, setSelectedMaterial] = + useState(null); + const [recentInsertHistory, setRecentInsertHistory] = useState< + CanvasImageInsertHistoryEntry[] + >(() => getVisibleInsertHistory(projectId)); + const pendingInsertRequestMetaRef = useRef< + Map< + string, + { + projectId: string; + contentId: string | null; + canvasType: CanvasImageTargetType; + theme: string; + imageTitle?: string; + } + > + >(new Map()); + + useEffect(() => { + setSelectedMaterial(null); + setRecentInsertHistory(getVisibleInsertHistory(projectId)); + }, [projectId]); + + useEffect(() => { + const unsubscribe = onCanvasImageInsertAck((ack: CanvasImageInsertAck) => { + const pendingMeta = pendingInsertRequestMetaRef.current.get( + ack.requestId, + ); + if (!pendingMeta) { + return; + } + pendingInsertRequestMetaRef.current.delete(ack.requestId); + + if (!ack.success) { + toast.error("插图失败,请返回创作区重试"); + return; + } + + const nextHistory = addCanvasImageInsertHistory({ + requestId: ack.requestId, + projectId: pendingMeta.projectId, + contentId: pendingMeta.contentId, + canvasType: pendingMeta.canvasType, + theme: pendingMeta.theme, + imageTitle: pendingMeta.imageTitle, + locationLabel: ack.locationLabel, + }); + setRecentInsertHistory( + (projectId + ? nextHistory.filter((entry) => entry.projectId === projectId) + : nextHistory + ).slice(0, 3), + ); + }); + + return unsubscribe; + }, [projectId]); + + const handleInsertFromGallery = (material: GalleryMaterial) => { + if (!projectId) { + toast.error("请先选择项目"); + return; + } + + const imageUrl = material.filePath + ? convertLocalFileSrc(material.filePath) + : material.metadata?.thumbnail || ""; + if (!imageUrl) { + toast.error("该素材缺少可用图片地址,无法插入"); + return; + } + + const target = getActiveContentTarget(); + const sameProjectTarget = target?.projectId === projectId ? target : null; + const targetContentId = sameProjectTarget?.contentId ?? null; + const targetCanvasType = normalizeCanvasType(sameProjectTarget?.canvasType); + const targetTheme = mapCanvasTypeToTheme(targetCanvasType); + + const request = emitCanvasImageInsertRequest({ + projectId, + contentId: targetContentId, + canvasType: targetCanvasType, + anchorHint: + targetCanvasType === "video" ? "video_start_frame" : "section_end", + source: "gallery", + image: { + id: material.id, + previewUrl: material.metadata?.thumbnail || imageUrl, + contentUrl: imageUrl, + title: material.name, + width: material.metadata?.width, + height: material.metadata?.height, + attributionName: "项目素材库", + provider: "gallery", + }, + }); + + pendingInsertRequestMetaRef.current.set(request.requestId, { + projectId, + contentId: targetContentId, + canvasType: targetCanvasType, + theme: targetTheme, + imageTitle: material.name, + }); + + onNavigate?.("agent", { + projectId, + contentId: targetContentId ?? undefined, + theme: targetTheme, + lockTheme: false, + }); + toast.success("已发送到当前画布,正在自动定位"); + }; + + const handleRelocate = (entry: CanvasImageInsertHistoryEntry) => { + onNavigate?.("agent", { + projectId: entry.projectId, + contentId: entry.contentId ?? undefined, + theme: entry.theme, + lockTheme: false, + }); + toast.success("正在定位到插图位置"); + }; + + if (!projectId) { + return ( +
+
+
+ +
+
+

+ 先选择资料库 +

+

+ 选择项目后,这里会统一承接本地图片上传、图片库浏览,以及插入当前画布。 +

+
+
+
+ ); + } + + return ( +
+
+
+
+ + 图片工作台 + +
+

+ 我的图片库 +

+

+ 本地图片上传与图片库插图动作已经收口到资料库图片视图。双击图片可直接插入当前画布,也可以先选中后再执行插入。 +

+
+
+ + {selectedMaterial + ? `已选中:${selectedMaterial.name}` + : "当前未选择图片"} + + + 与当前画布联动 + +
+
+ +
+ + {recentInsertHistory[0] && ( + + )} + +
+
+ + {recentInsertHistory.length > 0 && ( +
+ {recentInsertHistory.map((entry) => ( +
+
+
+

+ {entry.imageTitle?.trim() || "图片"} +

+

+ {entry.locationLabel || "已插入当前画布"} +

+
+ +
+
+ ))} +
+ )} + +
+ { + setSelectedMaterial(materials[0] || null); + }} + onDoubleClick={handleInsertFromGallery} + /> +
+
+
+ ); +} + +export default ResourcesImageWorkbench; diff --git a/src/components/resources/ResourcesPage.test.tsx b/src/components/resources/ResourcesPage.test.tsx index a5df80c38..4b83dcbda 100644 --- a/src/components/resources/ResourcesPage.test.tsx +++ b/src/components/resources/ResourcesPage.test.tsx @@ -126,6 +126,12 @@ vi.mock("./services/resourceAdapter", () => ({ fetchDocumentDetail: vi.fn(), })); +vi.mock("./ResourcesImageWorkbench", () => ({ + ResourcesImageWorkbench: () => ( +
图片工作台已挂载
+ ), +})); + vi.mock("./store", () => ({ resourcesSelectors: { visibleItems: (state: typeof resourcesState) => state.visibleItems, @@ -202,4 +208,23 @@ describe("ResourcesPage", () => { ); await leaveTip(categoryTip); }); + + it("切到图片分类后应挂载图片工作台", async () => { + const container = renderPage(); + await flushEffects(); + + const imageCategoryButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("图片")); + expect(imageCategoryButton).toBeInstanceOf(HTMLButtonElement); + + await act(async () => { + imageCategoryButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await flushEffects(); + }); + + expect(getBodyText()).toContain("图片工作台已挂载"); + }); }); diff --git a/src/components/resources/ResourcesPage.tsx b/src/components/resources/ResourcesPage.tsx index 7af5994f4..7d934bfa3 100644 --- a/src/components/resources/ResourcesPage.tsx +++ b/src/components/resources/ResourcesPage.tsx @@ -67,6 +67,7 @@ import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { CanvasBreadcrumbHeader } from "@/lib/workspace/workbenchUi"; import { cn } from "@/lib/utils"; import type { Page, PageParams } from "@/types/page"; +import { ResourcesImageWorkbench } from "./ResourcesImageWorkbench"; import { fetchDocumentDetail } from "./services/resourceAdapter"; import type { ResourceItem } from "./services/types"; import { resourcesSelectors, useResourcesStore } from "./store"; @@ -415,7 +416,6 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { useEffect(() => { return onResourceProjectChange((detail) => { if ( - detail.source !== "image-gen-target" && detail.source !== "image-gen-save" && detail.source !== "general-chat" && detail.source !== "browser-runtime" @@ -536,6 +536,35 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { await uploadFile(selected); }, [projectId, uploadFile]); + const handleUploadImage = useCallback(async () => { + if (!projectId) return; + + const selected = await open({ + directory: false, + multiple: false, + title: "选择本地图片", + filters: [ + { + name: "图片", + extensions: [ + "jpg", + "jpeg", + "png", + "webp", + "gif", + "bmp", + "svg", + "ico", + "heic", + ], + }, + ], + }); + if (!selected || Array.isArray(selected)) return; + + await uploadFile(selected); + }, [projectId, uploadFile]); + const handleRename = useCallback( async (item: ResourceItem) => { const name = window.prompt("请输入新名称", item.name); @@ -1317,6 +1346,14 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
)} + {viewCategory === "image" && ( + + )} +
diff --git a/src/components/settings-v2/general/appearance/index.test.tsx b/src/components/settings-v2/general/appearance/index.test.tsx index 3ca4232f2..d85bc7cda 100644 --- a/src/components/settings-v2/general/appearance/index.test.tsx +++ b/src/components/settings-v2/general/appearance/index.test.tsx @@ -189,7 +189,7 @@ describe("AppearanceSettings", () => { it("切换底部入口时应保留 workspace_preferences 的其他配置", async () => { const { container } = await renderPage(); const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("工具箱"), + (item) => item.textContent?.includes("插件中心"), ); await act(async () => { @@ -199,7 +199,7 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.navigation.enabled_items).toEqual(["tools"]); + expect(savedConfig.navigation.enabled_items).toEqual(["plugins"]); expect( savedConfig.workspace_preferences.media_defaults.voice .preferredProviderId, @@ -209,7 +209,7 @@ describe("AppearanceSettings", () => { it("切换底部入口时应保存完整的侧栏导航配置", async () => { const { container } = await renderPage(); const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("工具箱"), + (item) => item.textContent?.includes("插件中心"), ); await act(async () => { @@ -219,7 +219,7 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.navigation.enabled_items).toEqual(["tools"]); + expect(savedConfig.navigation.enabled_items).toEqual(["plugins"]); }); it("缺少导航配置时应回退到底部默认入口", async () => { @@ -232,7 +232,7 @@ describe("AppearanceSettings", () => { const { container } = await renderPage(); const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("工具箱"), + (item) => item.textContent?.includes("插件中心"), ); await act(async () => { @@ -242,20 +242,20 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.navigation.enabled_items).toEqual(["tools"]); + expect(savedConfig.navigation.enabled_items).toEqual(["plugins"]); }); it("应允许把所有可选侧栏入口恢复为默认隐藏", async () => { mockGetConfig.mockResolvedValue({ ...createMockConfig(), navigation: { - enabled_items: ["tools"], + enabled_items: ["plugins"], }, }); const { container } = await renderPage(); const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("工具箱"), + (item) => item.textContent?.includes("插件中心"), ); await act(async () => { diff --git a/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts b/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts index e93f59d6d..4b9fff4e2 100644 --- a/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts +++ b/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts @@ -35,19 +35,14 @@ describe("hotkey catalog", () => { }); expect(catalog.summary).toEqual({ - total: 18, - ready: 18, + total: 8, + ready: 8, attention: 0, globalReady: 3, }); expect( - catalog.sections.find((section) => section.scene === "terminal")?.hotkeys, - ).toHaveLength(10); - expect( - catalog.sections - .find((section) => section.scene === "terminal") - ?.hotkeys.some((item) => item.id === "terminal-scroll-bottom-mac"), - ).toBe(true); + catalog.sections.find((section) => section.scene === "terminal"), + ).toBeUndefined(); }); it("应正确标记未启用、未配置与运行时异常状态", () => { @@ -105,6 +100,6 @@ describe("hotkey catalog", () => { }), ); expect(catalog.summary.globalReady).toBe(0); - expect(catalog.summary.total).toBe(16); + expect(catalog.summary.total).toBe(8); }); }); diff --git a/src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts b/src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts index ccffc6117..00c7ac7fe 100644 --- a/src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts +++ b/src/components/settings-v2/general/hotkeys/hotkeyCatalog.ts @@ -13,7 +13,6 @@ import { import type { AuditedHotkeyDefinition, HotkeyScene } from "@/lib/hotkeys/types"; import { DOCUMENT_CANVAS_HOTKEYS } from "@/lib/workspace/workbenchCanvas"; import { DOCUMENT_EDITOR_HOTKEYS } from "@/lib/workspace/workbenchCanvas"; -import { getTerminalPageHotkeys } from "@/components/terminal/terminalPageHotkeys"; import { WORKBENCH_SIDEBAR_TOGGLE_HOTKEY } from "@/components/workspace/hooks/workbenchHotkeys"; export type HotkeyStatusKind = @@ -99,10 +98,6 @@ const SCENE_META: Record = title: "工作区", description: "用于主工作区导航与侧栏控制。", }, - terminal: { - title: "终端页面", - description: "只在终端页面里生效,用于搜索和字体调整。", - }, "document-editor": { title: "文档编辑器", description: "针对源码/富文本编辑态的保存与退出操作。", @@ -337,13 +332,6 @@ export function buildAuditedHotkeyCatalog({ createStaticHotkeyItem(WORKBENCH_SIDEBAR_TOGGLE_HOTKEY, platform), ], }, - { - scene: "terminal", - ...SCENE_META.terminal, - hotkeys: getTerminalPageHotkeys(platform).map((item) => - createStaticHotkeyItem(item, platform), - ), - }, { scene: "document-editor", ...SCENE_META["document-editor"], diff --git a/src/components/settings-v2/general/hotkeys/index.test.tsx b/src/components/settings-v2/general/hotkeys/index.test.tsx index 95b9d7757..7b0cf8142 100644 --- a/src/components/settings-v2/general/hotkeys/index.test.tsx +++ b/src/components/settings-v2/general/hotkeys/index.test.tsx @@ -192,10 +192,9 @@ describe("HotkeysSettings", () => { expect(text).toContain("查看已接入实现并完成审计的快捷键。"); expect(text).toContain("全局运行中 2 / 3"); expect(text).toContain("运行时状态已连接"); - expect(text).toContain("已审计 18 项"); - expect(text).toContain("终端页面"); - expect(text).toContain("共 10 项"); - expect(text).toContain("滚动到终端底部(macOS)"); + expect(text).toContain("已审计 8 项"); + expect(text).not.toContain("终端页面"); + expect(text).toContain("共 2 项"); expect(text).toContain("文档画布"); }); diff --git a/src/components/settings-v2/general/hotkeys/index.tsx b/src/components/settings-v2/general/hotkeys/index.tsx index d65421485..f0f75498c 100644 --- a/src/components/settings-v2/general/hotkeys/index.tsx +++ b/src/components/settings-v2/general/hotkeys/index.tsx @@ -18,7 +18,6 @@ import { PanelsTopLeft, ScrollText, Sparkles, - TerminalSquare, type LucideIcon, } from "lucide-react"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; @@ -137,7 +136,6 @@ function HotkeyRow({ item }: { item: AuditedHotkeyItem }) { const SECTION_ICON_MAP: Record = { global: Sparkles, workspace: PanelsTopLeft, - terminal: TerminalSquare, "document-editor": FileText, "document-canvas": ScrollText, }; diff --git a/src/components/settings-v2/system/about/index.test.tsx b/src/components/settings-v2/system/about/index.test.tsx index ecf32ee0e..0585e60cf 100644 --- a/src/components/settings-v2/system/about/index.test.tsx +++ b/src/components/settings-v2/system/about/index.test.tsx @@ -114,8 +114,8 @@ beforeEach(() => { vi.clearAllMocks(); mockCheckForUpdates.mockResolvedValue({ - current: "1.9.0", - latest: "1.9.1", + current: "1.10.0", + latest: "1.10.1", hasUpdate: true, downloadUrl: "https://example.com/lime", releaseNotes: "修复设置页视觉层级并优化更新体验。", @@ -153,7 +153,7 @@ describe("AboutSection", () => { const text = container.textContent ?? ""; expect(text).toContain("关于 Lime"); expect(text).toContain("了解版本、更新入口和 Lime 的工作区定位。"); - expect(text).toContain("当前版本 1.9.0"); + expect(text).toContain("当前版本 1.10.0"); expect(text).toContain("工作区主线 4 项"); expect(text).toContain("相关入口 3 个"); expect(text).toContain("产品定位"); diff --git a/src/components/settings-v2/system/web-search/index.test.tsx b/src/components/settings-v2/system/web-search/index.test.tsx index 6a733ff40..518e57e48 100644 --- a/src/components/settings-v2/system/web-search/index.test.tsx +++ b/src/components/settings-v2/system/web-search/index.test.tsx @@ -241,7 +241,9 @@ describe("WebSearchSettings", () => { expect(getBodyText()).toContain( "申请地址:https://www.pexels.com/api/new/", ); - expect(getBodyText()).toContain("验证路径:插图 → 图片搜索 → 联网搜索。"); + expect(getBodyText()).toContain( + "验证路径:Claw → @素材 → Pexels 图片候选。", + ); await leaveTip(pexelsTip); }); diff --git a/src/components/settings-v2/system/web-search/index.tsx b/src/components/settings-v2/system/web-search/index.tsx index 3fe80646a..dbd3e7117 100644 --- a/src/components/settings-v2/system/web-search/index.tsx +++ b/src/components/settings-v2/system/web-search/index.tsx @@ -1117,7 +1117,7 @@ export function WebSearchSettings() { Pexels 接入说明已收纳
@@ -1218,7 +1218,7 @@ export function WebSearchSettings() { Pixabay 接入说明已收纳
@@ -1268,8 +1268,8 @@ export function WebSearchSettings() {

{pexelsKeyConfigured || pixabayKeyConfigured - ? "插图页至少已有一个联网图片来源可用。" - : "图片搜索 Key 仍未配置,插图页联网搜索会回退到环境变量或不可用状态。"} + ? "Claw 图片素材搜索至少已有一个联网图片来源可用。" + : "图片搜索 Key 仍未配置,Claw `@素材` 会回退到环境变量或不可用状态。"}

diff --git a/src/components/terminal/ConnectionSelector.tsx b/src/components/terminal/ConnectionSelector.tsx deleted file mode 100644 index a8adef7fe..000000000 --- a/src/components/terminal/ConnectionSelector.tsx +++ /dev/null @@ -1,518 +0,0 @@ -/** - * 连接选择器组件 - * - * 提供 Waveterm 风格的连接下拉选择器。 - * 支持本地连接、SSH 远程连接和 WSL 连接。 - * - * @module components/terminal/ConnectionSelector - */ - -import { - useState, - useEffect, - useCallback, - useRef, - type KeyboardEvent, -} from "react"; -import styled from "styled-components"; -import { - Monitor, - Server, - Terminal, - Search, - Settings, - Loader2, - Check, -} from "lucide-react"; -import { - listConnections, - connectionToSessionString, - type ConnectionListEntry, - type ConnectionType, -} from "@/lib/connection-api"; - -// ============================================================================ -// 样式组件 -// ============================================================================ - -const SelectorContainer = styled.div` - position: relative; - display: inline-flex; -`; - -const TriggerButton = styled.button<{ $isOpen?: boolean }>` - display: flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border: none; - border-radius: 4px; - background: transparent; - color: #e0e0e0; - font-size: 12px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: rgba(255, 255, 255, 0.1); - } - - svg { - width: 14px; - height: 14px; - opacity: 0.7; - } -`; - -const TriggerLabel = styled.span` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 200px; -`; - -const DropdownPanel = styled.div<{ $visible: boolean }>` - position: absolute; - top: calc(100% + 4px); - left: 0; - min-width: 320px; - max-height: 450px; - background: #222222; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - z-index: 1000; - overflow: hidden; - display: ${({ $visible }) => ($visible ? "flex" : "none")}; - flex-direction: column; -`; - -const SearchBox = styled.div` - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: #2a2a2a; - - svg { - width: 16px; - height: 16px; - color: #666; - } -`; - -const SearchInput = styled.input` - flex: 1; - border: none; - background: transparent; - color: #e0e0e0; - font-size: 14px; - outline: none; - - &::placeholder { - color: #666; - } -`; - -const ConnectionList = styled.div` - flex: 1; - overflow-y: auto; - padding: 6px 0; -`; - -const GroupHeader = styled.div` - padding: 8px 14px 4px; - font-size: 11px; - font-weight: 600; - color: #888; - text-transform: uppercase; - letter-spacing: 0.5px; -`; - -const ConnectionItem = styled.button<{ - $selected?: boolean; - $focused?: boolean; -}>` - display: flex; - align-items: center; - gap: 10px; - width: 100%; - padding: 8px 14px; - border: none; - background: ${({ $selected, $focused }) => - $selected - ? "rgba(88, 193, 66, 0.15)" - : $focused - ? "rgba(255, 255, 255, 0.05)" - : "transparent"}; - color: #e0e0e0; - font-size: 13px; - text-align: left; - cursor: pointer; - transition: background 0.1s ease; - - &:hover { - background: ${({ $selected }) => - $selected ? "rgba(88, 193, 66, 0.2)" : "rgba(255, 255, 255, 0.08)"}; - } -`; - -const ConnectionIcon = styled.div<{ $type: string }>` - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - color: ${({ $type }) => ($type === "local" ? "#58c142" : "#888")}; - - svg { - width: 16px; - height: 16px; - } -`; - -const ConnectionInfo = styled.div` - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const CheckMark = styled.div<{ $visible: boolean }>` - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - color: #58c142; - visibility: ${({ $visible }) => ($visible ? "visible" : "hidden")}; - - svg { - width: 16px; - height: 16px; - } -`; - -const FooterActions = styled.div` - display: flex; - align-items: center; - padding: 8px 14px; - border-top: 1px solid rgba(255, 255, 255, 0.08); - background: #2a2a2a; -`; - -const FooterButton = styled.button` - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border: none; - border-radius: 4px; - background: transparent; - color: #888; - font-size: 12px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: #e0e0e0; - } - - svg { - width: 14px; - height: 14px; - } -`; - -const LoadingIndicator = styled.div` - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - color: #666; - - svg { - animation: spin 1s linear infinite; - } - - @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } - } -`; - -const EmptyState = styled.div` - padding: 24px; - text-align: center; - color: #666; - font-size: 13px; -`; - -// ============================================================================ -// 辅助函数 -// ============================================================================ - -function getConnectionIcon(type: ConnectionType) { - switch (type) { - case "local": - return ; - case "ssh": - return ; - case "wsl": - return ; - default: - return ; - } -} - -// ============================================================================ -// 主组件 -// ============================================================================ - -interface ConnectionSelectorProps { - /** 当前连接名称 */ - currentConnection?: string; - /** 选择连接回调 */ - onSelect: (connection: ConnectionListEntry) => void; - /** 编辑连接回调 - 打开连接编辑器模态窗口 */ - onEditConnections?: () => void; - /** 打开设置回调(已弃用,改用 onEditConnections) */ - onOpenSettings?: () => void; -} - -export function ConnectionSelector({ - currentConnection, - onSelect, - onEditConnections, - onOpenSettings, -}: ConnectionSelectorProps) { - const [isOpen, setIsOpen] = useState(false); - const [connections, setConnections] = useState([]); - const [loading, setLoading] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [focusedIndex, setFocusedIndex] = useState(-1); - - const containerRef = useRef(null); - const searchInputRef = useRef(null); - - // 加载连接列表 - const loadConnections = useCallback(async () => { - setLoading(true); - try { - const list = await listConnections(); - setConnections(list); - } catch (err) { - console.error("[ConnectionSelector] 加载连接列表失败:", err); - } finally { - setLoading(false); - } - }, []); - - // 首次挂载加载连接列表 - useEffect(() => { - loadConnections(); - }, [loadConnections]); - - // 打开时刷新并聚焦 - useEffect(() => { - if (isOpen) { - loadConnections(); - setSearchQuery(""); - setFocusedIndex(-1); - setTimeout(() => { - searchInputRef.current?.focus(); - }, 50); - } - }, [isOpen, loadConnections]); - - // 点击外部关闭 - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); - } - }, [isOpen]); - - // 过滤连接 - const filteredConnections = connections.filter((conn) => { - if (!searchQuery) return true; - const query = searchQuery.toLowerCase(); - return ( - conn.name.toLowerCase().includes(query) || - conn.label.toLowerCase().includes(query) || - (conn.host && conn.host.toLowerCase().includes(query)) - ); - }); - - // 分组 - const localConnections = filteredConnections.filter( - (c) => c.type === "local", - ); - const remoteConnections = filteredConnections.filter( - (c) => c.type !== "local", - ); - - // 扁平化列表(用于键盘导航) - const flatList = [...localConnections, ...remoteConnections]; - - // 获取当前选中的连接 - const currentConn = - connections.find((c) => c.name === currentConnection) || - connections.find((c) => c.type === "local"); - - // 键盘导航 - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - setFocusedIndex((prev) => Math.min(prev + 1, flatList.length - 1)); - break; - case "ArrowUp": - e.preventDefault(); - setFocusedIndex((prev) => Math.max(prev - 1, 0)); - break; - case "Enter": - e.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < flatList.length) { - handleSelect(flatList[focusedIndex]); - } - break; - case "Escape": - e.preventDefault(); - setIsOpen(false); - break; - } - }; - - // 选择连接 - const handleSelect = (connection: ConnectionListEntry) => { - onSelect(connection); - setIsOpen(false); - }; - - return ( - - setIsOpen(!isOpen)}> - - {currentConn?.label || "Local"} - - - - - - setSearchQuery(e.target.value)} - /> - - - - {loading ? ( - - - - ) : filteredConnections.length === 0 ? ( - - {searchQuery - ? "No matching connections" - : "No connections available"} - - ) : ( - <> - {localConnections.length > 0 && ( - <> - Local - {localConnections.map((conn) => { - const isSelected = currentConn?.name === conn.name; - return ( - handleSelect(conn)} - > - - {getConnectionIcon(conn.type)} - - {conn.label} - - - - - ); - })} - - )} - - {remoteConnections.length > 0 && ( - <> - Remote - {remoteConnections.map((conn) => { - const isSelected = currentConn?.name === conn.name; - return ( - handleSelect(conn)} - > - - {getConnectionIcon(conn.type)} - - {conn.label} - - - - - ); - })} - - )} - - )} - - - {(onEditConnections || onOpenSettings) && ( - - { - if (onEditConnections) { - onEditConnections(); - } else if (onOpenSettings) { - onOpenSettings(); - } - setIsOpen(false); - }} - > - - Edit Connections - - - )} - - - ); -} - -// eslint-disable-next-line react-refresh/only-export-components -export { connectionToSessionString }; -export type { ConnectionListEntry }; diff --git a/src/components/terminal/ConnectionStatusIndicator.tsx b/src/components/terminal/ConnectionStatusIndicator.tsx deleted file mode 100644 index 703ea162f..000000000 --- a/src/components/terminal/ConnectionStatusIndicator.tsx +++ /dev/null @@ -1,241 +0,0 @@ -/** - * @file ConnectionStatusIndicator.tsx - * @description 连接状态指示器组件 - * @module components/terminal/ConnectionStatusIndicator - * - * 显示终端连接状态,包括连接中、已连接、断开、错误等状态。 - * 提供重连按钮。 - * - * _Requirements: 7.3, 7.4, 7.5_ - */ - -import React from "react"; -import type { ConnStatus, ShellProcStatus } from "@/lib/terminal/store"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -/** 组件属性 */ -export interface ConnectionStatusIndicatorProps { - /** 连接状态 */ - connStatus: ConnStatus; - /** Shell 进程状态 */ - shellProcStatus: ShellProcStatus; - /** 退出码 */ - exitCode?: number; - /** 重连回调 - * _Requirements: 7.5_ - */ - onReconnect?: () => void; -} - -// ============================================================================ -// 图标组件 -// ============================================================================ - -/** 连接中图标 */ -const ConnectingIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 已连接图标 */ -const ConnectedIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 断开连接图标 */ -const DisconnectedIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - - - -); - -/** 错误图标 */ -const ErrorIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - -); - -/** 重连图标 */ -const ReconnectIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - -); - -// ============================================================================ -// 主组件 -// ============================================================================ - -/** - * 连接状态指示器组件 - * - * _Requirements: 7.3, 7.4, 7.5_ - */ -export const ConnectionStatusIndicator: React.FC< - ConnectionStatusIndicatorProps -> = ({ connStatus, shellProcStatus, exitCode, onReconnect }) => { - // 判断是否需要显示指示器 - const shouldShow = - connStatus.status !== "connected" || - shellProcStatus === "done" || - connStatus.error; - - if (!shouldShow) { - return null; - } - - // 获取状态信息 - const getStatusInfo = () => { - // Shell 进程已完成 - if (shellProcStatus === "done") { - const exitCodeText = - exitCode !== undefined && exitCode !== 0 - ? ` (退出码: ${exitCode})` - : ""; - return { - icon: , - text: `进程已结束${exitCodeText}`, - color: "text-gray-400", - bgColor: "bg-gray-800/80", - showReconnect: true, - }; - } - - // 连接错误 - // _Requirements: 7.3_ - if (connStatus.status === "error" || connStatus.error) { - return { - icon: , - text: connStatus.error || "连接错误", - color: "text-red-400", - bgColor: "bg-red-900/80", - showReconnect: true, - }; - } - - // 断开连接 - // _Requirements: 7.4_ - if (connStatus.status === "disconnected") { - return { - icon: , - text: "连接已断开", - color: "text-yellow-400", - bgColor: "bg-yellow-900/80", - showReconnect: true, - }; - } - - // 连接中 - if (connStatus.status === "connecting") { - return { - icon: , - text: "连接中...", - color: "text-blue-400", - bgColor: "bg-blue-900/80", - showReconnect: false, - }; - } - - // 初始化 - if (connStatus.status === "init") { - return { - icon: , - text: "初始化中...", - color: "text-gray-400", - bgColor: "bg-gray-800/80", - showReconnect: false, - }; - } - - return null; - }; - - const statusInfo = getStatusInfo(); - if (!statusInfo) { - return null; - } - - return ( -
-
- {statusInfo.icon} - {statusInfo.text} -
- - {/* 重连按钮 - * _Requirements: 7.5_ - */} - {statusInfo.showReconnect && onReconnect && ( - - )} -
- ); -}; - -export default ConnectionStatusIndicator; diff --git a/src/components/terminal/ConnectionsEditorModal.tsx b/src/components/terminal/ConnectionsEditorModal.tsx deleted file mode 100644 index 2b1463e30..000000000 --- a/src/components/terminal/ConnectionsEditorModal.tsx +++ /dev/null @@ -1,502 +0,0 @@ -/** - * 连接配置编辑器模态窗口 - * - * 提供 Waveterm 风格的 JSON 配置编辑界面。 - * 用于编辑 connections.json 配置文件。 - * - * @module components/terminal/ConnectionsEditorModal - */ - -import { useState, useEffect, useCallback } from "react"; -import styled from "styled-components"; -import { - X, - Save, - RefreshCw, - FileJson, - Check, - AlertCircle, - Folder, -} from "lucide-react"; -import { createPortal } from "react-dom"; -import { - getRawConnectionConfig, - saveRawConnectionConfig, - getConnectionConfigPath, -} from "@/lib/connection-api"; - -// ============================================================================ -// 样式组件 -// ============================================================================ - -const ModalOverlay = styled.div` - position: fixed; - inset: 0; - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); -`; - -const ModalContainer = styled.div` - width: 90vw; - max-width: 900px; - height: 80vh; - max-height: 700px; - background: #1a1a1a; - border: 1px solid #333; - border-radius: 12px; - display: flex; - flex-direction: column; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); - overflow: hidden; -`; - -const ModalHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid #333; - background: #222; -`; - -const HeaderTitle = styled.div` - display: flex; - align-items: center; - gap: 10px; - color: #e0e0e0; - font-size: 16px; - font-weight: 600; - - svg { - width: 20px; - height: 20px; - color: #58c142; - } -`; - -const HeaderActions = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; - -const IconButton = styled.button<{ - $variant?: "default" | "primary" | "danger"; -}>` - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: none; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - - ${({ $variant }) => { - switch ($variant) { - case "primary": - return ` - background: #58c142; - color: white; - &:hover { background: #4aa838; } - &:disabled { background: #2a2a2a; color: #666; } - `; - case "danger": - return ` - background: transparent; - color: #808080; - &:hover { background: rgba(255, 100, 100, 0.1); color: #ff6464; } - `; - default: - return ` - background: transparent; - color: #808080; - &:hover { background: rgba(255, 255, 255, 0.1); color: #e0e0e0; } - `; - } - }} - - svg { - width: 16px; - height: 16px; - } -`; - -const ModalBody = styled.div` - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -`; - -const ConfigPathBar = styled.div` - display: flex; - align-items: center; - gap: 8px; - padding: 10px 20px; - background: #1e1e1e; - border-bottom: 1px solid #2a2a2a; - color: #888; - font-size: 12px; - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; - - svg { - width: 14px; - height: 14px; - flex-shrink: 0; - } - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -`; - -const EditorContainer = styled.div` - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; -`; - -const TextEditor = styled.textarea` - flex: 1; - width: 100%; - padding: 16px 20px; - background: #1a1a1a; - border: none; - color: #e0e0e0; - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; - font-size: 13px; - line-height: 1.6; - resize: none; - outline: none; - tab-size: 2; - - &::placeholder { - color: #555; - } - - &:focus { - background: #1c1c1c; - } -`; - -const StatusBar = styled.div<{ $type?: "success" | "error" | "info" }>` - display: flex; - align-items: center; - gap: 8px; - padding: 10px 20px; - border-top: 1px solid #2a2a2a; - background: #1e1e1e; - font-size: 12px; - - ${({ $type }) => { - switch ($type) { - case "success": - return `color: #58c142;`; - case "error": - return `color: #ff6464;`; - default: - return `color: #888;`; - } - }} - - svg { - width: 14px; - height: 14px; - } -`; - -const ModalFooter = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 20px; - border-top: 1px solid #333; - background: #222; -`; - -const FooterInfo = styled.div` - font-size: 12px; - color: #666; -`; - -const FooterActions = styled.div` - display: flex; - align-items: center; - gap: 10px; -`; - -const Button = styled.button<{ $variant?: "default" | "primary" }>` - display: flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - border: none; - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - - ${({ $variant }) => - $variant === "primary" - ? ` - background: #58c142; - color: white; - &:hover { background: #4aa838; } - &:disabled { background: #2a2a2a; color: #666; cursor: not-allowed; } - ` - : ` - background: #333; - color: #e0e0e0; - &:hover { background: #404040; } - `} - - svg { - width: 14px; - height: 14px; - } -`; - -const HelpText = styled.div` - padding: 16px 20px; - background: #1e1e1e; - border-bottom: 1px solid #2a2a2a; - font-size: 12px; - color: #888; - line-height: 1.5; - - code { - background: #333; - padding: 2px 6px; - border-radius: 4px; - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; - color: #58c142; - } -`; - -// ============================================================================ -// 主组件 -// ============================================================================ - -interface ConnectionsEditorModalProps { - /** 是否打开 */ - isOpen: boolean; - /** 关闭回调 */ - onClose: () => void; - /** 保存成功回调 */ - onSaved?: () => void; -} - -export function ConnectionsEditorModal({ - isOpen, - onClose, - onSaved, -}: ConnectionsEditorModalProps) { - const [content, setContent] = useState(""); - const [originalContent, setOriginalContent] = useState(""); - const [configPath, setConfigPath] = useState(""); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [status, setStatus] = useState<{ - type: "success" | "error" | "info"; - message: string; - } | null>(null); - - // 加载配置 - const loadConfig = useCallback(async () => { - setLoading(true); - setStatus(null); - try { - const [rawConfig, path] = await Promise.all([ - getRawConnectionConfig(), - getConnectionConfigPath(), - ]); - setContent(rawConfig); - setOriginalContent(rawConfig); - setConfigPath(path); - } catch (err) { - console.error("[ConnectionsEditorModal] 加载配置失败:", err); - setStatus({ - type: "error", - message: `加载配置失败: ${err instanceof Error ? err.message : String(err)}`, - }); - } finally { - setLoading(false); - } - }, []); - - // 保存配置 - const handleSave = useCallback(async () => { - setSaving(true); - setStatus(null); - try { - // 验证 JSON 格式 - JSON.parse(content); - - const result = await saveRawConnectionConfig(content); - if (result.success) { - setOriginalContent(content); - setStatus({ type: "success", message: "配置已保存" }); - onSaved?.(); - } else { - setStatus({ - type: "error", - message: result.error || "保存失败", - }); - } - } catch (err) { - if (err instanceof SyntaxError) { - setStatus({ - type: "error", - message: `JSON 格式错误: ${err.message}`, - }); - } else { - setStatus({ - type: "error", - message: `保存失败: ${err instanceof Error ? err.message : String(err)}`, - }); - } - } finally { - setSaving(false); - } - }, [content, onSaved]); - - // 首次打开时加载 - useEffect(() => { - if (isOpen) { - loadConfig(); - } - }, [isOpen, loadConfig]); - - // ESC 键关闭 - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - // Ctrl/Cmd + S 保存 - if ((e.ctrlKey || e.metaKey) && e.key === "s") { - e.preventDefault(); - handleSave(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isOpen, onClose, handleSave]); - - // 检查是否有未保存的更改 - const hasChanges = content !== originalContent; - - // 格式化 JSON - const formatJson = () => { - try { - const parsed = JSON.parse(content); - setContent(JSON.stringify(parsed, null, 2)); - setStatus({ type: "info", message: "JSON 已格式化" }); - } catch { - setStatus({ type: "error", message: "JSON 格式错误,无法格式化" }); - } - }; - - if (!isOpen) return null; - - return createPortal( - e.target === e.currentTarget && onClose()}> - e.stopPropagation()}> - - - - Wave Config - connections.json - - - - - - - - - - - - - - - {configPath || "加载中..."} - - - - 配置 SSH 连接。格式:{" "} - {`"connection_name": { "type": "ssh", "user": "root", "host": "192.168.1.1", "port": 22 }`} - - - - {loading ? ( -
加载中...
- ) : ( - setContent(e.target.value)} - placeholder='{\n "connections": {\n "my-server": {\n "type": "ssh",\n "user": "root",\n "host": "192.168.1.100",\n "port": 22\n }\n }\n}' - spellCheck={false} - /> - )} -
- - {status && ( - - {status.type === "success" ? ( - - ) : status.type === "error" ? ( - - ) : null} - {status.message} - - )} -
- - - - {hasChanges ? "有未保存的更改" : "无更改"} - {" | "}按 Ctrl+S 保存 - - - - - - - -
-
, - document.body, - ); -} - -export default ConnectionsEditorModal; diff --git a/src/components/terminal/MultiInputIndicator.tsx b/src/components/terminal/MultiInputIndicator.tsx deleted file mode 100644 index b199051fe..000000000 --- a/src/components/terminal/MultiInputIndicator.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @file MultiInputIndicator.tsx - * @description 多输入模式指示器组件 - * @module components/terminal/MultiInputIndicator - * - * 显示多输入模式状态,允许用户切换多输入模式。 - * - * _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_ - */ - -import React from "react"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -/** 组件属性 */ -export interface MultiInputIndicatorProps { - /** 切换多输入模式回调 - * _Requirements: 10.4_ - */ - onToggle?: () => void; -} - -// ============================================================================ -// 图标组件 -// ============================================================================ - -/** 多输入图标 */ -const MultiInputIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - - - - -); - -// ============================================================================ -// 主组件 -// ============================================================================ - -/** - * 多输入模式指示器组件 - * - * 当多输入模式启用时显示,点击可禁用多输入模式。 - * - * _Requirements: 10.3, 10.4_ - */ -export const MultiInputIndicator: React.FC = ({ - onToggle, -}) => { - return ( -
- -
- ); -}; - -export default MultiInputIndicator; diff --git a/src/components/terminal/README.md b/src/components/terminal/README.md deleted file mode 100644 index ff42c9685..000000000 --- a/src/components/terminal/README.md +++ /dev/null @@ -1,125 +0,0 @@ -# terminal - - - -## 架构说明 - -内置终端组件,采用**后端预创建 PTY**架构(参考 WaveTerm)。 - -**核心原则:** - -- 后端是会话的唯一真相来源 -- PTY 在后端预创建,使用默认大小 (24x80) -- 前端只是"连接"到会话,不负责创建 -- resize 只是同步大小,不触发创建 - -**数据流:** - -``` -[用户点击新建终端] - ↓ -[前端调用 terminal_create_session] - ↓ -[后端创建 PTY(默认 24x80)] - ↓ -[返回 session_id 给前端] - ↓ -[前端创建 TermWrap,连接到 session_id] - ↓ -[TermWrap 初始化 xterm,监听事件] - ↓ -[首次 fit 后,同步实际大小到后端] -``` - -## 核心功能 - -- **PTY 会话管理**: 后端预创建,前端连接 -- **实时输入输出**: 通过 Tauri Events 实现 -- **自适应大小**: 自动调整终端尺寸,同步到后端(防抖处理) -- **xterm.js 渲染**: 高性能终端渲染 -- **WebGL 渲染**: 可选的 WebGL 加速渲染(默认启用) -- **Unicode 11 支持**: 宽字符正确显示 -- **多标签页**: 支持多个终端会话 -- **终端搜索**: 支持正则、大小写、全词匹配 -- **主题切换**: 多种预设主题 -- **IME 支持**: 正确处理输入法组合状态 -- **连接状态显示**: 显示连接状态指示器和重连按钮 -- **上下文菜单**: 右键菜单支持复制、粘贴、URL 打开 -- **多输入模式**: 同时向多个终端发送输入 -- **Jotai 状态管理**: 使用 TermViewModel 管理终端状态 -- **VDOM 模式**: 支持在终端内嵌入 React 组件 -- **贴纸系统**: 支持在终端上显示可定位的贴纸标注 -- **分块布局**: 支持在主终端旁添加附加面板(对齐 Waveterm TileLayout) -- **右侧小部件栏**: 快速添加 Terminal/Files/Web/Sysinfo/AI 面板 -- **Terminal AI**: 内置 AI 助手,支持终端上下文理解 - -## 文件索引 - -- `index.ts` - 模块导出 -- `TerminalWorkspace.tsx` - 终端工作区组件(分块布局 + 小部件栏 + AI 面板) -- `TerminalPanel.tsx` - 独立终端面板组件(用于分块布局) -- `TerminalView.tsx` - 终端视图组件(使用 Jotai 原子状态) -- `TerminalSearch.tsx` - 终端搜索组件 -- `TerminalContextMenu.tsx` - 终端上下文菜单组件 -- `ConnectionStatusIndicator.tsx` - 连接状态指示器组件 -- `MultiInputIndicator.tsx` - 多输入模式指示器组件 -- `VDomModeSwitch.tsx` - VDOM 模式切换组件 -- `VDomView.tsx` - VDOM 视图组件 -- `SubBlock.tsx` - VDOM 子块组件 -- `Sticker.tsx` - 终端贴纸组件 -- `StickerLayer.tsx` - 终端贴纸层组件 -- `termwrap.ts` - 终端封装类(连接模式,WebGL/Unicode11 支持) -- `fitaddon.ts` - 自定义 FitAddon -- `terminal.css` - 终端样式(Tokyo Night 主题) -- `ai/` - Terminal AI 模块(AI 助手面板) -- `widgets/` - 小部件系统子目录 - -## widgets 子目录 - -小部件系统,包含右侧工具栏和各种面板视图: - -- `types.ts` - 类型定义 -- `constants.ts` - 常量配置 -- `context.ts` - React Context 定义 -- `WidgetContext.tsx` - Provider 组件和状态持久化 -- `useWidgets.ts` - 小部件相关 Hooks -- `Widget.tsx` - 单个小部件按钮组件 -- `WidgetsSidebar.tsx` - 右侧小部件栏组件 -- `SettingsFloatingMenu.tsx` - 设置浮动菜单 -- `SysinfoView.tsx` - 系统信息监控视图(CPU/内存图表) -- `FileBrowserView.tsx` - 文件浏览器视图 -- `WebView.tsx` - 内嵌浏览器视图 - -## 依赖 - -- `@xterm/xterm` - 终端渲染 -- `@xterm/addon-fit` - 自适应大小 -- `@xterm/addon-web-links` - 链接支持 -- `@xterm/addon-search` - 搜索功能 -- `@xterm/addon-webgl` - WebGL 渲染加速 -- `@xterm/addon-unicode11` - Unicode 11 宽字符支持 -- `@tauri-apps/plugin-shell` - Tauri Shell 插件(URL 打开) -- `@floating-ui/react` - 浮动菜单定位 -- `@observablehq/plot` - 系统信息图表 -- `lucide-react` - 图标 -- `styled-components` - 样式 -- `jotai` - 原子化状态管理 -- `@/lib/api/terminal` - Tauri 终端 API -- `@/lib/terminal/themes` - 终端主题配置 -- `@/lib/terminal/store` - 终端状态管理 -- `@/lib/terminal/vdom` - VDOM 状态管理 -- `@/lib/terminal/stickers` - 贴纸状态管理 - -## 使用方式 - -```tsx -import { TerminalWorkspace } from "@/components/terminal"; - -function App() { - return setCurrentPage(page)} />; -} -``` - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/components/terminal/Sticker.tsx b/src/components/terminal/Sticker.tsx deleted file mode 100644 index 33f5fe175..000000000 --- a/src/components/terminal/Sticker.tsx +++ /dev/null @@ -1,284 +0,0 @@ -/** - * @file Sticker.tsx - * @description 终端贴纸组件 - * @module components/terminal/Sticker - * - * 单个贴纸的渲染组件,支持文本、图标、徽章等内容类型。 - * - * _Requirements: 15.1, 15.2, 15.3, 15.4_ - */ - -import React, { useCallback, useMemo, useState, useRef } from "react"; -import { useSetAtom } from "jotai"; -import { - type Sticker as StickerType, - type TerminalDimensions, - charGridToPixel, - removeStickerAtom, - moveStickerAtom, - DEFAULT_STICKER_STYLE, -} from "@/lib/terminal/stickers"; -import { X, AlertCircle, CheckCircle, Info, AlertTriangle } from "lucide-react"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -export interface StickerProps { - /** 贴纸数据 */ - sticker: StickerType; - /** 终端尺寸信息 */ - dimensions: TerminalDimensions; - /** 点击回调 */ - onClick?: (sticker: StickerType) => void; -} - -// ============================================================================ -// 徽章变体样式 -// ============================================================================ - -const BADGE_VARIANTS = { - default: { - backgroundColor: "rgba(122, 162, 247, 0.9)", - color: "#1a1b26", - }, - success: { - backgroundColor: "rgba(158, 206, 106, 0.9)", - color: "#1a1b26", - }, - warning: { - backgroundColor: "rgba(224, 175, 104, 0.9)", - color: "#1a1b26", - }, - error: { - backgroundColor: "rgba(247, 118, 142, 0.9)", - color: "#1a1b26", - }, - info: { - backgroundColor: "rgba(125, 207, 255, 0.9)", - color: "#1a1b26", - }, -}; - -// ============================================================================ -// 贴纸组件 -// ============================================================================ - -/** - * 终端贴纸组件 - * - * _Requirements: 15.1, 15.2, 15.3, 15.4_ - */ -export const Sticker: React.FC = ({ - sticker, - dimensions, - onClick, -}) => { - const removeSticker = useSetAtom(removeStickerAtom); - const moveSticker = useSetAtom(moveStickerAtom); - - // 拖拽状态 - const [isDragging, setIsDragging] = useState(false); - const dragStartRef = useRef<{ - x: number; - y: number; - row: number; - col: number; - } | null>(null); - - // 计算像素位置 - // _Requirements: 15.2, 15.4_ - const pixelPosition = useMemo( - () => charGridToPixel(sticker.position, dimensions), - [sticker.position, dimensions], - ); - - // 合并样式 - const mergedStyle = useMemo( - () => ({ - ...DEFAULT_STICKER_STYLE, - ...sticker.style, - }), - [sticker.style], - ); - - // 处理关闭 - const handleClose = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - removeSticker({ blockId: sticker.blockId, stickerId: sticker.id }); - }, - [removeSticker, sticker.blockId, sticker.id], - ); - - // 处理点击 - const handleClick = useCallback(() => { - onClick?.(sticker); - }, [onClick, sticker]); - - // 处理拖拽开始 - // _Requirements: 15.2_ - const handleDragStart = useCallback( - (e: React.MouseEvent) => { - if (!sticker.draggable) return; - - e.preventDefault(); - setIsDragging(true); - dragStartRef.current = { - x: e.clientX, - y: e.clientY, - row: sticker.position.row, - col: sticker.position.col, - }; - - // 添加全局事件监听 - const handleMouseMove = (moveEvent: MouseEvent) => { - if (!dragStartRef.current) return; - - const deltaX = moveEvent.clientX - dragStartRef.current.x; - const deltaY = moveEvent.clientY - dragStartRef.current.y; - - const newCol = - dragStartRef.current.col + Math.round(deltaX / dimensions.charWidth); - const newRow = - dragStartRef.current.row + Math.round(deltaY / dimensions.charHeight); - - moveSticker({ - blockId: sticker.blockId, - stickerId: sticker.id, - newPosition: { row: newRow, col: newCol }, - }); - }; - - const handleMouseUp = () => { - setIsDragging(false); - dragStartRef.current = null; - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }, - [sticker, dimensions, moveSticker], - ); - - // 渲染内容 - const renderContent = () => { - switch (sticker.contentType) { - case "text": - return {sticker.text}; - - case "icon": - return renderIcon(); - - case "badge": - return renderBadge(); - - case "custom": - return ( - {sticker.customComponentId} - ); - - default: - return null; - } - }; - - // 渲染图标 - const renderIcon = () => { - if (!sticker.icon) return null; - - const iconProps = { - size: sticker.icon.size ?? 16, - color: sticker.icon.color ?? mergedStyle.color, - }; - - // 简单的图标映射 - switch (sticker.icon.name) { - case "alert-circle": - return ; - case "check-circle": - return ; - case "info": - return ; - case "alert-triangle": - return ; - default: - return ; - } - }; - - // 渲染徽章 - const renderBadge = () => { - if (!sticker.badge) return null; - - const variant = sticker.badge.variant ?? "default"; - const variantStyle = BADGE_VARIANTS[variant]; - - return ( - - {sticker.badge.text} - - ); - }; - - return ( -
- {/* 内容 */} -
{renderContent()}
- - {/* 关闭按钮 */} - {sticker.closable && ( - - )} -
- ); -}; - -export default Sticker; diff --git a/src/components/terminal/StickerLayer.tsx b/src/components/terminal/StickerLayer.tsx deleted file mode 100644 index 87be0a5b2..000000000 --- a/src/components/terminal/StickerLayer.tsx +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @file StickerLayer.tsx - * @description 终端贴纸层组件 - * @module components/terminal/StickerLayer - * - * 管理和渲染终端上的所有贴纸。 - * 作为覆盖层放置在终端容器上方。 - * - * _Requirements: 15.1, 15.2, 15.3, 15.4_ - */ - -import React, { useEffect, useCallback, useMemo } from "react"; -import { useAtomValue, useSetAtom } from "jotai"; -import { Sticker } from "./Sticker"; -import { - type Sticker as StickerType, - type TerminalDimensions, - getStickersForBlockAtom, - getTerminalDimensionsAtom, - updateTerminalDimensionsAtom, - DEFAULT_TERMINAL_DIMENSIONS, -} from "@/lib/terminal/stickers"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -export interface StickerLayerProps { - /** 块 ID */ - blockId: string; - /** 终端容器引用(用于计算尺寸) */ - terminalRef?: React.RefObject; - /** 字符宽度(像素) */ - charWidth?: number; - /** 字符高度(像素) */ - charHeight?: number; - /** 终端行数 */ - rows?: number; - /** 终端列数 */ - cols?: number; - /** 贴纸点击回调 */ - onStickerClick?: (sticker: StickerType) => void; -} - -// ============================================================================ -// StickerLayer 组件 -// ============================================================================ - -/** - * 终端贴纸层组件 - * - * 渲染指定终端块的所有贴纸。 - * - * _Requirements: 15.1, 15.2, 15.3, 15.4_ - */ -export const StickerLayer: React.FC = ({ - blockId, - terminalRef, - charWidth, - charHeight, - rows, - cols, - onStickerClick, -}) => { - // 获取贴纸列表 - const getStickersForBlock = useAtomValue(getStickersForBlockAtom); - const stickers = useMemo( - () => getStickersForBlock(blockId), - [getStickersForBlock, blockId], - ); - - // 获取终端尺寸 - const getTerminalDimensions = useAtomValue(getTerminalDimensionsAtom); - const storedDimensions = useMemo( - () => getTerminalDimensions(blockId), - [getTerminalDimensions, blockId], - ); - - // 更新终端尺寸 - const updateDimensions = useSetAtom(updateTerminalDimensionsAtom); - - // 计算实际使用的尺寸 - // _Requirements: 15.4_ - const dimensions: TerminalDimensions = useMemo(() => { - // 优先使用 props 传入的值 - if (charWidth && charHeight && rows && cols) { - return { - charWidth, - charHeight, - rows, - cols, - paddingLeft: DEFAULT_TERMINAL_DIMENSIONS.paddingLeft, - paddingTop: DEFAULT_TERMINAL_DIMENSIONS.paddingTop, - }; - } - // 否则使用存储的值 - return storedDimensions; - }, [charWidth, charHeight, rows, cols, storedDimensions]); - - // 从终端容器计算尺寸 - // _Requirements: 15.4_ - useEffect(() => { - if (!terminalRef?.current) return; - - const calculateDimensions = () => { - const container = terminalRef.current; - if (!container) return; - - // 尝试从 xterm 获取尺寸信息 - const xtermScreen = container.querySelector(".xterm-screen"); - const xtermRows = container.querySelector(".xterm-rows"); - - if (xtermScreen && xtermRows) { - // 获取第一个字符单元格来计算字符尺寸 - const firstRow = xtermRows.querySelector(".xterm-row"); - if (firstRow) { - const firstChar = firstRow.querySelector("span"); - if (firstChar) { - const charRect = firstChar.getBoundingClientRect(); - const newDimensions: TerminalDimensions = { - charWidth: - charRect.width || DEFAULT_TERMINAL_DIMENSIONS.charWidth, - charHeight: - charRect.height || DEFAULT_TERMINAL_DIMENSIONS.charHeight, - rows: - rows ?? - Math.floor(xtermScreen.clientHeight / (charRect.height || 17)), - cols: - cols ?? - Math.floor(xtermScreen.clientWidth / (charRect.width || 8)), - paddingLeft: DEFAULT_TERMINAL_DIMENSIONS.paddingLeft, - paddingTop: DEFAULT_TERMINAL_DIMENSIONS.paddingTop, - }; - - updateDimensions({ blockId, dimensions: newDimensions }); - } - } - } - }; - - // 初始计算 - calculateDimensions(); - - // 监听大小变化 - const resizeObserver = new ResizeObserver(() => { - calculateDimensions(); - }); - - resizeObserver.observe(terminalRef.current); - - return () => { - resizeObserver.disconnect(); - }; - }, [terminalRef, blockId, rows, cols, updateDimensions]); - - // 处理贴纸点击 - const handleStickerClick = useCallback( - (sticker: StickerType) => { - onStickerClick?.(sticker); - }, - [onStickerClick], - ); - - // 如果没有贴纸,不渲染任何内容 - if (stickers.length === 0) { - return null; - } - - return ( -
- {stickers.map((sticker) => ( -
- -
- ))} -
- ); -}; - -export default StickerLayer; diff --git a/src/components/terminal/SubBlock.tsx b/src/components/terminal/SubBlock.tsx deleted file mode 100644 index 9a221901d..000000000 --- a/src/components/terminal/SubBlock.tsx +++ /dev/null @@ -1,585 +0,0 @@ -/** - * @file SubBlock.tsx - * @description VDOM 子块组件 - * @module components/terminal/SubBlock - * - * 渲染终端内嵌的 VDOM 块。 - * - * _Requirements: 14.3, 14.4, 14.5_ - */ - -import React, { useCallback, useRef, useEffect, useState } from "react"; -import { useSetAtom } from "jotai"; -import { - type VDomBlock, - type VDomContext, - removeVDomBlockAtom, - setVDomBlockFocusAtom, - updateVDomBlockAtom, -} from "@/lib/terminal/vdom"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -export interface SubBlockProps { - /** VDOM 块实例 */ - block: VDomBlock; - /** 终端块 ID */ - terminalBlockId: string; - /** 标签页 ID */ - tabId: string; - /** VDOM 上下文 */ - context: VDomContext; - /** 块索引(用于键盘导航) */ - index?: number; - /** 总块数(用于键盘导航) */ - totalBlocks?: number; - /** 导航到上一个块 */ - onNavigatePrev?: () => void; - /** 导航到下一个块 */ - onNavigateNext?: () => void; - /** 自定义类名 */ - className?: string; -} - -// ============================================================================ -// 图标组件 -// ============================================================================ - -const CloseIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -// ============================================================================ -// 内置 VDOM 组件注册表 -// ============================================================================ - -/** - * 内置 VDOM 组件 - * - * 可以通过 component 名称引用这些组件。 - */ -const builtinComponents: Record< - string, - React.FC<{ block: VDomBlock; context: VDomContext }> -> = { - // 占位符组件 - placeholder: ({ block }) => ( -
-

VDOM 块: {block.config.id}

-

组件: {block.config.component}

-
- ), - - // 加载中组件 - loading: () => ( -
-
- 加载中... -
- ), - - // 错误组件 - error: ({ block }) => ( -
- ⚠️ - {block.error ?? "发生错误"} -
- ), - - // 示例:信息卡片组件 - infoCard: ({ block }) => ( -
-

{(block.config.props?.title as string) ?? "信息"}

-

{(block.config.props?.content as string) ?? "无内容"}

-
- ), - - // 示例:按钮组组件 - buttonGroup: ({ block, context }) => { - const buttons = - (block.config.props?.buttons as Array<{ - label: string; - action: string; - }>) ?? []; - return ( -
- {buttons.map((btn, idx) => ( - - ))} -
- ); - }, -}; - -/** - * 自定义组件注册表 - * - * 允许外部注册自定义 VDOM 组件。 - */ -const customComponents: Map< - string, - React.FC<{ block: VDomBlock; context: VDomContext }> -> = new Map(); - -/** - * 注册自定义 VDOM 组件 - */ -// eslint-disable-next-line react-refresh/only-export-components -export function registerVDomComponent( - name: string, - component: React.FC<{ block: VDomBlock; context: VDomContext }>, -): void { - customComponents.set(name, component); -} - -/** - * 注销自定义 VDOM 组件 - */ -// eslint-disable-next-line react-refresh/only-export-components -export function unregisterVDomComponent(name: string): void { - customComponents.delete(name); -} - -/** - * 获取 VDOM 组件 - */ -function getVDomComponent( - componentName: string, -): React.FC<{ block: VDomBlock; context: VDomContext }> | null { - // 优先查找自定义组件 - const custom = customComponents.get(componentName); - if (custom) return custom; - - // 然后查找内置组件 - return builtinComponents[componentName] ?? null; -} - -// ============================================================================ -// 焦点管理工具函数 -// _Requirements: 14.4_ -// ============================================================================ - -/** - * 获取元素内所有可聚焦元素 - */ -function getFocusableElements(container: HTMLElement): HTMLElement[] { - const focusableSelectors = [ - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "a[href]", - '[tabindex]:not([tabindex="-1"])', - ].join(", "); - - return Array.from( - container.querySelectorAll(focusableSelectors), - ); -} - -/** - * 焦点陷阱 Hook - * - * 将焦点限制在容器内,支持 Tab 键循环导航。 - */ -function useFocusTrap( - containerRef: React.RefObject, - enabled: boolean, -) { - useEffect(() => { - if (!enabled || !containerRef.current) return; - - const container = containerRef.current; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Tab") return; - - const focusableElements = getFocusableElements(container); - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (e.shiftKey) { - // Shift + Tab:向后导航 - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement.focus(); - } - } else { - // Tab:向前导航 - if (document.activeElement === lastElement) { - e.preventDefault(); - firstElement.focus(); - } - } - }; - - container.addEventListener("keydown", handleKeyDown); - return () => container.removeEventListener("keydown", handleKeyDown); - }, [containerRef, enabled]); -} - -// ============================================================================ -// SubBlock 组件 -// ============================================================================ - -/** - * VDOM 子块组件 - * - * 渲染单个 VDOM 块,支持焦点管理和关闭操作。 - * - * _Requirements: 14.3, 14.4, 14.5_ - */ -export const SubBlock: React.FC = ({ - block, - terminalBlockId, - tabId: _tabId, - context, - index = 0, - totalBlocks = 1, - onNavigatePrev, - onNavigateNext, - className = "", -}) => { - const containerRef = useRef(null); - const [isFocusTrapEnabled, setIsFocusTrapEnabled] = useState(false); - - // 操作原子 - const removeBlock = useSetAtom(removeVDomBlockAtom); - const setBlockFocus = useSetAtom(setVDomBlockFocusAtom); - const updateBlock = useSetAtom(updateVDomBlockAtom); - - // 启用焦点陷阱 - useFocusTrap(containerRef, isFocusTrapEnabled && block.focused); - - // 处理关闭 - // _Requirements: 14.5_ - const handleClose = useCallback(() => { - if (!block.config.closable) return; - - removeBlock({ terminalBlockId, blockId: block.config.id }); - context.closeBlock(block.config.id); - }, [ - block.config.id, - block.config.closable, - terminalBlockId, - removeBlock, - context, - ]); - - // 处理聚焦 - // _Requirements: 14.4_ - const handleFocus = useCallback(() => { - setBlockFocus({ terminalBlockId, blockId: block.config.id }); - }, [block.config.id, terminalBlockId, setBlockFocus]); - - // 处理失焦 - const handleBlur = useCallback( - (e: React.FocusEvent) => { - // 检查焦点是否移出了块 - if (!containerRef.current?.contains(e.relatedTarget as Node)) { - setBlockFocus({ terminalBlockId, blockId: null }); - } - }, - [terminalBlockId, setBlockFocus], - ); - - // 键盘事件处理 - // _Requirements: 14.4_ - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case "Escape": - // Escape 键关闭块 - if (block.config.closable) { - e.preventDefault(); - handleClose(); - } - break; - - case "ArrowUp": - case "ArrowLeft": - // 向上/左导航到上一个块 - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - onNavigatePrev?.(); - } - break; - - case "ArrowDown": - case "ArrowRight": - // 向下/右导航到下一个块 - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - onNavigateNext?.(); - } - break; - - case "f": - // Ctrl/Cmd + F 启用焦点陷阱 - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - setIsFocusTrapEnabled((prev) => !prev); - } - break; - } - }, - [block.config.closable, handleClose, onNavigatePrev, onNavigateNext], - ); - - // 块加载完成后更新状态 - useEffect(() => { - if (block.status === "loading") { - // 模拟加载完成 - const timer = setTimeout(() => { - updateBlock({ - terminalBlockId, - blockId: block.config.id, - updates: { status: "ready" }, - }); - }, 100); - return () => clearTimeout(timer); - } - }, [block.config.id, block.status, terminalBlockId, updateBlock]); - - // 聚焦时自动滚动到视图 - useEffect(() => { - if (block.focused && containerRef.current) { - containerRef.current.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - } - }, [block.focused]); - - // 获取要渲染的组件 - const Component = getVDomComponent(block.config.component); - - // 计算样式 - const style: React.CSSProperties = {}; - if (block.config.position) { - const { top, left, bottom, right } = block.config.position; - if (top !== undefined) style.top = top; - if (left !== undefined) style.left = left; - if (bottom !== undefined) style.bottom = bottom; - if (right !== undefined) style.right = right; - } - if (block.config.size) { - const { width, height, minWidth, minHeight, maxWidth, maxHeight } = - block.config.size; - if (width !== undefined) style.width = width; - if (height !== undefined) style.height = height; - if (minWidth !== undefined) style.minWidth = minWidth; - if (minHeight !== undefined) style.minHeight = minHeight; - if (maxWidth !== undefined) style.maxWidth = maxWidth; - if (maxHeight !== undefined) style.maxHeight = maxHeight; - } - - return ( -
- {/* 块头部 */} - {(block.config.title || block.config.closable) && ( -
- {block.config.title && ( - {block.config.title} - )} -
- {/* 焦点陷阱指示器 */} - {isFocusTrapEnabled && ( - - 🔒 - - )} - {/* 块索引指示器 */} - {totalBlocks > 1 && ( - - {index + 1}/{totalBlocks} - - )} - {block.config.closable && ( - - )} -
-
- )} - - {/* 块内容 */} -
- {block.status === "loading" && ( -
-
-
- )} - {block.status === "error" && ( -
- ⚠️ - {block.error ?? "发生错误"} -
- )} - {block.status === "ready" && Component && ( - - )} - {block.status === "ready" && !Component && ( -
-

未找到组件: {block.config.component}

-
- )} -
-
- ); -}; - -// ============================================================================ -// SubBlockContainer 组件 -// ============================================================================ - -export interface SubBlockContainerProps { - /** VDOM 块列表 */ - blocks: VDomBlock[]; - /** 终端块 ID */ - terminalBlockId: string; - /** 标签页 ID */ - tabId: string; - /** VDOM 上下文 */ - context: VDomContext; - /** 自定义类名 */ - className?: string; -} - -/** - * VDOM 子块容器 - * - * 渲染多个 VDOM 块,支持键盘导航。 - * - * _Requirements: 14.3, 14.4_ - */ -export const SubBlockContainer: React.FC = ({ - blocks, - terminalBlockId, - tabId, - context, - className = "", -}) => { - const containerRef = useRef(null); - const setBlockFocus = useSetAtom(setVDomBlockFocusAtom); - - // 导航到指定索引的块 - const navigateToBlock = useCallback( - (index: number) => { - if (index < 0 || index >= blocks.length) return; - - const targetBlock = blocks[index]; - setBlockFocus({ terminalBlockId, blockId: targetBlock.config.id }); - - // 聚焦对应的 DOM 元素 - const blockElement = containerRef.current?.querySelector( - `[data-block-index="${index}"]`, - ) as HTMLElement | null; - blockElement?.focus(); - }, - [blocks, terminalBlockId, setBlockFocus], - ); - - // 获取当前聚焦块的索引 - const _getFocusedIndex = useCallback(() => { - return blocks.findIndex((b) => b.focused); - }, [blocks]); - - // 导航到上一个块 - const handleNavigatePrev = useCallback( - (currentIndex: number) => { - const prevIndex = currentIndex > 0 ? currentIndex - 1 : blocks.length - 1; - navigateToBlock(prevIndex); - }, - [blocks.length, navigateToBlock], - ); - - // 导航到下一个块 - const handleNavigateNext = useCallback( - (currentIndex: number) => { - const nextIndex = currentIndex < blocks.length - 1 ? currentIndex + 1 : 0; - navigateToBlock(nextIndex); - }, - [blocks.length, navigateToBlock], - ); - - if (blocks.length === 0) { - return null; - } - - return ( -
- {blocks.map((block, index) => ( - handleNavigatePrev(index)} - onNavigateNext={() => handleNavigateNext(index)} - /> - ))} -
- ); -}; - -export default SubBlock; diff --git a/src/components/terminal/TerminalContextMenu.tsx b/src/components/terminal/TerminalContextMenu.tsx deleted file mode 100644 index fb5e11cc8..000000000 --- a/src/components/terminal/TerminalContextMenu.tsx +++ /dev/null @@ -1,371 +0,0 @@ -/** - * @file TerminalContextMenu.tsx - * @description 终端上下文菜单组件 - * @module components/terminal/TerminalContextMenu - * - * 提供终端右键菜单功能,包括复制、粘贴、URL 打开等。 - * - * _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_ - */ - -import React, { useEffect, useRef, useCallback, useMemo } from "react"; -import { open } from "@tauri-apps/plugin-shell"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -/** 菜单位置 */ -export interface ContextMenuPosition { - x: number; - y: number; -} - -/** 组件属性 */ -export interface TerminalContextMenuProps { - /** 菜单位置 */ - position: ContextMenuPosition; - /** 关闭回调 */ - onClose: () => void; - /** 复制回调 - * _Requirements: 13.2_ - */ - onCopy: () => void; - /** 粘贴回调 - * _Requirements: 13.2_ - */ - onPaste: () => void; - /** 选中的文本 */ - selectedText: string; - /** 块 ID */ - blockId: string; -} - -/** 菜单项 */ -interface MenuItem { - id: string; - label: string; - icon?: React.ReactNode; - shortcut?: string; - disabled?: boolean; - onClick: () => void; - divider?: boolean; -} - -// ============================================================================ -// 图标组件 -// ============================================================================ - -/** 复制图标 */ -const CopyIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 粘贴图标 */ -const PasteIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 链接图标 */ -const LinkIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 清空图标 */ -const ClearIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 全选图标 */ -const SelectAllIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - - - -); - -// ============================================================================ -// 工具函数 -// ============================================================================ - -/** - * 检测文本是否为 URL - * - * _Requirements: 13.4_ - */ -function detectUrl(text: string): string | null { - const trimmed = text.trim(); - - // URL 正则表达式 - const urlPattern = - /^(https?:\/\/|ftp:\/\/|file:\/\/)?[\w-]+(\.[\w-]+)+([\w-.,@?^=%&:/~+#]*[\w-@?^=%&/~+#])?$/i; - - if (urlPattern.test(trimmed)) { - // 如果没有协议,添加 https:// - if (!/^(https?|ftp|file):\/\//i.test(trimmed)) { - return `https://${trimmed}`; - } - return trimmed; - } - - return null; -} - -// ============================================================================ -// 主组件 -// ============================================================================ - -/** - * 终端上下文菜单组件 - * - * _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_ - */ -export const TerminalContextMenu: React.FC = ({ - position, - onClose, - onCopy, - onPaste, - selectedText, - blockId: _blockId, -}) => { - const menuRef = useRef(null); - - // 检测选中文本是否为 URL - // _Requirements: 13.4_ - const detectedUrl = useMemo( - () => (selectedText ? detectUrl(selectedText) : null), - [selectedText], - ); - - // 点击外部关闭菜单 - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } - }; - - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleEscape); - }; - }, [onClose]); - - // 调整菜单位置,确保不超出视口 - useEffect(() => { - if (menuRef.current) { - const menu = menuRef.current; - const rect = menu.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let x = position.x; - let y = position.y; - - // 右边界检查 - if (x + rect.width > viewportWidth) { - x = viewportWidth - rect.width - 8; - } - - // 下边界检查 - if (y + rect.height > viewportHeight) { - y = viewportHeight - rect.height - 8; - } - - menu.style.left = `${x}px`; - menu.style.top = `${y}px`; - } - }, [position]); - - // 打开 URL - // _Requirements: 13.4_ - const handleOpenUrl = useCallback(async () => { - if (detectedUrl) { - try { - await open(detectedUrl); - } catch (err) { - console.error("[TerminalContextMenu] 打开 URL 失败:", err); - } - } - onClose(); - }, [detectedUrl, onClose]); - - // 构建菜单项 - const menuItems: MenuItem[] = useMemo(() => { - const items: MenuItem[] = []; - - // 复制 - // _Requirements: 13.2_ - items.push({ - id: "copy", - label: "复制", - icon: , - shortcut: "⌘C", - disabled: !selectedText, - onClick: onCopy, - }); - - // 粘贴 - // _Requirements: 13.2_ - items.push({ - id: "paste", - label: "粘贴", - icon: , - shortcut: "⌘V", - onClick: onPaste, - }); - - // 分隔线 - items.push({ - id: "divider-1", - label: "", - onClick: () => {}, - divider: true, - }); - - // 打开 URL(如果选中的是 URL) - // _Requirements: 13.4_ - if (detectedUrl) { - items.push({ - id: "open-url", - label: "打开链接", - icon: , - onClick: handleOpenUrl, - }); - - items.push({ - id: "divider-2", - label: "", - onClick: () => {}, - divider: true, - }); - } - - // 全选 - items.push({ - id: "select-all", - label: "全选", - icon: , - shortcut: "⌘A", - onClick: () => { - // TODO: 实现全选功能 - onClose(); - }, - }); - - // 清空终端 - items.push({ - id: "clear", - label: "清空终端", - icon: , - shortcut: "⌘K", - onClick: () => { - // TODO: 实现清空终端功能 - onClose(); - }, - }); - - return items; - }, [selectedText, detectedUrl, onCopy, onPaste, handleOpenUrl, onClose]); - - return ( -
- {menuItems.map((item) => - item.divider ? ( -
- ) : ( - - ), - )} -
- ); -}; - -export default TerminalContextMenu; diff --git a/src/components/terminal/TerminalPanel.tsx b/src/components/terminal/TerminalPanel.tsx deleted file mode 100644 index 151d67f7f..000000000 --- a/src/components/terminal/TerminalPanel.tsx +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @file TerminalPanel.tsx - * @description 独立终端面板组件 - 用于分块布局中的附加终端 - * @module components/terminal/TerminalPanel - * - * 简化版终端组件,用于在分块布局中显示独立的终端实例。 - * 每个面板有自己的会话 ID 和 TermWrap 实例。 - * 借鉴 Waveterm 的右键菜单功能。 - */ - -import React, { useEffect, useRef, useCallback, useState } from "react"; -import "@xterm/xterm/css/xterm.css"; -import { - createTerminalSession, - closeTerminal, - type SessionStatus, -} from "@/lib/api/terminal"; -import { TermWrap } from "./termwrap"; -import { - loadThemePreference, - loadFontSizePreference, - saveFontSizePreference, - saveThemePreference, - getTheme, - type ThemeName, -} from "@/lib/terminal/themes"; -import { - TerminalContextMenu, - type ContextMenuPosition, -} from "./widgets/TerminalContextMenu"; -import { resolveTerminalPageHotkeyAction } from "./terminalPageHotkeys"; -import "./terminal.css"; - -interface TerminalPanelProps { - /** 面板 ID */ - panelId: string; - /** 工作目录(可选) */ - cwd?: string; - /** 会话创建完成回调 */ - onSessionCreated?: (sessionId: string) => void; - /** 状态变化回调 */ - onStatusChange?: (status: SessionStatus) => void; - /** 水平分割回调 */ - onSplitHorizontal?: () => void; - /** 垂直分割回调 */ - onSplitVertical?: () => void; -} - -/** - * 独立终端面板组件 - */ -export function TerminalPanel({ - panelId, - cwd, - onSessionCreated, - onStatusChange, - onSplitHorizontal, - onSplitVertical, -}: TerminalPanelProps) { - const [sessionId, setSessionId] = useState(null); - const [error, setError] = useState(null); - const [isCreating, setIsCreating] = useState(false); - - // 右键菜单状态 - const [contextMenu, setContextMenu] = useState<{ - position: ContextMenuPosition; - } | null>(null); - - // 终端设置状态 - const [fontSize, setFontSize] = useState(loadFontSizePreference()); - const [themeName, setThemeName] = useState(loadThemePreference()); - - const connectElemRef = useRef(null); - const termWrapRef = useRef(null); - - // 创建终端会话 - const createSession = useCallback(async () => { - if (isCreating || sessionId) return; - - setIsCreating(true); - setError(null); - - try { - const newSessionId = await createTerminalSession(cwd); - console.log( - `[TerminalPanel ${panelId}] 会话已创建:`, - newSessionId, - cwd ? `(cwd: ${cwd})` : "", - ); - setSessionId(newSessionId); - onSessionCreated?.(newSessionId); - } catch (err) { - console.error(`[TerminalPanel ${panelId}] 创建终端失败:`, err); - setError("创建终端会话失败"); - } finally { - setIsCreating(false); - } - }, [isCreating, sessionId, panelId, cwd, onSessionCreated]); - - // 首次挂载时创建会话 - useEffect(() => { - createSession(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // 键盘事件处理器 - const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => { - if (e.type !== "keydown") return true; - - const termWrap = termWrapRef.current; - if (!termWrap) return true; - - const action = resolveTerminalPageHotkeyAction(e); - - if (action === "scroll-to-bottom") { - termWrap.terminal.scrollToBottom(); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (action === "scroll-to-top") { - termWrap.terminal.scrollToLine(0); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (action === "scroll-page-down") { - termWrap.terminal.scrollPages(1); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (action === "scroll-page-up") { - termWrap.terminal.scrollPages(-1); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - return true; - }, []); - - // 使用 ref 存储回调,避免回调变化导致终端重建 - const onStatusChangeRef = useRef(onStatusChange); - useEffect(() => { - onStatusChangeRef.current = onStatusChange; - }, [onStatusChange]); - - // 当 sessionId 变化时,创建 TermWrap - // 注意:只依赖 sessionId 和 handleTerminalKeydown,避免回调变化导致终端重建 - useEffect(() => { - const container = connectElemRef.current; - if (!container || !sessionId) { - if (termWrapRef.current) { - termWrapRef.current.dispose(); - termWrapRef.current = null; - } - return; - } - - // 如果已有 TermWrap 且 sessionId 相同,不重建 - if (termWrapRef.current) { - return; - } - - // 清空容器 - container.innerHTML = ""; - - // 创建新的 TermWrap - const termWrap = new TermWrap(sessionId, container, { - onStatusChange: (status) => onStatusChangeRef.current?.(status), - themeName: loadThemePreference(), - fontSize: loadFontSizePreference(), - keydownHandler: handleTerminalKeydown, - }); - - termWrapRef.current = termWrap; - - // 设置 ResizeObserver - const rszObs = new ResizeObserver(() => { - termWrap.handleResize_debounced(); - }); - rszObs.observe(container); - - // 异步初始化终端 - termWrap.initTerminal().catch(console.error); - - // 自动聚焦 - setTimeout(() => termWrap.focus(), 10); - - return () => { - termWrap.dispose(); - rszObs.disconnect(); - }; - }, [sessionId, handleTerminalKeydown]); - - // 组件卸载时关闭会话 - useEffect(() => { - return () => { - if (sessionId) { - closeTerminal(sessionId).catch(console.error); - } - }; - }, [sessionId]); - - // 右键菜单处理 - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ - position: { x: e.clientX, y: e.clientY }, - }); - }, []); - - // 关闭右键菜单 - const closeContextMenu = useCallback(() => { - setContextMenu(null); - }, []); - - // 复制选中文本 - const handleCopy = useCallback(() => { - const selection = termWrapRef.current?.terminal?.getSelection(); - if (selection) { - navigator.clipboard.writeText(selection); - } - }, []); - - // 粘贴 - const handlePaste = useCallback(async () => { - try { - const text = await navigator.clipboard.readText(); - if (text && termWrapRef.current) { - // 通过终端发送粘贴的文本 - termWrapRef.current.sendData(text); - } - } catch (e) { - console.error("[TerminalPanel] 粘贴失败:", e); - } - }, []); - - // 清屏 - const handleClear = useCallback(() => { - termWrapRef.current?.terminal?.clear(); - }, []); - - // 获取选中文本 - const getSelection = useCallback(() => { - return termWrapRef.current?.terminal?.getSelection() || null; - }, []); - - // 检查是否有选中文本 - const hasSelection = useCallback(() => { - return termWrapRef.current?.terminal?.hasSelection() || false; - }, []); - - // 字体大小变化 - const handleFontSizeChange = useCallback((size: number) => { - setFontSize(size); - saveFontSizePreference(size); - if (termWrapRef.current?.terminal) { - termWrapRef.current.terminal.options.fontSize = size; - termWrapRef.current.handleResize_debounced(); - } - }, []); - - // 主题变化 - const handleThemeChange = useCallback((theme: ThemeName) => { - setThemeName(theme); - saveThemePreference(theme); - if (termWrapRef.current?.terminal) { - const themeConfig = getTheme(theme); - termWrapRef.current.terminal.options.theme = themeConfig; - } - }, []); - - if (error) { - return ( -
- {error} -
- ); - } - - if (isCreating || !sessionId) { - return ( -
- 正在创建终端... -
- ); - } - - return ( -
termWrapRef.current?.focus()} - onContextMenu={handleContextMenu} - > - {/* 右键菜单 */} - {contextMenu && ( - - )} -
- ); -} - -export default TerminalPanel; diff --git a/src/components/terminal/TerminalSearch.tsx b/src/components/terminal/TerminalSearch.tsx deleted file mode 100644 index 6a1e02bd4..000000000 --- a/src/components/terminal/TerminalSearch.tsx +++ /dev/null @@ -1,334 +0,0 @@ -/** - * @file TerminalSearch.tsx - * @description 终端搜索组件 - * @module components/terminal/TerminalSearch - * - * 提供终端内搜索功能的 UI 组件。 - * 支持正则表达式、大小写敏感、全词匹配等选项。 - * - * _Requirements: 8.3, 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_ - */ - -import React, { useState, useCallback, useRef, useEffect } from "react"; -import type { ISearchOptions } from "@xterm/addon-search"; - -/** 搜索结果信息 */ -export interface SearchResultInfo { - /** 当前匹配索引(从 1 开始) */ - currentIndex: number; - /** 总匹配数 */ - totalCount: number; -} - -/** 搜索组件属性 */ -export interface TerminalSearchProps { - /** 是否显示 */ - visible: boolean; - /** 关闭回调 */ - onClose: () => void; - /** 搜索回调,返回是否找到匹配 */ - onSearch: (term: string, options: ISearchOptions) => boolean; - /** 搜索下一个,返回是否找到匹配 */ - onSearchNext: (term: string, options: ISearchOptions) => boolean; - /** 搜索上一个,返回是否找到匹配 */ - onSearchPrevious: (term: string, options: ISearchOptions) => boolean; - /** 清除搜索 */ - onClearSearch: () => void; - /** 搜索结果信息(可选,用于显示匹配计数) - * _Requirements: 11.4, 11.5_ - */ - searchResultInfo?: SearchResultInfo; -} - -/** 搜索图标 */ -const SearchIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 关闭图标 */ -const CloseIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -/** 上箭头图标 */ -const ChevronUpIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -/** 下箭头图标 */ -const ChevronDownIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - -); - -/** - * 终端搜索组件 - * - * _Requirements: 8.3, 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_ - */ -export const TerminalSearch: React.FC = ({ - visible, - onClose, - onSearch, - onSearchNext, - onSearchPrevious, - onClearSearch, - searchResultInfo, -}) => { - const [searchTerm, setSearchTerm] = useState(""); - // 区分大小写选项 - // _Requirements: 11.2_ - const [caseSensitive, setCaseSensitive] = useState(false); - // 全词匹配选项 - // _Requirements: 11.3_ - const [wholeWord, setWholeWord] = useState(false); - // 正则表达式选项 - // _Requirements: 11.1_ - const [regex, setRegex] = useState(false); - const [hasResults, setHasResults] = useState(null); - const inputRef = useRef(null); - - // 构建搜索选项 - const getSearchOptions = useCallback( - (): ISearchOptions => ({ - caseSensitive, - wholeWord, - regex, - incremental: true, - // 启用装饰器以高亮所有匹配项 - // _Requirements: 11.4_ - decorations: { - matchBackground: "#7aa2f7", - matchBorder: "#7aa2f7", - matchOverviewRuler: "#7aa2f7", - activeMatchBackground: "#ff9e64", - activeMatchBorder: "#ff9e64", - activeMatchColorOverviewRuler: "#ff9e64", - }, - }), - [caseSensitive, wholeWord, regex], - ); - - // 执行搜索 - const doSearch = useCallback(() => { - if (!searchTerm) { - onClearSearch(); - setHasResults(null); - return; - } - const found = onSearch(searchTerm, getSearchOptions()); - setHasResults(found); - }, [searchTerm, getSearchOptions, onSearch, onClearSearch]); - - // 搜索下一个 - // _Requirements: 11.6_ - const handleNext = useCallback(() => { - if (!searchTerm) return; - const found = onSearchNext(searchTerm, getSearchOptions()); - setHasResults(found); - }, [searchTerm, getSearchOptions, onSearchNext]); - - // 搜索上一个 - // _Requirements: 11.6_ - const handlePrevious = useCallback(() => { - if (!searchTerm) return; - const found = onSearchPrevious(searchTerm, getSearchOptions()); - setHasResults(found); - }, [searchTerm, getSearchOptions, onSearchPrevious]); - - // 关闭搜索 - // _Requirements: 11.7_ - const handleClose = useCallback(() => { - onClearSearch(); - setSearchTerm(""); - setHasResults(null); - onClose(); - }, [onClose, onClearSearch]); - - // 键盘事件处理 - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - handleClose(); - } else if (e.key === "Enter") { - if (e.shiftKey) { - handlePrevious(); - } else { - handleNext(); - } - } - // Alt+C 切换大小写敏感 - if (e.altKey && e.key === "c") { - e.preventDefault(); - setCaseSensitive((prev) => !prev); - } - // Alt+W 切换全词匹配 - if (e.altKey && e.key === "w") { - e.preventDefault(); - setWholeWord((prev) => !prev); - } - // Alt+R 切换正则表达式 - if (e.altKey && e.key === "r") { - e.preventDefault(); - setRegex((prev) => !prev); - } - }, - [handleClose, handleNext, handlePrevious], - ); - - // 搜索词变化时自动搜索 - useEffect(() => { - doSearch(); - }, [searchTerm, caseSensitive, wholeWord, regex, doSearch]); - - // 显示时聚焦输入框 - useEffect(() => { - if (visible && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [visible]); - - if (!visible) return null; - - // 渲染搜索结果计数 - // _Requirements: 11.5_ - const renderResultCount = () => { - if (!searchTerm) return null; - - if (hasResults === false) { - return 无结果; - } - - if (searchResultInfo && searchResultInfo.totalCount > 0) { - return ( - - {searchResultInfo.currentIndex} / {searchResultInfo.totalCount} - - ); - } - - return null; - }; - - return ( -
-
- - setSearchTerm(e.target.value)} - onKeyDown={handleKeyDown} - /> - {renderResultCount()} -
- - {/* 搜索选项 - * _Requirements: 11.1, 11.2, 11.3_ - */} -
- - - -
- - {/* 导航按钮 - * _Requirements: 11.6_ - */} -
- - -
- - {/* 关闭按钮 - * _Requirements: 11.7_ - */} - -
- ); -}; - -export default TerminalSearch; diff --git a/src/components/terminal/TerminalView.tsx b/src/components/terminal/TerminalView.tsx deleted file mode 100644 index 11d05c2fb..000000000 --- a/src/components/terminal/TerminalView.tsx +++ /dev/null @@ -1,532 +0,0 @@ -/** - * @file TerminalView.tsx - * @description 终端视图组件 - 使用 Jotai 原子状态 - * @module components/terminal/TerminalView - * - * 重构后的终端视图组件,使用 Jotai 进行状态管理。 - * 对齐 waveterm 的 TerminalView 架构。 - * - * ## 功能 - * - 使用 TermViewModel 管理状态 - * - 连接状态显示和重连 - * - 上下文菜单 - * - 多输入模式支持 - * - VDOM 模式支持 - * - 贴纸系统支持 - * - * _Requirements: 9.7, 7.3, 7.4, 7.5, 10.1, 10.2, 10.3, 10.4, 10.5, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 14.1, 14.2, 15.1, 15.2, 15.3, 15.4_ - */ - -import React, { - useEffect, - useRef, - useCallback, - useMemo, - useState, -} from "react"; -import { useAtomValue, useSetAtom } from "jotai"; -import { TermWrap } from "./termwrap"; -import { TerminalSearch } from "./TerminalSearch"; -import { - TerminalContextMenu, - type ContextMenuPosition, -} from "./TerminalContextMenu"; -import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator"; -import { MultiInputIndicator } from "./MultiInputIndicator"; -import { VDomView } from "./VDomView"; -import { VDomModeToggle } from "./VDomModeSwitch"; -import { StickerLayer } from "./StickerLayer"; -import { cleanupStickerStateAtom } from "@/lib/terminal/stickers"; -import { - getOrCreateTermViewModel, - cleanupTermViewModel, - useControllerStatusSync, - useConnStatusSync, - setTermModeAtom, - setConnStatusAtom, - setFontSizeAtom, - setThemeNameAtom, - cleanupTerminalStateAtom, - type TermMode, -} from "@/lib/terminal/store"; -import { cleanupVDomStateAtom } from "@/lib/terminal/vdom"; -import { - writeToTerminalRaw, - encodeBase64, - type SessionStatus, -} from "@/lib/api/terminal"; -import { - type ThemeName, - loadThemePreference, - loadFontSizePreference, - getTheme, -} from "@/lib/terminal/themes"; -import { resolveTerminalPageHotkeyAction } from "./terminalPageHotkeys"; -import "./terminal.css"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -/** TerminalView 属性 */ -export interface TerminalViewProps { - /** 块 ID(会话 ID) */ - blockId: string; - /** 标签页 ID */ - tabId: string; - /** 连接名称(可选,用于 SSH/WSL) */ - connection?: string; - /** 是否显示搜索栏 */ - showSearch?: boolean; - /** 搜索栏关闭回调 */ - onSearchClose?: () => void; - /** 状态变化回调 */ - onStatusChange?: (status: SessionStatus) => void; - /** 是否启用多输入模式 */ - multiInputEnabled?: boolean; - /** 多输入模式切换回调 */ - onMultiInputToggle?: () => void; - /** 初始主题 */ - initialTheme?: ThemeName; - /** 初始字体大小 */ - initialFontSize?: number; - /** 初始终端模式 - * _Requirements: 14.1_ - */ - initialTermMode?: TermMode; - /** 是否显示模式切换按钮 - * _Requirements: 14.2_ - */ - showModeSwitch?: boolean; - /** 模式变更回调 */ - onModeChange?: (mode: TermMode) => void; - /** 是否显示贴纸层 - * _Requirements: 15.1_ - */ - showStickers?: boolean; -} - -// ============================================================================ -// TerminalView 组件 -// ============================================================================ - -/** - * 终端视图组件 - * - * 使用 Jotai 原子状态管理,对齐 waveterm 架构。 - * - * _Requirements: 9.7_ - */ -export const TerminalView: React.FC = ({ - blockId, - tabId, - connection, - showSearch = false, - onSearchClose, - onStatusChange, - multiInputEnabled = false, - onMultiInputToggle, - initialTheme, - initialFontSize, - initialTermMode = "term", - showModeSwitch = false, - onModeChange, - showStickers = true, -}) => { - // 获取 TermViewModel - const viewModel = useMemo( - () => getOrCreateTermViewModel(blockId, tabId), - [blockId, tabId], - ); - - // 订阅后端事件 - useControllerStatusSync(blockId); - useConnStatusSync(blockId, connection); - - // 读取原子状态 - const termMode = useAtomValue(viewModel.termModeAtom); - const connStatus = useAtomValue(viewModel.connStatusAtom); - const fontSize = useAtomValue(viewModel.fontSizeAtom); - const themeName = useAtomValue(viewModel.termThemeNameAtom); - const shellProcStatus = useAtomValue(viewModel.shellProcStatusAtom); - const _isConnected = useAtomValue(viewModel.isConnectedAtom); - const _isRunning = useAtomValue(viewModel.isRunningAtom); - const _isDone = useAtomValue(viewModel.isDoneAtom); - const _hasError = useAtomValue(viewModel.hasErrorAtom); - const exitCode = useAtomValue(viewModel.exitCodeAtom); - - // 设置原子状态的 actions - const setTermMode = useSetAtom(setTermModeAtom); - const _setConnStatus = useSetAtom(setConnStatusAtom); - const setFontSize = useSetAtom(setFontSizeAtom); - const setThemeName = useSetAtom(setThemeNameAtom); - const cleanupState = useSetAtom(cleanupTerminalStateAtom); - const cleanupVDom = useSetAtom(cleanupVDomStateAtom); - const cleanupStickers = useSetAtom(cleanupStickerStateAtom); - - // 本地状态 - const [contextMenu, setContextMenu] = useState( - null, - ); - - // Refs - const connectElemRef = useRef(null); - const termWrapRef = useRef(null); - // 使用 ref 存储回调,避免回调变化导致终端重建 - const onStatusChangeRef = useRef(onStatusChange); - - // 更新回调 ref - useEffect(() => { - onStatusChangeRef.current = onStatusChange; - }, [onStatusChange]); - - // 初始化主题和字体大小 - useEffect(() => { - const theme = initialTheme ?? loadThemePreference(); - const size = initialFontSize ?? loadFontSizePreference(); - setThemeName({ blockId, themeName: theme }); - setFontSize({ blockId, fontSize: size }); - // 初始化终端模式 - // _Requirements: 14.1_ - setTermMode({ blockId, mode: initialTermMode }); - }, [ - blockId, - initialTheme, - initialFontSize, - initialTermMode, - setThemeName, - setFontSize, - setTermMode, - ]); - - // ============================================================================ - // 键盘事件处理器(对齐 waveterm 的 handleTerminalKeydown) - // 返回 true = 允许事件传递到终端 - // 返回 false = 阻止事件传递到终端(已处理) - // ============================================================================ - - const handleTerminalKeydown = useCallback((e: KeyboardEvent): boolean => { - // 只处理 keydown 事件 - if (e.type !== "keydown") { - return true; - } - - const termWrap = termWrapRef.current; - if (!termWrap) return true; - - const action = resolveTerminalPageHotkeyAction(e); - - if (action === "scroll-to-bottom") { - termWrap.terminal.scrollToBottom(); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (action === "scroll-to-top") { - termWrap.terminal.scrollToLine(0); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (action === "scroll-page-down") { - termWrap.terminal.scrollPages(1); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - if (action === "scroll-page-up") { - termWrap.terminal.scrollPages(-1); - e.preventDefault(); - e.stopPropagation(); - return false; - } - - // 未处理的事件,允许传递到终端 - return true; - }, []); - - // ============================================================================ - // TermWrap 生命周期管理 - // 注意:只依赖 blockId,避免主题/字体/回调变化导致终端重建 - // ============================================================================ - - useEffect(() => { - const container = connectElemRef.current; - if (!container) return; - - // 如果已有 TermWrap,不重建 - if (termWrapRef.current) { - return; - } - - // 清空容器 - container.innerHTML = ""; - - // 创建新的 TermWrap - const termWrap = new TermWrap(blockId, container, { - onStatusChange: (status) => { - onStatusChangeRef.current?.(status); - }, - themeName: themeName as ThemeName, - fontSize: fontSize, - keydownHandler: handleTerminalKeydown, - }); - - termWrapRef.current = termWrap; - - // 设置 ResizeObserver - const rszObs = new ResizeObserver(() => { - termWrap.handleResize_debounced(); - }); - rszObs.observe(container); - - // 异步初始化终端 - termWrap.initTerminal().catch(console.error); - - // 自动聚焦 - setTimeout(() => termWrap.focus(), 10); - - return () => { - termWrap.dispose(); - termWrapRef.current = null; - rszObs.disconnect(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [blockId]); - - // 主题变化时更新终端(不重建) - useEffect(() => { - if (termWrapRef.current?.terminal) { - const themeConfig = getTheme(themeName as ThemeName); - termWrapRef.current.terminal.options.theme = themeConfig; - } - }, [themeName]); - - // 字体大小变化时更新终端(不重建) - useEffect(() => { - if (termWrapRef.current?.terminal) { - termWrapRef.current.terminal.options.fontSize = fontSize; - termWrapRef.current.handleResize_debounced(); - } - }, [fontSize]); - - // 清理 TermViewModel - useEffect(() => { - return () => { - cleanupTermViewModel(blockId, tabId); - cleanupState(blockId); - cleanupVDom(blockId); - cleanupStickers(blockId); - }; - }, [blockId, tabId, cleanupState, cleanupVDom, cleanupStickers]); - - // ============================================================================ - // 搜索功能 - // ============================================================================ - - const handleSearch = useCallback( - (term: string, options: import("@xterm/addon-search").ISearchOptions) => { - if (!termWrapRef.current) return false; - return termWrapRef.current.search(term, options); - }, - [], - ); - - const handleSearchNext = useCallback( - (term: string, options: import("@xterm/addon-search").ISearchOptions) => { - if (!termWrapRef.current) return false; - return termWrapRef.current.searchNext(term, options); - }, - [], - ); - - const handleSearchPrevious = useCallback( - (term: string, options: import("@xterm/addon-search").ISearchOptions) => { - if (!termWrapRef.current) return false; - return termWrapRef.current.searchPrevious(term, options); - }, - [], - ); - - const handleClearSearch = useCallback(() => { - termWrapRef.current?.clearSearch(); - }, []); - - const handleSearchClose = useCallback(() => { - handleClearSearch(); - onSearchClose?.(); - }, [handleClearSearch, onSearchClose]); - - // ============================================================================ - // 上下文菜单 - // _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_ - // ============================================================================ - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ - x: e.clientX, - y: e.clientY, - }); - }, []); - - const handleContextMenuClose = useCallback(() => { - setContextMenu(null); - }, []); - - const handleCopy = useCallback(() => { - const selection = termWrapRef.current?.terminal.getSelection(); - if (selection) { - navigator.clipboard.writeText(selection); - } - setContextMenu(null); - }, []); - - const handlePaste = useCallback(async () => { - try { - const text = await navigator.clipboard.readText(); - if (text && termWrapRef.current) { - const base64 = encodeBase64(text); - await writeToTerminalRaw(blockId, base64); - } - } catch (err) { - console.error("[TerminalView] 粘贴失败:", err); - } - setContextMenu(null); - }, [blockId]); - - const getSelectedText = useCallback(() => { - return termWrapRef.current?.terminal.getSelection() ?? ""; - }, []); - - // ============================================================================ - // 重连功能 - // _Requirements: 7.4, 7.5_ - // ============================================================================ - - const handleReconnect = useCallback(async () => { - // TODO: 实现重连逻辑,调用后端 resync_controller - console.log("[TerminalView] 重连请求:", blockId); - }, [blockId]); - - // ============================================================================ - // 模式切换处理 - // _Requirements: 14.1, 14.2_ - // ============================================================================ - - const handleModeChange = useCallback( - (mode: TermMode) => { - setTermMode({ blockId, mode }); - onModeChange?.(mode); - }, - [blockId, setTermMode, onModeChange], - ); - - const handleSwitchToTerminal = useCallback(() => { - handleModeChange("term"); - // 聚焦终端 - setTimeout(() => termWrapRef.current?.focus(), 10); - }, [handleModeChange]); - - // ============================================================================ - // 聚焦处理 - // ============================================================================ - - const handleContainerClick = useCallback(() => { - termWrapRef.current?.focus(); - }, []); - - // ============================================================================ - // 渲染 - // ============================================================================ - - // VDOM 模式渲染 - // _Requirements: 14.1, 14.2_ - if (termMode === "vdom") { - return ( -
- -
- ); - } - - // 终端模式渲染 - return ( -
- {/* 连接状态指示器 - * _Requirements: 7.3, 7.4, 7.5_ - */} - - - {/* 多输入模式指示器 - * _Requirements: 10.3, 10.4_ - */} - {multiInputEnabled && ( - - )} - - {/* 模式切换按钮 - * _Requirements: 14.2_ - */} - {showModeSwitch && ( -
- -
- )} - - {/* 搜索栏 */} - {showSearch && ( - - )} - - {/* 终端容器 */} -
- - {/* 贴纸层 - * _Requirements: 15.1, 15.2, 15.3, 15.4_ - */} - {showStickers && ( - - )} - - {/* 上下文菜单 - * _Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6_ - */} - {contextMenu && ( - - )} -
- ); -}; - -export default TerminalView; diff --git a/src/components/terminal/TerminalWorkspace.tsx b/src/components/terminal/TerminalWorkspace.tsx deleted file mode 100644 index 608ee422b..000000000 --- a/src/components/terminal/TerminalWorkspace.tsx +++ /dev/null @@ -1,514 +0,0 @@ -/** - * @file TerminalWorkspace.tsx - * @description 终端工作区组件 - 支持分块布局 - * @module components/terminal/TerminalWorkspace - * - * 管理终端页面的分块布局,支持在主终端旁边添加附加面板。 - * 对齐 Waveterm 的 TileLayout 风格,所有面板水平排列。 - * 包含右侧小部件栏(WidgetsSidebar)。 - * - * ## 功能 - * - 主终端区域(左侧) - * - 附加面板区域(右侧,水平排列) - * - 支持 Terminal/Files/Web/Sysinfo 面板类型 - * - Terminal 类型支持多实例 - * - 右侧小部件栏 - * - AI 面板可控制活动终端 - */ - -import { useState, useCallback, useRef, useEffect } from "react"; -import styled from "styled-components"; -import { TerminalPanel } from "./TerminalPanel"; -import { - SysinfoView, - FileBrowserView, - WebView, - WidgetsSidebar, - WidgetProvider, - WidgetType, -} from "./widgets"; -import { TerminalAIPanel } from "./ai"; -import { - ConnectionSelector, - type ConnectionListEntry, -} from "./ConnectionSelector"; -import { ConnectionsEditorModal } from "./ConnectionsEditorModal"; -import { Page } from "@/types/page"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -/** 附加面板类型 */ -export type SidePanelType = "terminal" | "files" | "web" | "sysinfo" | "ai"; - -/** 附加面板配置 */ -export interface SidePanel { - id: string; - type: SidePanelType; - title: string; - /** 终端工作目录(仅 terminal 类型使用) */ - cwd?: string; - /** 连接配置(仅 terminal 类型使用) */ - connection?: ConnectionListEntry; - /** 终端会话 ID(仅 terminal 类型使用,由 TerminalPanel 回调设置) */ - sessionId?: string; -} - -// ============================================================================ -// 样式组件 -// ============================================================================ - -/** 终端工作区外层容器 - 包含内容区和右侧小部件栏 */ -const WorkspaceOuterContainer = styled.div` - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: row; -`; - -/** - * 终端工作区内容容器 - 对齐 Waveterm TileLayout - * 所有面板平等分布,使用 flex: 1 实现均分 - */ -const WorkspaceContainer = styled.div` - flex: 1; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: row; - gap: 3px; - padding: 3px; - background: #0a0a0a; -`; - -/** - * 单个面板块 - Block 样式(对齐 Waveterm) - * 所有面板使用相同的 flex: 1,实现平等分布 - */ -const PanelBlock = styled.div<{ $focused?: boolean }>` - flex: 1 1 0; - min-width: 150px; - min-height: 0; - display: flex; - flex-direction: column; - background: #1a1a1a; - border-radius: 6px; - border: 2px solid ${({ $focused }) => ($focused ? "#58a6ff" : "#2a2a2a")}; - overflow: hidden; -`; - -/** 面板头部 - 对齐 Waveterm */ -const PanelHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 10px; - background: #1a1a1a; - border-bottom: 1px solid #2a2a2a; - min-height: 28px; - max-height: 28px; -`; - -const PanelTitle = styled.span` - font-size: 12px; - font-weight: 500; - color: #e0e0e0; - display: flex; - align-items: center; - gap: 6px; - - svg { - width: 14px; - height: 14px; - opacity: 0.6; - } -`; - -const PanelCloseButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border: none; - border-radius: 4px; - background: transparent; - color: #808080; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: #333; - color: #e0e0e0; - } - - svg { - width: 12px; - height: 12px; - } -`; - -const PanelContent = styled.div` - flex: 1; - min-height: 0; - overflow: hidden; - background: #1a1a1a; -`; - -// ============================================================================ -// 图标组件 -// ============================================================================ - -const TerminalIcon = () => ( - - - - -); - -const FilesIcon = () => ( - - - -); - -const WebIcon = () => ( - - - - - -); - -const SysinfoIcon = () => ( - - - -); - -const AIIcon = () => ( - - - - - -); - -const CloseIcon = () => ( - - - - -); - -// ============================================================================ -// 主组件 -// ============================================================================ - -interface TerminalWorkspaceProps { - /** 页面导航回调 */ - onNavigate: (page: Page) => void; - /** 当前页面是否已激活 */ - isActive: boolean; -} - -/** - * 终端工作区组件 - */ -export function TerminalWorkspace({ - onNavigate, - isActive, -}: TerminalWorkspaceProps) { - // 面板状态管理 - 初始包含主终端 - const [panels, setPanels] = useState([ - { id: "main-terminal", type: "terminal", title: "Terminal" }, - ]); - - // AI 面板状态 - const [showAIPanel, setShowAIPanel] = useState(false); - const [hasActivated, setHasActivated] = useState(isActive); - - // 活动终端面板 ID(用于 AI 控制) - const [activeTerminalPanelId, setActiveTerminalPanelId] = - useState("main-terminal"); - - // 终端输出引用(用于 AI 上下文) - const terminalOutputRef = useRef(null); - - // 连接编辑器模态窗口状态 - const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false); - - useEffect(() => { - if (isActive) { - setHasActivated(true); - } - }, [isActive]); - - // 获取活动终端的会话 ID - const getActiveTerminalSessionId = useCallback((): string | null => { - const activePanel = panels.find( - (p) => p.id === activeTerminalPanelId && p.type === "terminal", - ); - return activePanel?.sessionId || null; - }, [panels, activeTerminalPanelId]); - - // 更新终端面板的会话 ID - const updatePanelSessionId = useCallback( - (panelId: string, sessionId: string) => { - setPanels((prev) => - prev.map((p) => (p.id === panelId ? { ...p, sessionId } : p)), - ); - }, - [], - ); - - // 添加面板 - 所有类型都允许多开 - const addPanel = useCallback( - ( - type: SidePanelType, - options?: { cwd?: string; connection?: ConnectionListEntry }, - ) => { - setPanels((prev) => { - const titles: Record = { - terminal: "Terminal", - files: "Files", - web: "Web", - sysinfo: "Sysinfo", - ai: "AI", - }; - - // 如果有连接配置,使用连接标签作为标题 - const title = options?.connection - ? options.connection.label - : titles[type]; - - return [ - ...prev, - { - id: `panel-${Date.now()}`, - type, - title, - cwd: options?.cwd, - connection: options?.connection, - }, - ]; - }); - }, - [], - ); - - // 移除面板 - const removePanel = useCallback((id: string) => { - setPanels((prev) => prev.filter((p) => p.id !== id)); - }, []); - - // 更新面板连接 - const updatePanelConnection = useCallback( - (id: string, connection: ConnectionListEntry) => { - setPanels((prev) => - prev.map((p) => { - if (p.id === id && p.type === "terminal") { - return { - ...p, - title: connection.label, - connection, - }; - } - return p; - }), - ); - }, - [], - ); - - // 渲染面板图标 - const renderPanelIcon = (type: SidePanelType) => { - switch (type) { - case "terminal": - return ; - case "files": - return ; - case "web": - return ; - case "sysinfo": - return ; - case "ai": - return ; - default: - return null; - } - }; - - // 在文件浏览器中打开终端的回调 - const handleOpenTerminalFromFiles = useCallback( - (path: string) => { - addPanel("terminal", { cwd: path }); - }, - [addPanel], - ); - - // 获取终端输出(用于 AI 上下文) - const getTerminalOutput = useCallback(() => { - return terminalOutputRef.current; - }, []); - - // 渲染面板内容 - const renderPanelContent = (panel: SidePanel) => { - switch (panel.type) { - case "terminal": - if (!hasActivated) { - return ( -
- 终端将在首次打开该页面时初始化 -
- ); - } - - return ( - - updatePanelSessionId(panel.id, sessionId) - } - /> - ); - case "files": - return ; - case "web": - return ; - case "sysinfo": - return ; - case "ai": - return ( - - ); - default: - return null; - } - }; - - /** - * 处理右侧小部件点击 - * 添加面板或导航到其他页面 - */ - const handleWidgetClick = useCallback( - (type: WidgetType) => { - switch (type) { - case "terminal": - addPanel("terminal"); - break; - case "files": - addPanel("files"); - break; - case "web": - addPanel("web"); - break; - case "sysinfo": - addPanel("sysinfo"); - break; - case "ai": - // 切换 AI 面板显示 - setShowAIPanel((prev) => !prev); - break; - case "settings": - onNavigate("settings"); - break; - case "tips": - console.log("提示功能待实现"); - break; - case "secrets": - onNavigate("settings"); - break; - case "help": - console.log("帮助功能待实现"); - break; - } - }, - [addPanel, onNavigate], - ); - - return ( - - - {/* AI 面板(左侧,参考 Waveterm) */} - {showAIPanel && ( -
- -
- )} - - - {/* 所有面板统一渲染,都可以关闭和多开 */} - {panels.map((panel) => ( - { - // 点击终端面板时设置为活动终端 - if (panel.type === "terminal") { - setActiveTerminalPanelId(panel.id); - } - }} - > - - {panel.type === "terminal" ? ( - updatePanelConnection(panel.id, conn)} - onEditConnections={() => setIsConnectionsEditorOpen(true)} - /> - ) : ( - - {renderPanelIcon(panel.type)} - {panel.title} - - )} - removePanel(panel.id)}> - - - - {renderPanelContent(panel)} - - ))} - {/* 没有面板时显示提示 */} - {panels.length === 0 && ( - - - 点击右侧图标添加面板 - - - )} - - -
- - {/* 连接配置编辑器模态窗口 */} - setIsConnectionsEditorOpen(false)} - /> -
- ); -} - -export default TerminalWorkspace; diff --git a/src/components/terminal/VDomModeSwitch.tsx b/src/components/terminal/VDomModeSwitch.tsx deleted file mode 100644 index ed31967fc..000000000 --- a/src/components/terminal/VDomModeSwitch.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @file VDomModeSwitch.tsx - * @description VDOM 模式切换组件 - * @module components/terminal/VDomModeSwitch - * - * 提供终端模式(term/vdom)切换的 UI 组件。 - * - * _Requirements: 14.1, 14.2_ - */ - -import React, { useCallback } from "react"; -import { useAtomValue, useSetAtom } from "jotai"; -import { - termModeAtomFamily, - setTermModeAtom, - type TermMode, -} from "@/lib/terminal/store"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -export interface VDomModeSwitchProps { - /** 块 ID */ - blockId: string; - /** 是否显示标签 */ - showLabel?: boolean; - /** 是否禁用 */ - disabled?: boolean; - /** 模式变更回调 */ - onModeChange?: (mode: TermMode) => void; - /** 自定义类名 */ - className?: string; -} - -// ============================================================================ -// 图标组件 -// ============================================================================ - -const TerminalIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -const VDomIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - - -); - -// ============================================================================ -// VDomModeSwitch 组件 -// ============================================================================ - -/** - * VDOM 模式切换组件 - * - * 提供终端模式和 VDOM 模式之间的切换。 - * - * _Requirements: 14.1, 14.2_ - */ -export const VDomModeSwitch: React.FC = ({ - blockId, - showLabel = true, - disabled = false, - onModeChange, - className = "", -}) => { - // 读取当前模式 - const termMode = useAtomValue(termModeAtomFamily(blockId)); - const setTermMode = useSetAtom(setTermModeAtom); - - // 切换模式 - const handleModeChange = useCallback( - (newMode: TermMode) => { - if (disabled || newMode === termMode) return; - - setTermMode({ blockId, mode: newMode }); - onModeChange?.(newMode); - }, - [blockId, termMode, disabled, setTermMode, onModeChange], - ); - - return ( -
- {/* 终端模式按钮 */} - - - {/* VDOM 模式按钮 */} - -
- ); -}; - -// ============================================================================ -// 紧凑版模式切换 -// ============================================================================ - -export interface VDomModeToggleProps { - /** 块 ID */ - blockId: string; - /** 是否禁用 */ - disabled?: boolean; - /** 模式变更回调 */ - onModeChange?: (mode: TermMode) => void; - /** 自定义类名 */ - className?: string; -} - -/** - * 紧凑版 VDOM 模式切换 - * - * 单按钮切换,适合工具栏使用。 - */ -export const VDomModeToggle: React.FC = ({ - blockId, - disabled = false, - onModeChange, - className = "", -}) => { - const termMode = useAtomValue(termModeAtomFamily(blockId)); - const setTermMode = useSetAtom(setTermModeAtom); - - const handleToggle = useCallback(() => { - if (disabled) return; - - const newMode: TermMode = termMode === "term" ? "vdom" : "term"; - setTermMode({ blockId, mode: newMode }); - onModeChange?.(newMode); - }, [blockId, termMode, disabled, setTermMode, onModeChange]); - - return ( - - ); -}; - -export default VDomModeSwitch; diff --git a/src/components/terminal/VDomView.tsx b/src/components/terminal/VDomView.tsx deleted file mode 100644 index 9c908b130..000000000 --- a/src/components/terminal/VDomView.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @file VDomView.tsx - * @description VDOM 视图组件 - * @module components/terminal/VDomView - * - * 在 VDOM 模式下渲染终端内嵌的 UI 块。 - * - * _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_ - */ - -import React, { useCallback, useMemo } from "react"; -import { useAtomValue, useSetAtom } from "jotai"; -import { SubBlockContainer } from "./SubBlock"; -import { VDomModeToggle } from "./VDomModeSwitch"; -import { - type VDomContext, - type VDomEvent, - vdomBlocksAtomFamily, - vdomToolbarAtomFamily, - removeVDomBlockAtom, - cleanupVDomStateAtom, -} from "@/lib/terminal/vdom"; -import { setTermModeAtom } from "@/lib/terminal/store"; - -// ============================================================================ -// 类型定义 -// ============================================================================ - -export interface VDomViewProps { - /** 终端块 ID */ - blockId: string; - /** 标签页 ID */ - tabId: string; - /** 切换回终端模式的回调 */ - onSwitchToTerminal?: () => void; - /** 自定义类名 */ - className?: string; -} - -// ============================================================================ -// 图标组件 -// ============================================================================ - -const TerminalIcon: React.FC<{ className?: string }> = ({ className }) => ( - - - - -); - -// ============================================================================ -// VDomView 组件 -// ============================================================================ - -/** - * VDOM 视图组件 - * - * 在 VDOM 模式下渲染终端内嵌的 UI 块。 - * - * _Requirements: 14.1, 14.2, 14.3, 14.4, 14.5_ - */ -export const VDomView: React.FC = ({ - blockId, - tabId, - onSwitchToTerminal, - className = "", -}) => { - // 读取 VDOM 状态 - const blocks = useAtomValue(vdomBlocksAtomFamily(blockId)); - const toolbar = useAtomValue(vdomToolbarAtomFamily(blockId)); - - // 操作原子 - const setTermMode = useSetAtom(setTermModeAtom); - const removeBlock = useSetAtom(removeVDomBlockAtom); - const _cleanupVDom = useSetAtom(cleanupVDomStateAtom); - - // 切换回终端模式 - // _Requirements: 14.5_ - const handleSwitchToTerminal = useCallback(() => { - setTermMode({ blockId, mode: "term" }); - onSwitchToTerminal?.(); - }, [blockId, setTermMode, onSwitchToTerminal]); - - // 关闭 VDOM 块 - const handleCloseBlock = useCallback( - (vdomBlockId: string) => { - removeBlock({ terminalBlockId: blockId, blockId: vdomBlockId }); - - // 如果没有更多块,自动切换回终端模式 - // _Requirements: 14.5_ - if (blocks.length <= 1) { - handleSwitchToTerminal(); - } - }, - [blockId, blocks.length, removeBlock, handleSwitchToTerminal], - ); - - // 发送 VDOM 事件 - const handleSendEvent = useCallback((event: VDomEvent) => { - console.log("[VDomView] 事件:", event); - // TODO: 发送事件到后端或处理本地事件 - }, []); - - // 创建 VDOM 上下文 - const context: VDomContext = useMemo( - () => ({ - terminalBlockId: blockId, - tabId, - termMode: "vdom", - sendEvent: handleSendEvent, - switchToTerminal: handleSwitchToTerminal, - closeBlock: handleCloseBlock, - }), - [blockId, tabId, handleSendEvent, handleSwitchToTerminal, handleCloseBlock], - ); - - return ( -
- {/* VDOM 工具栏 */} - {toolbar && toolbar.visible && toolbar.position === "top" && ( -
- {toolbar.items.map((item) => ( -
- {item.type === "separator" ? ( -
- ) : ( - - )} -
- ))} -
- )} - - {/* VDOM 内容区域 */} -
- {blocks.length > 0 ? ( - - ) : ( -
-

没有 VDOM 块

- -
- )} -
- - {/* 底部工具栏 */} - {toolbar && toolbar.visible && toolbar.position === "bottom" && ( -
- {toolbar.items.map((item) => ( -
- {item.type === "separator" ? ( -
- ) : ( - - )} -
- ))} -
- )} - - {/* 模式切换按钮(固定在右上角) */} -
- { - if (mode === "term") { - onSwitchToTerminal?.(); - } - }} - /> -
-
- ); -}; - -export default VDomView; diff --git a/src/components/terminal/ai/CommandApproval.tsx b/src/components/terminal/ai/CommandApproval.tsx deleted file mode 100644 index 0d799830c..000000000 --- a/src/components/terminal/ai/CommandApproval.tsx +++ /dev/null @@ -1,186 +0,0 @@ -/** - * @file CommandApproval.tsx - * @description 命令审批组件 - AI 执行命令前的用户确认 - * @module components/terminal/ai/CommandApproval - * - * 当 AI 需要在终端执行命令时,显示审批对话框让用户确认。 - * 参考 Waveterm 的工具调用审批流程。 - */ - -import React from "react"; -import styled from "styled-components"; -import type { PendingCommand } from "./TerminalController"; - -// ============================================================================ -// 样式组件 -// ============================================================================ - -const ApprovalContainer = styled.div` - background: #1e293b; - border: 1px solid #334155; - border-radius: 8px; - padding: 12px; - margin: 8px 0; -`; - -const ApprovalHeader = styled.div` - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - color: #f59e0b; - font-size: 13px; - font-weight: 500; -`; - -const WarningIcon = () => ( - - - - - -); - -const CommandBox = styled.div` - background: #0f172a; - border: 1px solid #1e293b; - border-radius: 6px; - padding: 10px 12px; - font-family: "Hack", "Menlo", monospace; - font-size: 13px; - color: #e2e8f0; - margin-bottom: 12px; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; -`; - -const ButtonGroup = styled.div` - display: flex; - gap: 8px; - justify-content: flex-end; -`; - -const Button = styled.button<{ $variant?: "approve" | "reject" }>` - padding: 6px 16px; - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - border: none; - - ${({ $variant }) => - $variant === "approve" - ? ` - background: #22c55e; - color: white; - &:hover { - background: #16a34a; - } - ` - : ` - background: #334155; - color: #94a3b8; - &:hover { - background: #475569; - color: #e2e8f0; - } - `} -`; - -const HelpText = styled.p` - font-size: 12px; - color: #64748b; - margin: 0 0 12px 0; -`; - -// ============================================================================ -// 组件 -// ============================================================================ - -interface CommandApprovalProps { - /** 待审批的命令 */ - command: PendingCommand; - /** 批准回调 */ - onApprove: (commandId: string) => void; - /** 拒绝回调 */ - onReject: (commandId: string) => void; -} - -/** - * 命令审批组件 - */ -export function CommandApproval({ - command, - onApprove, - onReject, -}: CommandApprovalProps) { - return ( - - - - AI 请求执行命令 - - - {command.command} - - 此命令将在当前终端中执行。请确认是否允许。 - - - - - - - ); -} - -// ============================================================================ -// 命令列表组件 -// ============================================================================ - -interface CommandApprovalListProps { - /** 待审批的命令列表 */ - commands: PendingCommand[]; - /** 批准回调 */ - onApprove: (commandId: string) => void; - /** 拒绝回调 */ - onReject: (commandId: string) => void; -} - -/** - * 命令审批列表组件 - */ -export function CommandApprovalList({ - commands, - onApprove, - onReject, -}: CommandApprovalListProps) { - if (commands.length === 0) { - return null; - } - - return ( - <> - {commands.map((cmd) => ( - - ))} - - ); -} - -export default CommandApproval; diff --git a/src/components/terminal/ai/README.md b/src/components/terminal/ai/README.md deleted file mode 100644 index 263c4cce2..000000000 --- a/src/components/terminal/ai/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# Terminal AI 模块 - - - -## 架构说明 - -Terminal AI 是终端内置的 AI 助手功能,参考 Waveterm 的 AI 面板设计。 - -**核心特性:** - -- 复用 AI Agent 的模型选择器和运行时 API -- 支持终端上下文(Widget Context) -- 流式响应显示 -- 工具调用支持 -- **AI 控制终端**:AI 可以向活动终端发送命令(需用户审批) - -## 文件索引 - -| 文件 | 说明 | -| ---------------------------- | -------------------------------- | -| `index.ts` | 模块导出 | -| `types.ts` | 类型定义 | -| `useTerminalAI.ts` | Terminal AI Hook(含终端控制) | -| `TerminalAIPanel.tsx` | AI 面板主组件 | -| `TerminalAIInput.tsx` | 输入框组件 | -| `TerminalAIMessages.tsx` | 消息列表组件 | -| `TerminalAIModeSelector.tsx` | 模式/模型选择器 | -| `TerminalAIWelcome.tsx` | 欢迎页面组件 | -| `CommandApproval.tsx` | 命令审批组件 | -| `TerminalController.ts` | 终端控制器(管理 AI 与终端通信) | - -## 使用方式 - -```tsx -import { TerminalAIPanel } from "@/components/terminal/ai"; - -function MyComponent() { - const getTerminalOutput = () => { - // 返回终端输出内容 - return "$ ls -la\ntotal 0\n..."; - }; - - // 终端会话 ID(用于 AI 控制终端) - const terminalSessionId = "session-123"; - - return ( - - ); -} -``` - -## 功能说明 - -### Widget Context - -开启后,AI 可以看到终端的最近输出(默认 50 行),用于: - -- 解释命令输出 -- 调试错误信息 -- 提供上下文相关的建议 - -### 模型选择 - -复用 AI Agent 的 Provider/Model 选择器,支持: - -- OAuth 凭证(Kiro、Gemini、Antigravity 等) -- API Key 凭证(OpenAI、Claude 等) - -### AI 控制终端(新功能) - -AI 可以向活动终端发送命令,流程如下: - -1. AI 生成命令建议 -2. 用户在审批对话框中确认 -3. 命令发送到终端执行 - -参考 Waveterm 的 `sendDataToController()` 机制实现。 - -### 快捷操作 - -欢迎页面提供快捷操作按钮: - -- 解释命令 -- 调试错误 -- 写脚本 -- 优化命令 - -## 依赖 - -- `@/lib/api/agentRuntime` - Agent / Aster 现役运行时 API -- `@/lib/api/agentProtocol` - Conversation Runtime 流式事件与展示类型出口 -- `@/lib/api/terminal` - 终端 API(用于发送命令) -- `@/hooks/useProviderPool` - Provider 凭证 -- `@/hooks/useApiKeyProvider` - API Key 凭证 -- `@/hooks/useModelRegistry` - 模型注册表 -- `@/components/ui/*` - UI 组件 - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src/components/terminal/ai/TerminalAIInput.tsx b/src/components/terminal/ai/TerminalAIInput.tsx deleted file mode 100644 index 6ccfb6c31..000000000 --- a/src/components/terminal/ai/TerminalAIInput.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @file TerminalAIInput.tsx - * @description Terminal AI 输入框组件 - * @module components/terminal/ai/TerminalAIInput - * - * 参考 Waveterm 的 AIPanelInput 设计 - */ - -import React, { useRef } from "react"; -import { Send, Square, Paperclip } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { BaseComposer } from "@/components/input-kit"; -import { CharacterMention } from "@/components/agent/chat/skill-selection/CharacterMention"; -import { SkillBadge } from "@/components/agent/chat/skill-selection/SkillBadge"; -import { useActiveSkill } from "@/components/agent/chat/skill-selection/useActiveSkill"; -import type { Skill } from "@/lib/api/skills"; - -interface TerminalAIInputProps { - /** 输入值 */ - value: string; - /** 输入变化回调 */ - onChange: (value: string) => void; - /** 提交回调(可接受 textOverride) */ - onSubmit: (textOverride?: string) => void; - /** 停止回调 */ - onStop?: () => void; - /** 是否正在发送 */ - isSending: boolean; - /** 是否禁用 */ - disabled?: boolean; - /** 占位符 */ - placeholder?: string; - /** 技能列表 */ - skills?: Skill[]; -} - -export const TerminalAIInput: React.FC = ({ - value, - onChange, - onSubmit, - onStop, - isSending, - disabled = false, - placeholder = "Continue...", - skills = [], -}) => { - const textareaRef = useRef(null); - const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } = - useActiveSkill(); - - const handleSend = () => { - const text = activeSkill ? wrapTextWithSkill(value) : undefined; - onSubmit(text); - clearActiveSkill(); - }; - - return ( - - {({ textareaProps, onPrimaryAction, isPrimaryDisabled }) => ( -
- {/* CharacterMention */} - {skills.length > 0 && ( - - )} -
- {/* Skill Badge */} - {activeSkill && ( - - )} -