diff --git a/.github/workflows/harness-nightly.yml b/.github/workflows/harness-nightly.yml new file mode 100644 index 000000000..03d8e89e9 --- /dev/null +++ b/.github/workflows/harness-nightly.yml @@ -0,0 +1,62 @@ +name: Harness Nightly + +on: + schedule: + - cron: "0 18 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + harness_eval_summary: + name: Harness Eval Summary + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Restore harness eval history cache + id: restore-harness-history + uses: actions/cache/restore@v4 + with: + path: artifacts/history + key: harness-nightly-history-${{ github.run_id }} + restore-keys: | + harness-nightly-history- + + - name: Generate harness eval summary + run: | + mkdir -p "./artifacts/history" + node scripts/harness-eval-runner.mjs \ + --output-json "./artifacts/harness-eval-summary.json" \ + --output-markdown "./artifacts/harness-eval-summary.md" + cp "./artifacts/harness-eval-summary.json" "./artifacts/history/$(date -u +%Y%m%dT%H%M%SZ)-harness-eval-summary.json" + ls -1t "./artifacts/history"/*.json 2>/dev/null | tail -n +31 | xargs -r rm -f + + - name: Generate harness eval trend + run: | + node scripts/harness-eval-trend-report.mjs \ + --history-dir "./artifacts/history" \ + --output-json "./artifacts/harness-eval-trend.json" \ + --output-markdown "./artifacts/harness-eval-trend.md" + + - name: Upload harness eval artifact + uses: actions/upload-artifact@v4 + with: + name: harness-eval-nightly + path: artifacts + if-no-files-found: error + + - name: Save harness eval history cache + if: always() + uses: actions/cache/save@v4 + with: + path: artifacts/history + key: harness-nightly-history-${{ github.run_id }} diff --git a/.gitignore b/.gitignore index dc721636e..40a8e75dc 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ docs/roadmap/* docs/gongzonghao/ docs/bussniss/ docs/oem/ +docs/tech/ # Issues tracking (internal use only) .issues/ diff --git a/AGENTS.md b/AGENTS.md index f6744b1fc..3c351d2db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,17 @@ 14. **不要继续放大历史大文件** - 现有超大文件属于历史包袱,但新增逻辑应优先拆边界,不继续堆叠 15. **质量门禁保持单一主线** - `.github/workflows/quality.yml`、`scripts/quality-task-planner.mjs`、本地统一入口要保持一致 +## 路线图主线护栏 + +当用户明确要求“对齐路线图 / 按顺序完成目标 / 继续主线”时,额外遵守以下规则: + +1. **先重述主目标** - 开始新一轮实现前,先用一句话重述当前路线图的主目标、当前阶段和下一刀 +2. **主线优先于清理** - 默认优先推进路线图中尚未完成的主链事项;零引用清理、README 同步、dead util 删除只能作为从属动作,不能替代主线 +3. **每一刀都要回挂路线图** - 任何改动都必须能明确回答“它对应路线图哪一节、缩短了哪条主链距离” +4. **连续清理后强制回看路线图** - 如果连续两轮工作主要是治理减法或 dead surface 清退,下一轮必须重新打开路线图并优先选择尚未完成的主链项 +5. **发现偏离要立即纠偏** - 如果当前改动无法直接服务 `Conversation Runtime 效率 / 前端瘦身 / Team 委派 / 协议收敛 / 状态统一` 这五条主线,应立即停止扩散并回到路线图 +6. **汇报必须带主线判断** - 阶段汇报时必须显式说明“这一步为什么仍在主目标上”,不能只汇报局部文件改动 + ## UI 规则 1. **改界面先读视觉规范** - 先看 `docs/aiprompts/design-language.md` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8fa912d9f..4bf068252 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,38 +1,42 @@ -## Lime v0.96.0 +## Lime v0.97.0 ### ✨ 主要更新 -- **Aster Agent 运行时与 Artifact 工作台继续落主链**:桌面端补齐了新的运行时协议、Artifact 文档处理链路与工作台渲染入口,产物预览、自动持久化、运行态元数据和时间线展示现在统一走同一套事实源 -- **Browser Runtime 新增站点适配器目录与调试能力**:内置站点适配器注册表开始随应用打包,Browser Runtime 可以列出、搜索、调试、执行并保存站点适配结果,为后续站点自动化与内容采集提供统一入口 -- **云端账户与 Provider 消费态完成收口**:设置页新增云端 Offer / 模型目录 / 本地 Provider 的分层视图,OAuth / 会话 / 控制面 bootstrap / 默认来源切换都统一到 OEM Cloud 运行时链路 -- **快捷键与工作区交互体验更完整**:快捷键设置页改成“已审计、已接入、可测试”的目录视图,工作台、终端、文档画布、海报画布与系统能力的可用热键与运行时状态都能集中查看 -- **发布质量入口与 GUI 冒烟主线收敛**:仓库新增 `verify:tasks`、`verify:gui-smoke` 与统一质量工作流,版本发布不再只看静态检查,而是把契约、Bridge 与 GUI 壳准备态一起纳入门槛 +- **Harness 导出链路补齐四类制品**:处理工作台与统一运行时接通 `handoff bundle`、`evidence pack`、`replay case`、`analysis handoff` 导出,支持 pending request 重放、外部诊断交接和问题复盘闭环 +- **Replay Eval / Nightly 骨架进入 current 主线**:仓库新增 `docs/test/harness-evals.*`、固定 replay fixture、`harness-eval-runner`、`harness-eval-trend-report`、`harness-replay-promote` 与 nightly workflow,把 replay 样本、grader 合同和趋势摘要收口到统一入口 +- **Service Skill 到 Automation 的落地链路更完整**:Home Shell、Workspace 与自动化设置页现在可以直接从服务技能创建本地 automation job,保留技能与任务关联,并回填 workspace/content 上下文 +- **Browser Runtime 站点采集继续收口**:站点采集工作台补齐推荐适配器、资料自动选择、目录状态展示和结果回写当前内容/项目的主链,优先复用已连接 Chrome 的真实登录态 +- **Agent Chat 提交流程与处理面板继续瘦身**:slash skill、selected team、session/runtime steady-state 与 Harness 状态面板的交互拆分重组,关键回归测试同步补齐 ### ⚠️ 兼容性说明 -- 现网包发布仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包 -- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖已同步到 `v0.22.0`;如本地仍在用 `.cargo/config.toml` 覆盖本地 Aster,请确认覆盖版本与本次发布一致 -- 站点适配器目录现在会随桌面端资源一起打包,同时支持服务端同步目录;打包前请确认目标环境允许下发对应的站点脚本与运行时配置 -- 云端 Provider、服务技能目录与站点适配目录都依赖 OEM 控制面 bootstrap;发布到不同品牌/环境前,请确认 `public/oem-runtime-config.js` 已替换为目标环境值 +- 正式发布仍由 `v*` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 +- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向的是干净的 `v0.22.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 +- 站点适配器与 Browser Runtime 冒烟现在默认依赖已就绪的 `DevBridge`、浏览器资料和服务端同步目录;发布到目标环境前请确认对应控制面与 Browser Bridge 状态可用 +- Harness 新增 handoff/evidence/replay/analysis 导出后,会在工作区 `.lime/harness/sessions//...` 下沉淀更多制品;如有路径清理策略,请同步评估磁盘与归档规则 ### 🔗 依赖同步 -- 应用版本已同步提升到 `v0.96.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json` -- Lime 内置的 `aster-rust` 依赖已从 `v0.21.0` 升级到 `v0.22.0` -- `src-tauri/Cargo.lock` 已随本次 Rust 校验更新,确保发布时依赖解析结果可复现 +- 应用版本已同步提升到 `v0.97.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock` +- 当前仓库声明的 `aster-rust` 依赖仍为 `v0.22.0`;本地覆盖仓库已核对为干净 `v0.22.0` 状态 +- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.97.0` 对齐 ### 🧪 测试 -- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all` - 发布前执行:`npm run verify:app-version` -- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.96.0 npm run verify:local` -- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.96.0 cargo clippy --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`npm run lint` +- 发布前执行:`npm run test:contracts` +- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all` +- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 cargo test --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 cargo clippy --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`CARGO_TARGET_DIR=/tmp/lime-target-v0.97.0 npm run verify:gui-smoke -- --timeout-ms 480000` +- 格式状态:已执行 `cargo fmt --manifest-path src-tauri/Cargo.toml --all` ### 📝 文档 -- 发布说明随 `RELEASE_NOTES.md` 更新,供 GitHub Release 工作流直接读取 -- 工程质量与命令边界文档已同步更新到新的 GUI 冒烟 / 契约 / 版本校验主线 +- 发布说明已切换到 `v0.97.0`,供 GitHub Release 工作流直接读取 +- Harness eval / 工程质量 / 命令边界与 GUI 冒烟相关文档已在当前工作区同步演进 --- -**完整变更**: v0.95.0...v0.96.0 +**完整变更**: v0.96.0...v0.97.0 diff --git a/docs/README.md b/docs/README.md index c64b63fa6..7dbeac97d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ `docs/` 是 Lime 文档中心,分为两类受众: - 普通创作者:优先阅读 `content/` 下的入门与用户指南 -- 开发者与维护者:阅读 `aiprompts/`、`develop/`、`tests/` 等工程文档 +- 开发者与维护者:阅读 `aiprompts/`、`develop/`、`tech/`、`tests/` 等工程文档 文档站基于 Nuxt Content 构建。 @@ -13,6 +13,7 @@ - `content/`:对外文档站正文(产品介绍、用户指南、进阶能力) - `aiprompts/`:模块级工程文档(前后端组件、服务、命令、数据层) +- `tech/`:跨模块技术蓝图与专题工程文档(当前已包含 Harness Engineering 指导文档) - `bussniss/`:商务合作与代理运营方案 - `develop/`:开发流程与协作规范 - `plugins/`:插件与扩展相关文档 @@ -24,6 +25,8 @@ - `develop/execution-tracker-deprecation-plan.md`:统一执行追踪旧路径退场计划(P0 收口) - `develop/execution-tracker-p0-acceptance-report.md`:统一执行追踪 P0 验收报告 - `develop/execution-tracker-p1-p2-roadmap.md`:统一执行追踪后续路线(P1/P2) +- `tech/harness/README.md`:Lime Harness Engineering 总入口 +- `tech/harness/implementation-blueprint.md`:Lime Harness 分阶段实施蓝图 - `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复) - `roadmap/lime-skills-standardization-roadmap.md`:Skills 标准化与产品化路线图 - `roadmap/lime-service-skill-cloud-config-prd.md`:服务型技能的端优先执行与云配置同步 PRD diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 4f34a4129..a1696232e 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -19,6 +19,7 @@ - `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁 - `project-heatmap.md` - 仓库热力图与治理候选分析 - `limecore-collaboration-entry.md` - 跨仓库联动入口 +- `../tech/harness/README.md` - Lime Harness Engineering 总入口与实施蓝图 ### GUI 与前端 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 7749799e1..4489d0017 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -145,8 +145,9 @@ npm run verify:local 如果命令边界改动影响会话运行时恢复语义,例如: -- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy` -- 话题切换时的 provider/model 恢复从本地 fallback 向 `execution_runtime` 收敛 +- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_preferences / recent_team_selection` +- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` +- 话题切换时的 provider/model、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛 除了契约检查,还应补对应 Hook / UI 稳定回归,确认切换话题后模型选择器恢复的是会话 runtime,而不是陈旧本地缓存。 @@ -188,7 +189,15 @@ npm run verify:local 以下是仓库当前已经明确收敛的几个方向: - **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*` -- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略以及 session provider/model 的轻量持久化回写 +- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 +- **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel` +- **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据 +- **运行时 replay 样本主链**:继续收敛到 `agent_runtime_export_replay_case`,复用 handoff bundle + evidence pack 生成 `input / expected / grader / evidence-links` +- **运行时外部分析交接主链**:继续收敛到 `agent_runtime_export_analysis_handoff`,复用 handoff bundle + evidence pack + replay case 生成 `analysis-brief.md / analysis-context.json / copy_prompt`,供外部 Claude Code / Codex 直接诊断与最小修复;当前 GUI 入口位于 `HarnessStatusPanel` +- **运行时人工审核记录主链**:继续收敛到 `agent_runtime_export_review_decision_template`,复用 `analysis handoff` 生成 `review-decision.md / review-decision.json`,把开发者的接受 / 延后 / 拒绝与回归要求回挂到工作区;当前 GUI 入口位于 `HarnessStatusPanel` +- **会话主题上下文主链**:`getSession` 返回的 `execution_runtime.recent_theme / recent_session_mode` 负责承接最近一次运行态主题上下文;当前端已命中同一 steady-state theme/workbench mode 时,不应继续每回合重复携带 `harness.theme / harness.session_mode` +- **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次 Theme Workbench 运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title` +- **会话内容上下文主链**:`getSession` 返回的 `execution_runtime.recent_content_id` 负责承接最近一次运行态 `content_id`;当前端已命中同一 steady-state 内容时,不应继续每回合重复携带 `harness.content_id` - **运行态摘要主链**:Aster `runtime_status` item -> timeline `turn_summary` - **旧 `chat_*` 命令**:已停止注册,不应重新回到 `commands::mod` 或 `generate_handler!` - **旧 `general_chat_*` 边界**:前端 compat 网关与 Rust 命令都已移除,不应重新接入 @@ -198,6 +207,14 @@ npm run verify:local **不要再造第三套入口,优先继续把能力收敛到已存在的主链。** +补充约定: + +- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_run_adapter` +- **站点 Agent 工具主链**:继续收敛到 `lime_site_list / lime_site_recommend / lime_site_search / lime_site_info / lime_site_run` +- **站点结果沉淀主线**:`site_run_adapter` / `lime_site_run` 优先透传 `content_id` 写回当前主稿;只有缺少 `content_id` 时,才回退到 `project_id` 新建结果文档 +- **站点运行失败语义**:`SiteAdapterRunResult` 至少统一输出 `auth_required / no_matching_context / adapter_runtime_error`,并在前端与 Agent 结果里保留 `report_hint` +- **浏览器资料 / 环境预设主链**:`list/save/archive/restore_browser_profile_cmd` 与 `list/save/archive/restore_browser_environment_preset_cmd` 已进入真实 DevBridge 主路径;浏览器模式下不应再默认放进 `mockPriorityCommands`,仅在 DevBridge 不可用时才允许回落 `defaultMocks` + ## 相关检查脚本 ```bash diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index 9520a628c..55e44637e 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -17,6 +17,18 @@ 其余实现必须被明确归类。 +## 路线图任务防跑偏 + +如果用户明确绑定了某份路线图,尤其是要求“按顺序继续”“对齐目标”“先完成主线”,治理动作必须服从路线图主线,而不是反过来主导路线图。 + +执行时额外遵守: + +1. 先重述当前路线图的 **主目标 / 当前阶段 / 下一刀** +2. 只有当 dead / compat / deprecated surface **直接阻碍主线收口** 时,才优先做治理减法 +3. 不要把“还能删一点旧代码”误当成“继续推进目标” +4. 连续两轮主要都在删零引用或补文档时,必须重新打开路线图,改选尚未完成的主链项 +5. 汇报治理结果时,必须补一句“这一步如何服务路线图主线”;如果说不出来,就说明这一步不该先做 + ## 分类语言 治理默认使用这四类: diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index f7ea89faf..bb00350e7 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -129,6 +129,18 @@ npm run test:contracts 6. 点击 `确认生成` 7. 验证页面出现 `Theme Workbench` 或相关工作台内容 8. 再次检查控制台 error +9. 如能查看运行时摘要,继续确认当前 gate 与任务标题恢复自该话题最近一次 `execution_runtime.recent_gate_key / recent_run_title` + +### 浏览器工作台站点采集验证 + +1. 进入带有 browser assist 的工作区或浏览器运行时面板 +2. 打开 `站点采集工作台` 或对应调试面板 +3. 先确认推荐区已出现,并至少看到一个推荐适配器卡片 +4. 点击一个推荐项,确认适配器、资料提示和标签页提示同步变化 +5. 触发一次执行失败场景时,确认结果区展示业务级错误码与 `report_hint` +6. 如当前页面带有 `contentId` 上下文,再确认执行成功后默认是“写回当前主稿”,而不是新建资源文档 +7. 如工作台模式开启自动保存,再确认执行成功后保存态文案与打开入口正常 +8. 打开控制台并确认浏览器资料 / 环境预设读取没有落回 web mock,尤其不应出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd` ### 话题模型恢复验证 @@ -138,6 +150,71 @@ npm run test:contracts 4. 验证模型选择器恢复的是该话题最近一次 session runtime,而不是陈旧的 localStorage 默认值 5. 如页面暴露运行时摘要条,再确认 provider/model 文案与选择器一致 +### 话题工具偏好恢复验证 + +1. 进入同一工作区中的两个话题 +2. 分别切换 `联网 / 深度思考 / 任务模式 / 子代理` 开关组合 +3. 在两个话题之间来回切换,必要时新建一个空白话题再切回 +4. 验证工具开关恢复的是该话题最近一次 session runtime,而不是主题级 localStorage 默认值 +5. 如首次切回旧话题时只能命中 fallback,再继续切换一次,确认第二次开始已优先走 runtime 恢复 + +### 话题 Team 恢复验证 + +1. 进入同一工作区中的两个话题 +2. 在话题 A 里选择一个 builtin Team,在话题 B 里选择另一个 builtin 或 custom Team +3. 在两个话题之间来回切换,必要时新建一个空白话题再切回 +4. 验证 Team 选择器、摘要区和 Team Workbench 展示恢复的是该话题最近一次 `recent_team_selection`,而不是主题级 localStorage 的旧值 +5. 对 custom Team 额外确认:切回后 label / description / roles 没丢;如果本轮是从 fallback 回填,继续切换一次确认第二次开始已优先走 runtime 恢复 + +### 运行时交接制品验证 + +1. 进入带有 `HarnessStatusPanel` 的对话工作区,并确保当前话题已经拿到 `sessionId` +2. 展开 `交接制品` 区块,点击 `导出交接制品` +3. 验证区块内出现: + - 导出时间 + - 线程状态 / 最新 Turn 状态 + - Todo 统计 + - `plan / progress / handoff / review` 文件列表 +4. 继续点击单个制品的 `预览`,确认预览弹窗能打开,并能看到对应绝对路径 +5. 如页面桥接到了真实后端,再点击 `打开目录` 或单文件 `打开`,确认不会落回 mock,且工作区内确实生成 `.lime/harness/sessions//...` +6. 如果这轮继续开发问题证据包,再把同一条续测链扩展为“先导出 handoff,再导出 evidence pack”,确认两者目录与状态卡不会串线 +7. 如果这轮继续开发 replay 样本导出,再点击 `导出 Replay 样本`,确认: + - `input / expected / grader / evidence-links` 文件列表出现 + - replay 区块能显示 handoff / evidence 的关联根路径 + - 打开目录后工作区内确实生成 `.lime/harness/sessions//replay` +8. 如果这轮继续开发外部分析交接,再点击 `导出分析交接` 与 `一键复制给 AI`,确认: + - `analysis-brief.md / analysis-context.json` 文件列表出现 + - 复制内容直接来自后端 `copy_prompt`,不需要前端再手写 prompt + - analysis 区块能显示 handoff / evidence / replay 的关联目录 +9. 如果这轮继续开发人工审核记录,再点击 `导出人工审核记录`,确认: + - `review-decision.md / review-decision.json` 文件列表出现 + - 区块能显示默认状态、审核清单与关联 analysis 文件 + - 打开目录后工作区内确实生成 `.lime/harness/sessions//review` + +### 话题内容上下文恢复验证 + +1. 进入带 `contentId` 的工作台话题并完成至少一次发送 +2. 留在同一话题下再次发送,保持目标主稿不变 +3. 验证本轮仍写回当前主稿,没有误新建资源文档或切到其他内容 +4. 如能查看调试面板或运行时摘要,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_content_id`,而不是页面一次性参数或陈旧缓存 +5. 再切到另一个 `contentId` 后立即发送一次,确认同步窗口内仍能命中新主稿,而不是被旧 runtime 误覆盖 + +### 话题主题上下文恢复验证 + +1. 进入普通对话话题完成一次发送,再切到 `Theme Workbench` 话题完成一次发送 +2. 在两个话题之间来回切换,必要时新建一个空白话题再切回 +3. 验证 UI 恢复的是该话题最近一次主题上下文,而不是页面一次性参数或主题级缓存 +4. 如能查看调试面板或运行时摘要,继续确认依据是当前话题最近一次 `execution_runtime.recent_theme / recent_session_mode` +5. 再从普通对话切到新的 `theme_workbench` 后立即发送一次,确认同步窗口内仍命中新 theme / session mode,而不是被旧 runtime 误覆盖 + +### Theme Workbench 运行阶段恢复验证 + +1. 进入同一个 Theme Workbench 话题,至少完成一次 `write_mode` 或 `publish_confirm` 阶段发送 +2. 留在同一话题下再次发送,保持当前 gate 和任务标题不变 +3. 验证本轮仍衔接当前 gate / 任务标题,而不是掉回旧阶段或空标题 +4. 如能查看调试面板或运行时摘要,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_gate_key / recent_run_title` +5. 再切到新的 gate 或新的运行标题后立即发送一次,确认同步窗口内仍命中新 gate / run title,而不是被旧 runtime 误覆盖 + ### 服务型技能自动化交付链 1. 从首页进入服务型技能卡片 @@ -228,6 +305,7 @@ npm run test:contracts - 如果该命令属于浏览器模式可接受的降级能力,加入 mock 优先列表 - 如果该命令属于当前主路径必须能力,补真实 bridge +- 对浏览器资料 / 环境预设这类已桥接命令,优先排查真实 DevBridge 或默认种子,不要再把它们加回 mock 优先集合 ## 何时补 mock,何时补真实 bridge diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 8d121dfc0..14a9ca4f9 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -32,6 +32,19 @@ 4. **用户可见回归已补齐** - 用户可见 UI 改动有稳定断言或既有 snapshot 回归 5. **文档与锁文件不掉队** - 相关文档、schema、锁文件与实际实现保持一致 +## 路线图任务防跑偏 + +如果任务明确绑定路线图主线,质量校验除了回答“是否通过”,还必须回答“这次改动是否真的推进了路线图目标”。 + +执行时额外遵守: + +1. 校验前先确认本轮改动对应路线图哪一项 +2. 如果本轮改动只是清理 dead surface、补 README 或局部整理,但没有直接推进主链,不能把“校验通过”当作完成目标 +3. 汇报时必须同时给出: + - 本轮改动对应的路线图节点 + - 本轮校验覆盖了哪条主线风险 + - 当前距离该路线图阶段完成还差什么 + ## 执行硬规则 ### 1. 不要继续扩展 compat / deprecated 路径 @@ -120,6 +133,8 @@ npm run verify:gui-smoke - 启动或复用 `headless Tauri` - 等待 `DevBridge` 健康检查通过 - 验证默认 workspace 的准备态可用 +- 验证 `browser runtime` 的启动、状态读取与审计主链可用 +- 验证 `site adapter catalog` 的状态、列表与推荐主链可读 它解决的是 GUI 产品特有风险: @@ -142,13 +157,17 @@ npm run bridge:health -- --timeout-ms 120000 作用: - 检查前端命令调用与 Rust 注册表是否一致 +- 检查 harness metadata / execution runtime / 后端 request metadata 的关键字段是否漂移 - 检查浏览器桥接 / mock 优先路径是否同步 - 检查 `DevBridge` 是否可用 高频场景: - 修改 `safeInvoke` / `invoke` -- 修改 `agent_runtime_update_session` 或会话 provider/model 恢复语义 +- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_preferences / recent_team_selection 恢复语义 +- 修改 `execution_runtime.recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 +- 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_run_adapter` +- 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` @@ -172,19 +191,35 @@ npm run bridge:health -- --timeout-ms 120000 ## 改动类型与最低门槛 -| 改动类型 | 至少运行 | 额外要求 | -| ----------------------------------- | -------------------------------------------------- | ------------------------------------------- | -| 普通前端改动 | `npm run verify:local` | 如有用户可见变化,补稳定回归 | -| Tauri 命令 / Bridge / mock 改动 | `npm run verify:local`、`npm run test:contracts` | 必要时补 `npm run governance:legacy-report` | -| GUI 壳 / Workspace / 页面主路径改动 | `npm run verify:local`、`npm run verify:gui-smoke` | 必须补对应 UI 回归 | -| 配置结构改动 | `npm run verify:local` | 同步 schema、消费者、文档 | -| 版本相关改动 | `npm run verify:app-version` | 与发布配置一起核对 | -| Rust 模块改动 | 受影响 crate / 模块定向测试 | 再决定是否跑全量 `cargo test` | -| 真实页面交互验证 | 先跑 `npm run verify:gui-smoke` | 再进入 `playwright-e2e.md` | +| 改动类型 | 至少运行 | 额外要求 | +| ----------------------------------- | ------------------------------------------------------ | ------------------------------------------- | +| 普通前端改动 | `npm run verify:local` | 如有用户可见变化,补稳定回归 | +| Tauri 命令 / Bridge / mock 改动 | `npm run verify:local`、`npm run test:contracts` | 必要时补 `npm run governance:legacy-report` | +| GUI 壳 / Workspace / 页面主路径改动 | `npm run verify:local`、`npm run verify:gui-smoke` | 必须补对应 UI 回归 | +| 运行时 handoff / 证据包导出改动 | `npm run test:contracts`、相关 `vitest`、Rust 定向测试 | 如入口落在工作台 UI,再补最小 GUI 续测 | +| 配置结构改动 | `npm run verify:local` | 同步 schema、消费者、文档 | +| 版本相关改动 | `npm run verify:app-version` | 与发布配置一起核对 | +| Rust 模块改动 | 受影响 crate / 模块定向测试 | 再决定是否跑全量 `cargo test` | +| 真实页面交互验证 | 先跑 `npm run verify:gui-smoke` | 再进入 `playwright-e2e.md` | 补充说明: - 如果这次改动把 `ServiceSkill -> automation_job -> agent_turn` 接到 Artifact 主线,除了常规 `verify:local` / `test:contracts` 之外,还应至少补一条稳定回归,证明 `content_id + request_metadata.artifact` 没在表单编辑或执行链路里丢失。 +- 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: + - session 已有 `execution_runtime.recent_content_id` 时,前端不会重复提交相同 `harness.content_id` + - 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id` +- 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: + - session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode` + - 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode` +- 如果这次改动把 `gate_key / run_title` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: + - session 已有 `execution_runtime.recent_gate_key / recent_run_title` 时,前端不会重复提交相同 `harness.gate_key / harness.run_title` + - 切换到新的 Theme Workbench gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title` +- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。 +- 如果这次改动影响浏览器资料 / 环境预设的真实来源,还应补一次浏览器模式实测,确认控制台不再出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`。 +- 如果这次改动影响 `agent_runtime_export_handoff_bundle`、`agent_runtime_export_evidence_pack`、`agent_runtime_export_analysis_handoff`、`agent_runtime_export_review_decision_template` 或 `agent_runtime_export_replay_case` 这条 Harness 导出主链,除了契约检查,还应至少补: + - `src/lib/api/agent.test.ts` 一类的网关回归,确认仍走统一 `agent_runtime_*` 主命令 + - `HarnessStatusPanel.test.tsx` 一类的 UI 回归,确认导出入口、状态与制品展示正常 + - 受影响 Rust 服务 / 命令的定向测试,确认 `.lime/harness/sessions//...` 一类制品仍能生成 ## CI 事实源 @@ -223,6 +258,9 @@ npm run verify:local:full # GUI 最小冒烟 npm run verify:gui-smoke +npm run smoke:workspace-ready +npm run smoke:browser-runtime +npm run smoke:site-adapters # 前端 / 桥接 / 契约 npm test diff --git a/docs/develop/lime-borrow-codex-engineering-practices.md b/docs/develop/lime-borrow-codex-engineering-practices.md index 2f03dac17..dd817ba5b 100644 --- a/docs/develop/lime-borrow-codex-engineering-practices.md +++ b/docs/develop/lime-borrow-codex-engineering-practices.md @@ -15,6 +15,10 @@ 我们现在更需要的,不是继续堆更多脚本,而是把已有能力收敛成一条清晰、分层、可执行的开发与交付路径。 +如果要把这些工程方法进一步落实到 Lime 的 Agent / Harness 改造,请继续阅读: + +- `docs/tech/harness/README.md` + Codex 值得借鉴的,不是 Bazel、不是纯 Rust、也不是它的体量; 真正值得借鉴的是: diff --git a/docs/test/README.md b/docs/test/README.md index 51e129ccd..475aa8dc5 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -48,6 +48,7 @@ docs/test/ ├── integration-tests.md # 集成测试指南 ├── e2e-tests.md # 浏览器续测与 E2E 总览 ├── agent-evaluation.md # Agent 评估指南(核心文档) +├── harness-evals.md # Harness eval 任务集与 runner 入口 └── test-cases/ # 测试用例模板 ├── converter-tests.md # 协议转换器测试用例 ├── provider-tests.md # Provider 测试用例 @@ -56,17 +57,18 @@ docs/test/ ## 文档索引 -| 文档 | 说明 | 适用场景 | -| ---------------------------------------------------------------- | -------------------------- | ------------------------------------- | -| [testing-strategy-2026.md](testing-strategy-2026.md) | 当前 Lime 测试体系建设建议 | 建立分层门禁、规划演进 | -| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 | -| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 | -| [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 | -| [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 | -| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 | -| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 | -| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 | -| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 | +| 文档 | 说明 | 适用场景 | +| ---------------------------------------------------------------- | ---------------------------- | ------------------------------------- | +| [testing-strategy-2026.md](testing-strategy-2026.md) | 当前 Lime 测试体系建设建议 | 建立分层门禁、规划演进 | +| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 | +| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 | +| [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 | +| [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 | +| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 | +| [harness-evals.md](harness-evals.md) | Harness eval 任务集与 runner | Replay 样本、grader、nightly 摘要 | +| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 | +| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 | +| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 | ## 快速开始 @@ -106,6 +108,24 @@ npm run bridge:health -- --timeout-ms 120000 npm run smoke:workspace-ready ``` +### 运行 Harness eval 摘要 + +```bash +npm run harness:eval +``` + +### 提升工作区 Replay 为仓库样本 + +```bash +npm run harness:eval:promote -- --session-id "session-123" --slug "pending-request-runtime" +``` + +### 运行 Harness eval 趋势报告 + +```bash +npm run harness:eval:trend +``` + ### 当前浏览器续测入口 当前仓库的浏览器模式 E2E / 续测文档分两层: diff --git a/docs/test/harness-evals.manifest.json b/docs/test/harness-evals.manifest.json new file mode 100644 index 000000000..7dd333d3f --- /dev/null +++ b/docs/test/harness-evals.manifest.json @@ -0,0 +1,89 @@ +{ + "manifestVersion": "v1", + "title": "Lime Harness Eval Manifest", + "defaults": { + "requiredArtifacts": [ + "input.json", + "expected.json", + "grader.md", + "evidence-links.json" + ], + "requiredInputFields": [ + "session.sessionId", + "session.threadId", + "task.goalSummary", + "classification.suiteTags", + "classification.failureModes", + "linkedArtifacts.handoffBundle.relativeRoot", + "linkedArtifacts.evidencePack.relativeRoot" + ], + "requiredExpectedFields": [ + "successCriteria", + "blockingChecks", + "artifactChecks", + "graderSuggestion.preferredMode" + ], + "requiredEvidenceFields": [ + "handoffBundle.relativeRoot", + "evidencePack.relativeRoot" + ] + }, + "suites": [ + { + "id": "repo-fixtures", + "title": "仓库固定 Replay 样本", + "priority": "P0", + "roadmap": "P3-2 Eval runner", + "description": "固定一条可在 CI 和 nightly 中稳定运行的 replay fixture,先验证 grader 合同与样本结构,而不是等真实工作区样本才能开始。", + "upstream": { + "codex": "沿用 Codex evidence-first 的 replay / grader 形状。", + "aster": "沿用 Aster runtime / thread / turn 的会话事实边界。", + "lime": "把样本、脚本和摘要统一落在 Lime 仓库 current 主线。" + }, + "cases": [ + { + "id": "fixture-minimal-pending-request", + "title": "最小 pending request Replay 样本", + "source": "repo_fixture", + "caseDir": "docs/test/harness-fixtures/replay/minimal-pending-request", + "tags": ["conversation-runtime", "replay", "handoff", "evidence"] + } + ] + }, + { + "id": "repo-promoted-replays", + "title": "仓库沉淀 Replay 样本", + "priority": "P1", + "roadmap": "P3-6 Replay 样本沉淀", + "description": "把工作区导出的高价值 replay case 提升为仓库 current 样本,用于固定回归入口与 nightly 趋势对比。", + "upstream": { + "codex": "沿用 Codex 把真实失败沉淀为 replay 资产的做法。", + "aster": "继续复用 Aster runtime 导出的 thread / turn / evidence 边界。", + "lime": "由 Lime 持有 promotion 命令、repo fixture 目录与 current manifest。" + }, + "cases": [] + }, + { + "id": "workspace-replay-discovery", + "title": "工作区 Replay 自动发现", + "priority": "P1", + "roadmap": "P3-2 Eval runner", + "description": "扫描当前工作区 `.lime/harness/sessions/*/replay`,把真实会话导出的 replay case 接进固定摘要格式。", + "upstream": { + "codex": "参考 Codex 把真实失败沉淀为 replay / eval 样本的习惯。", + "aster": "复用 Aster session runtime 导出的 thread / turn 上下文。", + "lime": "让 Lime 的 handoff bundle 与 evidence pack 成为 eval 的事实源。" + }, + "cases": [ + { + "id": "workspace-session-replays", + "title": "工作区会话 Replay 样本", + "source": "workspace_replay_discovery", + "root": ".lime/harness/sessions", + "allowZeroMatches": true, + "tags": ["workspace", "replay", "runtime-export"] + } + ] + } + ] +} diff --git a/docs/test/harness-evals.md b/docs/test/harness-evals.md new file mode 100644 index 000000000..15f276065 --- /dev/null +++ b/docs/test/harness-evals.md @@ -0,0 +1,230 @@ +# Lime Harness Evals + +> 面向 Lime `P3-6 Replay 样本沉淀` 的 current 事实源 +> 目标:把 replay 样本、grader 合同、仓库固定任务集与 nightly 摘要,收口到一条可执行主链。 + +## 先给结论 + +Lime 当前不直接把“真实模型重放平台”一次做完,而是先固定四件事: + +1. **固定任务集入口** + 由 [harness-evals.manifest.json](harness-evals.manifest.json) 持有机可读任务清单。 + +2. **固定样本形状** + 所有 replay case 统一要求最小四件套: + - `input.json` + - `expected.json` + - `grader.md` + - `evidence-links.json` + 其中 `input.json` 继续承载 `classification.suiteTags` 与 `classification.failureModes`。 + +3. **固定摘要出口** + 由 `scripts/harness-eval-runner.mjs` 统一产出 JSON / Markdown 摘要,后续 nightly 与趋势报表都从这里接。 + +4. **固定趋势入口** + 由 `scripts/harness-eval-trend-report.mjs` 把一个或多个 summary JSON 聚合成 trend 报告。 + +这一步对应 Harness 路线图里的 `P3-2 Eval runner`,不是终点,但它把“评估理念”升级成了仓库内可执行入口。 + +## 为什么这一步要先做 + +如果没有固定 manifest 和 runner,Lime 当前的 replay 样本会停留在“可以导出”,却还不能稳定回答: + +- 当前有哪些回放样本可以复用 +- 哪些样本结构不完整 +- grader 需要哪些输入字段 +- nightly 应该上传什么摘要 + +先把这层收口,后面的真实模型评估、trend 报表、熵管理和清理才有统一入口。 + +## 三层来源挂载 + +| 层次 | 作用 | 当前落点 | +| ------------ | --------------------------------------------------------------------- | --------------------------------------------------------------- | +| `codex-rs` | 提供 replay / grader / evidence-first 的形状参照 | manifest 中的 replay case 四件套与评分原则 | +| `aster-rust` | 提供 thread / turn / runtime / telemetry 的事实边界 | `input.json` 中的 session / thread / turn / runtimeContext 结构 | +| `lime` | 持有产品层 handoff bundle、evidence pack、workspace `.lime/` 样本目录 | runner、fixture、nightly 摘要与工作区发现逻辑 | + +一句话: + +**Codex 决定评估形状,Aster 决定运行时事实边界,Lime 负责把 replay case、grader 和 nightly 摘要落到 current 主链。** + +## 当前任务集 + +当前 manifest 默认分三条 suite: + +1. **仓库固定 Replay 样本** + - 用于 CI / nightly 的稳定入口 + - 当前固定一条 fixture: + - `fixture-minimal-pending-request` + - 目的不是替代真实会话,而是先验证 grader 合同、字段预算和摘要出口不会漂移 + +2. **仓库沉淀 Replay 样本** + - 用于把高价值真实失败从工作区提升为仓库 current 资产 + - 默认进入 `repo-promoted-replays` suite + - 由 `scripts/harness-replay-promote.mjs` 负责: + - 复制最小四件套 + - 把绝对工作区路径脱敏为稳定占位路径 + - 回写 manifest case + - 目的不是把所有会话都进仓,而是把“值得长期回归”的失败收进固定任务集 + +3. **工作区 Replay 自动发现** + - 扫描 `.lime/harness/sessions/*/replay` + - 自动把真实导出的 replay case 纳入统一摘要 + - 默认允许零样本,避免没有本地会话时误报失败 + +这是一种“固定入口、允许样本增长”的设计: + +- 入口是固定的 +- 样本既可以来自仓库 fixture,也可以来自已沉淀的 current case,还可以来自真实工作区导出 +- 不需要为每个新 session 再发明一套单独脚本 + +## Runner 做什么 + +`scripts/harness-eval-runner.mjs` 当前负责四件事: + +1. 读取 manifest +2. 解析固定 fixture 与工作区自动发现 case +3. 校验 replay case 最小四件套与关键 JSON 字段 +4. 输出统一 JSON / Markdown 摘要,并聚合 `suite tag / failure mode` 分布 + +当前它**不直接执行真实模型重放**,而是先把“样本是否可评估、摘要是否可归档”工程化。 + +这符合 Lime 当前阶段的约束: + +- 先复用现有 `handoff bundle + evidence pack + replay export` +- 不引入第二套总控平台 +- 先把仓库 fixture、repo current 样本和工作区 replay case 变成稳定资产 + +## 如何把真实 Replay 提升为 current 样本 + +当某个工作区 replay case 已经足够稳定、足够重要,应该把它从“工作区临时样本”提升到“仓库固定样本”。当前主入口: + +```bash +npm run harness:eval:promote -- \ + --session-id "session-123" \ + --slug "pending-request-runtime" \ + --title "Pending request 会话不会被误判为完成" +``` + +也可以直接指定 replay 目录: + +```bash +node scripts/harness-replay-promote.mjs \ + --replay-dir ".lime/harness/sessions/session-123/replay" \ + --slug "pending-request-runtime" +``` + +这个命令会做四件事: + +1. 读取 replay 最小四件套。 +2. 把工作区绝对路径脱敏成稳定占位路径,避免把本机路径直接写进仓库。 +3. 把样本复制到 `docs/test/harness-fixtures/replay//`。 +4. 把 case 回写到 `repo-promoted-replays` suite,成为 nightly 与 trend 的 current 样本。 + +默认原则: + +- 不是每个 replay 都要 promotion,只提升高价值、可重复、能代表失败模式的样本。 +- promotion 之后,样本不再只是“本机能看到”,而是仓库 current 主线的一部分。 +- 仓库沉淀样本仍然复用原来的 handoff / evidence 形状,不另造 schema。 + +## Trend Report 做什么 + +`scripts/harness-eval-trend-report.mjs` 当前负责三件事: + +1. 读取一个或多个 `harness eval summary` JSON +2. 生成 baseline / latest 对比、suite 级 delta,以及 `suite tag / failure mode` 聚合变化 +3. 输出 JSON / Markdown 趋势报告 + +如果没有显式提供输入,它会先调用 `harness-eval-runner` 生成当前 summary,再把它当作第一条 trend seed。 + +这一步的目的不是假装已经有完整历史,而是先把: + +- trend 报告字段 +- nightly 报告出口 +- baseline / latest / suite delta 的最小合同 + +固定下来。 + +当前 nightly 还会恢复并追加 `artifacts/history/*.json` 历史窗口,用于让 trend 不只停留在单次 seed。 + +## 常用命令 + +```bash +# 人类可读摘要 +npm run harness:eval + +# JSON 输出,适合脚本和 CI 消费 +npm run harness:eval:json + +# 把工作区 replay 提升为仓库 current 样本 +npm run harness:eval:promote -- --session-id "session-123" --slug "pending-request-runtime" + +# 生成当前趋势报告;若没有历史输入,会先生成当前 summary 作为 trend seed +npm run harness:eval:trend + +# 指定工作区根目录扫描真实 replay 样本 +node scripts/harness-eval-runner.mjs --workspace-root "/path/to/workspace" + +# 生成 nightly 可上传的双格式摘要 +node scripts/harness-eval-runner.mjs \ + --output-json "./tmp/harness-eval-summary.json" \ + --output-markdown "./tmp/harness-eval-summary.md" + +# 从历史 summary 目录生成趋势报告 +node scripts/harness-eval-trend-report.mjs \ + --history-dir "./artifacts/history" \ + --output-json "./tmp/harness-eval-trend.json" \ + --output-markdown "./tmp/harness-eval-trend.md" +``` + +## 输出摘要里应该看什么 + +Runner 摘要至少回答下面这些问题: + +- 总共有多少 suite / case +- 有多少 case 已经 ready +- 哪些 case 缺文件 +- 哪些 case JSON 字段不完整 +- 哪些 case 属于什么 suite tag / failure mode +- 哪些 case 默认需要人工复核 +- 工作区 replay 是否已经开始形成增量样本 + +如果摘要回答不了这些问题,就说明 runner 还不算进入 current 主链。 + +Trend 报告至少还要回答: + +- baseline 和 latest 之间,ready / invalid / pending request 有没有变化 +- 哪些 suite 在 latest 里变差了 +- 哪些 failure mode / suite tag 在 latest 里增长或退化了 +- 当前只有 trend seed,还是已经开始形成真正的历史窗口 + +## 与其他事实源的关系 + +| 文档 / 文件 | 角色 | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| [agent-evaluation.md](agent-evaluation.md) | 解释评估原则、pass@k / pass^k、grader 类型 | +| [testing-strategy-2026.md](testing-strategy-2026.md) | 解释为什么 eval 工程化排在 smoke 之后 | +| [../tech/harness/implementation-blueprint.md](../tech/harness/implementation-blueprint.md) | 解释 `P3-2 Eval runner` 在 Harness 主线中的位置 | +| [../tech/harness/tooling-roadmap.md](../tech/harness/tooling-roadmap.md) | 解释 runner、nightly、trend 的后续工具面 | +| `scripts/harness-eval-runner.mjs` | 当前唯一的 runner 入口 | +| `scripts/harness-eval-trend-report.mjs` | 当前 trend 聚合与 nightly 趋势出口 | +| [harness-evals.manifest.json](harness-evals.manifest.json) | 当前任务集与 suite 机可读事实源 | + +## 下一刀 + +`P3-6` 做完之后,下一刀优先级建议固定为: + +1. 把分类聚合直接挂到熵治理清单,形成 replay 驱动 cleanup 主线 +2. 继续补 observability 证据字段,让 grader 能消费更多 request / timeline / artifact 关联 +3. 逐步提高 repo current 样本质量,而不是只增加数量 +4. 再考虑是否引入真实模型执行或 transcript grading + +## 非目标 + +当前阶段默认不做: + +- 不把 runner 变成第二套 CI 总控 +- 不要求所有 replay case 都进仓库版本控制 +- 不在这一刀里直接引入真实模型调用成本 +- 不绕开 `handoff bundle / evidence pack / replay export` 另造样本格式 diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json b/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json new file mode 100644 index 000000000..83007b1b8 --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/evidence-links.json @@ -0,0 +1,43 @@ +{ + "replayCaseVersion": "v1", + "exportedAt": "2026-03-27T11:30:00Z", + "handoffBundle": { + "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff", + "absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-minimal-pending-request/handoff", + "artifacts": [ + { + "kind": "plan", + "title": "执行计划", + "relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/plan.md" + }, + { + "kind": "handoff", + "title": "交接摘要", + "relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md" + } + ] + }, + "evidencePack": { + "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence", + "absoluteRoot": "/workspace/lime/.lime/harness/sessions/fixture-session-minimal-pending-request/evidence", + "knownGaps": [ + "当前 fixture 不包含真实浏览器快照,只验证 replay 样本结构与 grader 约定。" + ], + "artifacts": [ + { + "kind": "summary", + "title": "证据摘要", + "relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence/summary.md" + }, + { + "kind": "runtime", + "title": "运行时快照", + "relativePath": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence/runtime.json" + } + ] + }, + "recentArtifacts": [ + "docs/tech/harness/implementation-blueprint.md", + ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md" + ] +} diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json b/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json new file mode 100644 index 000000000..0d1c52d2c --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/expected.json @@ -0,0 +1,28 @@ +{ + "replayCaseVersion": "v1", + "exportedAt": "2026-03-27T11:30:00Z", + "sessionId": "fixture-session-minimal-pending-request", + "threadId": "fixture-thread-minimal-pending-request", + "goalSummary": "确认评估链不会把仍存在 approval request 的会话误判为已完成。", + "successCriteria": [ + "评分结果必须明确说明 pending request 是否已解决、保留还是不影响结论。", + "若沿用 handoff bundle 与 evidence pack,结论必须引用至少一条证据来源。", + "不得因为工具调用路径不同而直接判失败。" + ], + "blockingChecks": [ + "确认 approval-fixture-001 是否仍处于待处理状态。", + "确认 `waiting_request` 不会被误判为 `completed`。" + ], + "artifactChecks": [ + "确认 `.lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md` 仍与目标一致。", + "确认 evidence pack 中记录的 known gaps 没有被当作 PASS 证据。" + ], + "nonGoals": [ + "不要要求与原始会话完全相同的工具调用顺序。", + "不要把措辞差异当作失败,除非它改变了交付结果或风险判断。" + ], + "graderSuggestion": { + "preferredMode": "result_artifact_and_request_resolution", + "requiresHumanReview": false + } +} diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/grader.md b/docs/test/harness-fixtures/replay/minimal-pending-request/grader.md new file mode 100644 index 000000000..8be5cf8f8 --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/grader.md @@ -0,0 +1,37 @@ +# Replay Case 评分说明 + +- 会话:`fixture-session-minimal-pending-request` +- 线程:`fixture-thread-minimal-pending-request` +- 导出时间:2026-03-27T11:30:00Z +- 目标摘要:确认评估链不会把仍存在 approval request 的会话误判为已完成。 + +## 建议读取顺序 + +1. 先读 `input.json`,理解当前任务与运行时上下文。 +2. 再读 `expected.json`,确认只评估结果与风险。 +3. 再读 `evidence-links.json`,跳转到已有证据源。 +4. 如需补证据,优先回看 handoff bundle 与 evidence pack。 + +## 评分原则 + +- 只评结果,不评路径。 +- 先证据后结论;没有证据支撑的 PASS 不成立。 +- 如仍存在 pending request,必须解释它是已处理、仍保留,还是不影响判定。 + +## 最小通过条件 + +- 结果必须解释 pending request 的处理状态。 +- 结果必须引用 handoff 或 evidence 中的至少一条证据。 +- 不得把 `waiting_request` 误判成 `completed`。 + +## 建议输出模板 + +```text +verdict: pass | fail | needs_review +reason: +- ... +evidence: +- ... +risks: +- ... +``` diff --git a/docs/test/harness-fixtures/replay/minimal-pending-request/input.json b/docs/test/harness-fixtures/replay/minimal-pending-request/input.json new file mode 100644 index 000000000..7792f35ca --- /dev/null +++ b/docs/test/harness-fixtures/replay/minimal-pending-request/input.json @@ -0,0 +1,102 @@ +{ + "replayCaseVersion": "v1", + "source": "lime.fixture.replay_case", + "exportedAt": "2026-03-27T11:30:00Z", + "session": { + "sessionId": "fixture-session-minimal-pending-request", + "threadId": "fixture-thread-minimal-pending-request", + "workspaceId": "fixture-workspace", + "workspaceRoot": "/workspace/lime", + "model": "fixture-model", + "executionStrategy": "agent_runtime" + }, + "task": { + "goalSummary": "确认评估链不会把仍存在 approval request 的会话误判为已完成。", + "latestPlan": "先导出 handoff bundle,再导出 evidence pack,最后生成 replay case。", + "latestTurnSummary": "当前会话已生成 replay case,但还有一个待处理审批请求。", + "latestTurnPrompt": "请把这次 pending request 会话导出为可回放样本。", + "latestTurnId": "turn-fixture-001", + "latestTurnStatus": "action_required", + "threadStatus": "waiting_request", + "primaryBlockingSummary": "存在待审批写文件请求,需要在评分时显式说明是否已处理。" + }, + "classification": { + "sourceKind": "repo_fixture", + "suiteTags": [ + "conversation-runtime", + "replay", + "pending-request", + "handoff", + "evidence" + ], + "failureModes": ["pending_request", "unfinished_todo"], + "primaryBlockingKind": "pending_request" + }, + "runtimeContext": { + "pendingRequests": [ + { + "requestId": "approval-fixture-001", + "requestType": "approval_request", + "title": "允许写入 replay 样本目录", + "actionType": "write_file", + "prompt": "需要确认是否允许写入 `.lime/harness/sessions/.../replay`。" + } + ], + "queuedTurns": [], + "todoItems": [ + { + "content": "导出 replay case", + "status": "completed" + }, + { + "content": "评估 pending request 风险", + "status": "in_progress" + } + ], + "activeSubagents": [], + "recentArtifacts": [ + "docs/tech/harness/implementation-blueprint.md", + ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff/handoff.md" + ], + "recentTimeline": [ + { + "itemId": "timeline-fixture-001", + "turnId": "turn-fixture-001", + "payloadKind": "plan", + "status": "completed", + "summary": "产出 replay 导出计划", + "updatedAt": "2026-03-27T11:20:00Z" + }, + { + "itemId": "timeline-fixture-002", + "turnId": "turn-fixture-001", + "payloadKind": "approval_request", + "status": "pending", + "summary": "等待写文件审批", + "updatedAt": "2026-03-27T11:24:00Z" + } + ], + "lastOutcome": { + "thread_id": "fixture-thread-minimal-pending-request", + "turn_id": "turn-fixture-001", + "outcome_type": "partial_success", + "summary": "handoff 与 evidence 已导出,replay 样本待审批确认", + "primary_cause": "pending_request", + "retryable": true + }, + "incidents": [] + }, + "linkedArtifacts": { + "handoffBundle": { + "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/handoff", + "artifactCount": 4 + }, + "evidencePack": { + "relativeRoot": ".lime/harness/sessions/fixture-session-minimal-pending-request/evidence", + "artifactCount": 4, + "knownGaps": [ + "当前 fixture 不包含真实浏览器快照,只验证 replay 样本结构与 grader 约定。" + ] + } + } +} diff --git a/docs/test/testing-strategy-2026.md b/docs/test/testing-strategy-2026.md index 5a6a79177..f075bb067 100644 --- a/docs/test/testing-strategy-2026.md +++ b/docs/test/testing-strategy-2026.md @@ -45,10 +45,10 @@ ## 3. 当前仍未解决的问题优先级 -| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 | -| ------ | --------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| P0 | 自包含 smoke 仍然不足 | 单测很多,但主链路仍缺少无需人工准备的自动回归 | 目前仅有 `smoke:workspace-ready` 属于自包含 smoke;`smoke:social-workbench` 仍依赖已有 session,`bridge:e2e` 更像排障脚本 | 至少补齐 3 条无需人工准备的 smoke;当前已完成 1 条,仍需补 server / terminal / browser runtime 等 2 条以上 | -| P1 | Agent eval 尚未工程化 | 价值高,但建立在前面基础门禁稳定之后 | 仓库已有理念和局部真实测试,但缺少任务集、grader、nightly 报表 | 形成固定任务集、采样归档、grader、nightly 输出与趋势指标 | +| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 | +| ------ | ------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| P0 | 自包含 smoke 仍然不足 | 单测很多,但主链路仍缺少无需人工准备的自动回归 | 目前仅有 `smoke:workspace-ready` 属于自包含 smoke;`smoke:social-workbench` 仍依赖已有 session,`bridge:e2e` 更像排障脚本 | 至少补齐 3 条无需人工准备的 smoke;当前已完成 1 条,仍需补 server / terminal / browser runtime 等 2 条以上 | +| P1 | Agent eval 仍未完全工程化 | 价值高,但建立在前面基础门禁稳定之后 | 已补 `docs/test/harness-evals.md`、`harness-evals.manifest.json`、`scripts/harness-eval-runner.mjs`、`scripts/harness-eval-trend-report.mjs` 与 nightly 摘要 / trend 骨架,但真实执行与更多高价值样本仍缺 | 形成稳定任务集、可增长 replay 样本、grader、nightly 输出与趋势指标 | ## 4. 建议执行顺序 @@ -70,12 +70,17 @@ - 有稳定契约检查 - 有可重复 smoke -完成后再上: +当前已先补: + +- 固定 manifest 与 replay fixture +- runner 摘要出口 +- nightly artifact 与 trend 骨架 + +后续再继续补: -- 固定任务集 - transcript 存档 -- grader -- nightly 报表 +- 更多真实高价值 replay 样本 +- 更长窗口的趋势报表 ## 5. 当前建议 diff --git a/extensions/lime-chrome/background.js b/extensions/lime-chrome/background.js index 36ba90ee4..f955c42d9 100644 --- a/extensions/lime-chrome/background.js +++ b/extensions/lime-chrome/background.js @@ -242,7 +242,7 @@ async function executeRemoteCommand(commandData) { return; } - const tabId = await resolveTargetTabId(); + const tabId = await resolveCommandTargetTabId(commandData.target); if (!tabId) { sendCommandResult({ requestId, @@ -290,6 +290,26 @@ async function executeRemoteCommand(commandData) { } } +async function resolveCommandTargetTabId(rawTarget) { + const normalizedTarget = String(rawTarget || "").trim(); + if (!normalizedTarget) { + return await resolveTargetTabId(); + } + + const byId = Number(normalizedTarget); + if (Number.isInteger(byId) && byId > 0) { + try { + const tab = await chrome.tabs.get(byId); + if (tab?.id) { + activeTabId = tab.id; + return tab.id; + } + } catch (_) {} + } + + return await resolveTargetTabId(); +} + async function handleOpenUrl(commandData, waitForPageInfo) { const requestId = commandData.requestId; const sourceClientId = commandData.sourceClientId; diff --git a/package.json b/package.json index 2cdfe5afb..b51e5ca35 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.96.0", + "version": "0.97.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -20,7 +20,7 @@ "preview": "vite preview", "tauri": "tauri", "tauri:dev": "CARGO_TARGET_DIR=target tauri dev", - "tauri:dev:headless": "CARGO_TARGET_DIR=src-tauri/target tauri dev --config src-tauri/tauri.conf.headless.json", + "tauri:dev:headless": "CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", "tauri:dev:nowatch": "CARGO_TARGET_DIR=target tauri dev --no-watch", "tauri:dev:profile:trace": "node scripts/run-tauri-profile.mjs trace", "tauri:dev:profile:trace:devtools": "node scripts/run-tauri-profile.mjs trace --open-devtools", @@ -37,8 +37,14 @@ "test:watch": "vitest", "test:frontend": "npm run lint && npm run typecheck && npm test", "test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts", - "test:contracts": "node scripts/check-command-contracts.mjs", + "test:contracts": "node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs", "test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"", + "harness:analysis": "node scripts/harness-analysis-brief.mjs", + "harness:eval": "node scripts/harness-eval-runner.mjs", + "harness:eval:json": "node scripts/harness-eval-runner.mjs --format json", + "harness:eval:promote": "node scripts/harness-replay-promote.mjs", + "harness:eval:trend": "node scripts/harness-eval-trend-report.mjs", + "harness:eval:trend:json": "node scripts/harness-eval-trend-report.mjs --format json", "lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"", "detect-translations": "tsx scripts/detect-missing-translations.ts", "detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix", @@ -56,6 +62,8 @@ "bridge:e2e": "node scripts/chrome-bridge-e2e.mjs", "bridge:health": "node scripts/check-dev-bridge-health.mjs", "smoke:workspace-ready": "node scripts/workspace-ready-smoke.mjs", + "smoke:browser-runtime": "node scripts/browser-runtime-smoke.mjs", + "smoke:site-adapters": "node scripts/site-adapter-catalog-smoke.mjs", "smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs", "dev:web-bridge": "node scripts/start-web-bridge-dev.mjs", "governance:legacy-report": "node scripts/report-legacy-surfaces.mjs", diff --git a/scripts/browser-runtime-smoke.mjs b/scripts/browser-runtime-smoke.mjs new file mode 100644 index 000000000..1ae9188a6 --- /dev/null +++ b/scripts/browser-runtime-smoke.mjs @@ -0,0 +1,293 @@ +#!/usr/bin/env node + +import process from "node:process"; + +const DEFAULTS = { + healthUrl: "http://127.0.0.1:3030/health", + invokeUrl: "http://127.0.0.1:3030/invoke", + timeoutMs: 90_000, + intervalMs: 1_000, + launchUrl: "about:blank", + openWindow: false, + streamMode: "both", +}; + +function printHelp() { + console.log(` +Lime Browser Runtime Smoke + +用途: + 验证 browser runtime 最短主链可用:启动会话、读取状态、执行最小动作,并确认审计日志带出 session / target 关联键。 + +用法: + node scripts/browser-runtime-smoke.mjs [选项] + +选项: + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke + --timeout-ms 等待健康检查超时,默认 90000 + --interval-ms 健康检查轮询间隔,默认 1000 + --launch-url 启动浏览器会话的 URL,默认 about:blank + --open-window 显式打开浏览器窗口 + --stream-mode events | frames | both,默认 both + -h, --help 显示帮助 +`); +} + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--health-url" && argv[index + 1]) { + options.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--invoke-url" && argv[index + 1]) { + options.invokeUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--timeout-ms" && argv[index + 1]) { + options.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--interval-ms" && argv[index + 1]) { + options.intervalMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--launch-url" && argv[index + 1]) { + options.launchUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--stream-mode" && argv[index + 1]) { + options.streamMode = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--open-window") { + options.openWindow = true; + continue; + } + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) { + throw new Error("--timeout-ms 必须是 >= 1000 的数字"); + } + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + if (!["events", "frames", "both"].includes(options.streamMode)) { + throw new Error("--stream-mode 只支持 events / frames / both"); + } + if (!options.launchUrl) { + throw new Error("--launch-url 不能为空"); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function invoke(invokeUrl, cmd, args) { + const response = await fetch(invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload?.error) { + throw new Error(String(payload.error)); + } + + return payload?.result; +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl, { method: "GET" }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log( + `[smoke:browser-runtime] DevBridge 已就绪 (${Date.now() - startedAt}ms)${ + payload?.status ? ` status=${payload.status}` : "" + }`, + ); + return; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[smoke:browser-runtime] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`, + ); +} + +function findLatestAudit(logs, matcher) { + return (logs || []).find((item) => matcher(item)); +} + +async function main() { + if (typeof fetch !== "function") { + throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); + } + + const options = parseArgs(process.argv.slice(2)); + await waitForHealth(options); + + const profileKey = `smoke-browser-runtime-${Date.now()}`; + let sessionId = null; + + try { + const launchResponse = await invoke(options.invokeUrl, "launch_browser_session", { + request: { + profile_key: profileKey, + url: options.launchUrl, + open_window: options.openWindow, + stream_mode: options.streamMode, + }, + }); + + sessionId = launchResponse?.session?.session_id ?? null; + assert( + typeof sessionId === "string" && sessionId.trim(), + "launch_browser_session 未返回 session.session_id", + ); + assert( + launchResponse?.session?.profile_key === profileKey, + "launch_browser_session 返回的 profile_key 与请求不一致", + ); + + const sessionState = await invoke( + options.invokeUrl, + "get_browser_session_state", + { + request: { + session_id: sessionId, + }, + }, + ); + assert( + sessionState?.session_id === sessionId, + "get_browser_session_state 返回的 session_id 不一致", + ); + assert( + sessionState?.profile_key === profileKey, + "get_browser_session_state 返回的 profile_key 不一致", + ); + assert( + typeof sessionState?.target_id === "string" && sessionState.target_id.trim(), + "get_browser_session_state 未返回 target_id", + ); + + const actionResult = await invoke(options.invokeUrl, "browser_execute_action", { + request: { + profile_key: profileKey, + action: "read_page", + timeout_ms: 20_000, + }, + }); + assert(actionResult?.success === true, "browser_execute_action(read_page) 未成功"); + assert( + actionResult?.session_id === sessionId, + "browser_execute_action 未返回对应的 session_id", + ); + assert( + actionResult?.target_id === sessionState.target_id, + "browser_execute_action 未返回对应的 target_id", + ); + + const auditLogs = await invoke(options.invokeUrl, "get_browser_action_audit_logs", { + limit: 10, + }); + const launchAudit = findLatestAudit( + auditLogs, + (item) => + item?.kind === "launch" && + item?.profile_key === profileKey && + item?.session_id === sessionId, + ); + assert(launchAudit, "未找到对应的 launch audit 记录"); + assert( + launchAudit?.target_id === sessionState.target_id, + "launch audit 缺少 target_id 关联键", + ); + + const actionAudit = findLatestAudit( + auditLogs, + (item) => + item?.kind === "action" && + item?.action === "read_page" && + item?.profile_key === profileKey, + ); + assert(actionAudit, "未找到对应的 action audit 记录"); + assert( + actionAudit?.session_id === sessionId, + `action audit 缺少 session_id 关联键,record=${actionAudit?.id ?? "unknown"}`, + ); + assert( + actionAudit?.target_id === sessionState.target_id, + `action audit 缺少 target_id 关联键,record=${actionAudit?.id ?? "unknown"}`, + ); + + console.log( + `[smoke:browser-runtime] 通过 session=${sessionId} target=${sessionState.target_id} profile=${profileKey}`, + ); + } finally { + if (sessionId) { + try { + await invoke(options.invokeUrl, "close_cdp_session", { + request: { + session_id: sessionId, + }, + }); + } catch (error) { + console.warn( + `[smoke:browser-runtime] 清理会话失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/check-harness-contracts.mjs b/scripts/check-harness-contracts.mjs new file mode 100644 index 000000000..749d05220 --- /dev/null +++ b/scripts/check-harness-contracts.mjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); + +function readSource(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +function assertMatch(source, pattern, message, failures) { + if (!pattern.test(source)) { + failures.push(message); + } +} + +function assertIncludes(source, needle, message, failures) { + if (!source.includes(needle)) { + failures.push(message); + } +} + +function assertNotMatch(source, pattern, message, failures) { + if (pattern.test(source)) { + failures.push(message); + } +} + +function extractBalancedBlock(sourceCode, marker, openChar, closeChar) { + const markerIndex = sourceCode.indexOf(marker); + if (markerIndex < 0) { + throw new Error(`未找到标记: ${marker}`); + } + + const openIndex = sourceCode.indexOf(openChar, markerIndex); + if (openIndex < 0) { + throw new Error(`标记后未找到 ${openChar}: ${marker}`); + } + + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inTemplateString = false; + let inLineComment = false; + let inBlockComment = false; + let escaped = false; + + for (let index = openIndex; index < sourceCode.length; index += 1) { + const currentChar = sourceCode[index]; + const nextChar = sourceCode[index + 1]; + + if (inLineComment) { + if (currentChar === "\n") { + inLineComment = false; + } + continue; + } + + if (inBlockComment) { + if (currentChar === "*" && nextChar === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + + if (inSingleQuote) { + if (!escaped && currentChar === "'") { + inSingleQuote = false; + } + escaped = !escaped && currentChar === "\\"; + continue; + } + + if (inDoubleQuote) { + if (!escaped && currentChar === '"') { + inDoubleQuote = false; + } + escaped = !escaped && currentChar === "\\"; + continue; + } + + if (inTemplateString) { + if (!escaped && currentChar === "`") { + inTemplateString = false; + } + escaped = !escaped && currentChar === "\\"; + continue; + } + + if (currentChar === "/" && nextChar === "/") { + inLineComment = true; + index += 1; + continue; + } + + if (currentChar === "/" && nextChar === "*") { + inBlockComment = true; + index += 1; + continue; + } + + if (currentChar === "'") { + inSingleQuote = true; + escaped = false; + continue; + } + + if (currentChar === '"') { + inDoubleQuote = true; + escaped = false; + continue; + } + + if (currentChar === "`") { + inTemplateString = true; + escaped = false; + continue; + } + + if (currentChar === openChar) { + depth += 1; + continue; + } + + if (currentChar === closeChar) { + depth -= 1; + if (depth === 0) { + return sourceCode.slice(openIndex + 1, index); + } + } + } + + throw new Error(`无法提取 ${marker} 的平衡块`); +} + +function main() { + const failures = []; + const harnessMetadataPath = + "src/components/agent/chat/utils/harnessRequestMetadata.ts"; + const executionRuntimePath = + "src/components/agent/chat/utils/sessionExecutionRuntime.ts"; + const requestMetadataPath = + "src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs"; + + const harnessMetadataSource = readSource(harnessMetadataPath); + const executionRuntimeSource = readSource(executionRuntimePath); + const requestMetadataSource = readSource(requestMetadataPath); + + const legacyKeysBlock = extractBalancedBlock( + harnessMetadataSource, + "const LEGACY_HARNESS_STATE_KEYS = [", + "[", + "]", + ); + const metadataBuilderBlock = extractBalancedBlock( + harnessMetadataSource, + "const metadata: Record = {", + "{", + "}", + ); + + const requiredMetadataKeys = [ + "preferences:", + "preferred_team_preset_id:", + "selected_team_id:", + "selected_team_source:", + "selected_team_label:", + "selected_team_description:", + "selected_team_summary:", + "selected_team_roles:", + "browser_requirement:", + "browser_requirement_reason:", + "browser_launch_url:", + "browser_assist:", + ]; + + const forbiddenLegacyOutputKeys = [ + "creation_mode:", + "creationMode:", + "chat_mode:", + "chatMode:", + "web_search_enabled:", + "webSearchEnabled:", + "thinking_enabled:", + "thinkingEnabled:", + "task_mode_enabled:", + "taskModeEnabled:", + "subagent_mode_enabled:", + "subagentModeEnabled:", + "turn_team_decision:", + "turnTeamDecision:", + "turn_team_reason:", + "turnTeamReason:", + "turn_team_blueprint:", + "turnTeamBlueprint:", + ]; + + const requiredLegacyCleanupKeys = [ + "creation_mode", + "chat_mode", + "web_search_enabled", + "thinking_enabled", + "task_mode_enabled", + "subagent_mode_enabled", + "turn_team_decision", + "turn_team_reason", + "turn_team_blueprint", + ]; + + const requiredBackendMappings = [ + '("preferred_team_preset_id", "preferred_team_preset_id")', + '("preferredTeamPresetId", "preferred_team_preset_id")', + '("selected_team_id", "selected_team_id")', + '("selectedTeamId", "selected_team_id")', + '("selected_team_source", "selected_team_source")', + '("selectedTeamSource", "selected_team_source")', + '("selected_team_label", "selected_team_label")', + '("selectedTeamLabel", "selected_team_label")', + '("selected_team_description", "selected_team_description")', + '("selectedTeamDescription", "selected_team_description")', + '("selected_team_summary", "selected_team_summary")', + '("selectedTeamSummary", "selected_team_summary")', + '("selected_team_roles", "selected_team_roles")', + '("selectedTeamRoles", "selected_team_roles")', + '("browser_requirement", "browser_requirement")', + '("browserRequirement", "browser_requirement")', + '("browser_requirement_reason", "browser_requirement_reason")', + '("browserRequirementReason", "browser_requirement_reason")', + '("browser_launch_url", "browser_launch_url")', + '("browserLaunchUrl", "browser_launch_url")', + ]; + + const requiredRuntimeFields = [ + "session_id:", + "execution_strategy:", + "recent_preferences:", + "recent_team_selection:", + "recent_content_id:", + ]; + + requiredMetadataKeys.forEach((key) => { + assertIncludes( + metadataBuilderBlock, + key, + `[harness-contracts] 前端 metadata builder 缺少字段: ${key}`, + failures, + ); + }); + + forbiddenLegacyOutputKeys.forEach((key) => { + assertNotMatch( + metadataBuilderBlock, + new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), + `[harness-contracts] 前端 metadata builder 仍在输出 legacy 字段: ${key}`, + failures, + ); + }); + + requiredLegacyCleanupKeys.forEach((key) => { + assertIncludes( + legacyKeysBlock, + `"${key}"`, + `[harness-contracts] LEGACY_HARNESS_STATE_KEYS 缺少清理项: ${key}`, + failures, + ); + }); + + assertMatch( + harnessMetadataSource, + /preferences:\s*\{\s*web_search:\s*preferences\.webSearch,\s*thinking:\s*preferences\.thinking,\s*task:\s*preferences\.task,\s*subagent:\s*preferences\.subagent,\s*\}/s, + "[harness-contracts] 前端未按约定输出 preferences.web_search/thinking/task/subagent", + failures, + ); + + requiredBackendMappings.forEach((mapping) => { + assertIncludes( + requestMetadataSource, + mapping, + `[harness-contracts] 后端 request metadata 映射缺少字段: ${mapping}`, + failures, + ); + }); + + assertMatch( + requestMetadataSource, + /\("web_search_enabled",\s*&\["web_search", "webSearch"\]\[\.\.\]\)/, + "[harness-contracts] 后端未从 preferences 回填 web_search_enabled", + failures, + ); + assertIncludes( + requestMetadataSource, + '&["thinking", "thinking_enabled", "thinkingEnabled"][..]', + "[harness-contracts] 后端未从 preferences 回填 thinking_enabled", + failures, + ); + assertMatch( + requestMetadataSource, + /\("task_mode_enabled",\s*&\["task", "task_mode", "taskMode"\]\[\.\.\]\)/, + "[harness-contracts] 后端未从 preferences 回填 task_mode_enabled", + failures, + ); + assertIncludes( + requestMetadataSource, + '&["subagent", "subagent_mode", "subagentMode"][..]', + "[harness-contracts] 后端未从 preferences 回填 subagent_mode_enabled", + failures, + ); + + requiredRuntimeFields.forEach((field) => { + assertIncludes( + executionRuntimeSource, + field, + `[harness-contracts] execution runtime 缺少字段: ${field}`, + failures, + ); + }); + + assertIncludes( + executionRuntimeSource, + "createSessionRecentPreferencesFromChatToolPreferences", + "[harness-contracts] execution runtime 缺少 recent preferences 适配函数", + failures, + ); + assertIncludes( + executionRuntimeSource, + "createTeamDefinitionFromExecutionRuntimeRecentTeamSelection", + "[harness-contracts] execution runtime 缺少 recent team 反序列化函数", + failures, + ); + assertIncludes( + executionRuntimeSource, + "createSessionRecentTeamSelectionFromTeamDefinition", + "[harness-contracts] execution runtime 缺少 recent team 序列化函数", + failures, + ); + + console.log("[harness-contracts] 检查文件:"); + console.log(`- ${harnessMetadataPath}`); + console.log(`- ${executionRuntimePath}`); + console.log(`- ${requestMetadataPath}`); + + if (failures.length > 0) { + console.error("\n[harness-contracts] 发现契约漂移:"); + failures.forEach((failure) => { + console.error(`- ${failure}`); + }); + process.exitCode = 1; + return; + } + + console.log("\n[harness-contracts] Harness 契约检查通过。"); +} + +main(); diff --git a/scripts/harness-analysis-brief.mjs b/scripts/harness-analysis-brief.mjs new file mode 100644 index 000000000..6976dd583 --- /dev/null +++ b/scripts/harness-analysis-brief.mjs @@ -0,0 +1,724 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const DEFAULT_SANITIZED_WORKSPACE_ROOT = "/workspace/lime"; +const REQUIRED_REPLAY_ARTIFACTS = [ + "input.json", + "expected.json", + "grader.md", + "evidence-links.json", +]; +const HANDOFF_ARTIFACTS = [ + "plan.md", + "progress.json", + "handoff.md", + "review-summary.md", +]; +const EVIDENCE_ARTIFACTS = [ + "summary.md", + "runtime.json", + "timeline.json", + "artifacts.json", +]; +const ANALYSIS_BRIEF_FILE_NAME = "analysis-brief.md"; +const ANALYSIS_CONTEXT_FILE_NAME = "analysis-context.json"; + +function parseArgs(argv) { + const result = { + dryRun: false, + format: "text", + help: false, + outputDir: "", + replayDir: "", + sanitizedWorkspaceRoot: DEFAULT_SANITIZED_WORKSPACE_ROOT, + sessionId: "", + title: "", + workspaceRoot: process.cwd(), + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--session-id" && argv[index + 1]) { + result.sessionId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--replay-dir" && argv[index + 1]) { + result.replayDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--workspace-root" && argv[index + 1]) { + result.workspaceRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output-dir" && argv[index + 1]) { + result.outputDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--title" && argv[index + 1]) { + result.title = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--sanitized-workspace-root" && argv[index + 1]) { + result.sanitizedWorkspaceRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--dry-run") { + result.dryRun = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Harness Analysis Brief Export + +用法: + node scripts/harness-analysis-brief.mjs --session-id "session-123" + node scripts/harness-analysis-brief.mjs --replay-dir ".lime/harness/sessions/session-123/replay" + +选项: + --session-id ID 从 /.lime/harness/sessions//replay 生成分析交接包 + --replay-dir PATH 直接指定 replay 目录;与 --session-id 二选一 + --workspace-root PATH 工作区根目录,默认当前目录 + --output-dir PATH 输出目录;默认 /analysis + --title TEXT 分析包标题;默认从 goal summary 推导 + --sanitized-workspace-root PATH 导出到外部 AI 时使用的工作区占位路径,默认 /workspace/lime + --dry-run 只预览,不写文件 + --format FMT 标准输出格式:text | json + -h, --help 显示帮助 +`); +} + +function resolvePath(baseDir, targetPath) { + return path.resolve(baseDir, targetPath); +} + +function toPortablePath(value) { + return String(value).replaceAll("\\", "/"); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJsonFile(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function ensureDirectory(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function truncateText(value, maxLength = 800) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (trimmed.length <= maxLength) { + return trimmed; + } + return `${trimmed.slice(0, maxLength)}…`; +} + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); +} + +function replaceWorkspaceRootInString(value, workspaceRoot, placeholder) { + if (typeof value !== "string" || value.length === 0) { + return value; + } + + let nextValue = value; + const rawRoot = String(workspaceRoot); + const portableRoot = toPortablePath(rawRoot); + + if (rawRoot) { + nextValue = nextValue.replaceAll(rawRoot, placeholder); + } + if (portableRoot && portableRoot !== rawRoot) { + nextValue = nextValue.replaceAll(portableRoot, placeholder); + } + if (nextValue.includes(placeholder) && nextValue.includes("\\")) { + nextValue = nextValue.replaceAll("\\", "/"); + } + + return nextValue; +} + +function sanitizeValue(value, workspaceRoot, placeholder) { + if (typeof value === "string") { + return replaceWorkspaceRootInString(value, workspaceRoot, placeholder); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeValue(entry, workspaceRoot, placeholder)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + sanitizeValue(entryValue, workspaceRoot, placeholder), + ]), + ); + } + return value; +} + +function resolveReplayDirectory(options, workspaceRoot) { + if (options.replayDir) { + return resolvePath(process.cwd(), options.replayDir); + } + + if (!options.sessionId) { + throw new Error("必须提供 --session-id 或 --replay-dir。"); + } + + return path.join( + workspaceRoot, + ".lime", + "harness", + "sessions", + options.sessionId, + "replay", + ); +} + +function validateReplayDirectory(replayDir) { + if (!fs.existsSync(replayDir) || !fs.statSync(replayDir).isDirectory()) { + throw new Error(`replay 目录不存在: ${replayDir}`); + } + + const missing = REQUIRED_REPLAY_ARTIFACTS.filter( + (artifact) => !fs.existsSync(path.join(replayDir, artifact)), + ); + if (missing.length > 0) { + throw new Error(`replay 目录缺少文件: ${missing.join(", ")}`); + } +} + +function deriveSessionRootFromReplayDirectory(replayDir) { + if (path.basename(replayDir) === "replay") { + return path.dirname(replayDir); + } + return replayDir; +} + +function safeReadFile(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + return fs.readFileSync(filePath, "utf8"); +} + +function safeReadJson(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + return readJsonFile(filePath); +} + +function sanitizeAbsolutePathForExternalUse(absolutePath, workspaceRoot, placeholder) { + const relativePath = path.relative(workspaceRoot, absolutePath); + if ( + !relativePath.startsWith("..") && + !path.isAbsolute(relativePath) && + relativePath !== "" + ) { + return toPortablePath(path.join(placeholder, relativePath)); + } + return ""; +} + +function listExistingArtifacts(rootPath, artifactNames, workspaceRoot, placeholder) { + return artifactNames.map((fileName) => { + const absolutePath = path.join(rootPath, fileName); + const exists = fs.existsSync(absolutePath); + return { + fileName, + exists, + absolutePath: exists + ? sanitizeAbsolutePathForExternalUse( + absolutePath, + workspaceRoot, + placeholder, + ) + : "", + relativePath: exists ? toPortablePath(path.relative(rootPath, absolutePath)) : "", + }; + }); +} + +function deriveTitle(options, inputPayload, replayDir) { + if (options.title) { + return options.title; + } + + const goalSummary = inputPayload?.task?.goalSummary; + if (typeof goalSummary === "string" && goalSummary.trim().length > 0) { + return goalSummary.trim(); + } + + const sessionId = + inputPayload?.session?.sessionId ?? path.basename(path.dirname(replayDir)); + return `外部分析交接 / ${sessionId}`; +} + +function buildReadingOrder(handoffArtifacts, evidenceArtifacts) { + const order = ["先读 replay/input.json 与 replay/expected.json,确认任务目标与判定标准。"]; + + if (handoffArtifacts.some((entry) => entry.fileName === "handoff.md" && entry.exists)) { + order.push("再读 handoff/handoff.md 与 handoff/progress.json,确认当前状态、待继续事项与恢复顺序。"); + } + + if (evidenceArtifacts.some((entry) => entry.fileName === "summary.md" && entry.exists)) { + order.push("再读 evidence/summary.md 与 evidence/runtime.json,确认当前阻塞、pending request 与 diagnostics。"); + } + + if (evidenceArtifacts.some((entry) => entry.fileName === "timeline.json" && entry.exists)) { + order.push("如需复盘过程,再读 evidence/timeline.json。"); + } + + order.push("最后回看 replay/grader.md,按约定输出根因、修复建议、回归建议与风险项。"); + return order; +} + +function buildExternalAnalysisPromptContract() { + return { + audience: "Claude Code / Codex", + task: "基于 Lime 导出的结构化证据做问题分析与修复建议,不直接代替团队做最终决策。", + requiredSections: [ + "结论", + "根因判断", + "关键证据", + "修复建议", + "回归建议", + "风险与未知项", + ], + rules: [ + "优先引用现有证据文件,不要求重建完整会话。", + "如果证据不足,显式列出缺口,不要假装已经确认。", + "只给分析与建议,不直接替团队批准或拒绝修复方案。", + "如果怀疑路径、凭证或外部系统状态影响结论,先标注为待人工复核。", + ], + }; +} + +function buildHumanReviewChecklist(inputPayload, expectedPayload) { + const checklist = [ + "确认外部 AI 是否引用了现有证据,而不是凭空推断。", + "确认修复建议是否直接服务当前失败模式,而不是顺手扩大范围。", + "确认回归建议是否能沉淀为 replay / eval / smoke,而不是停留在口头建议。", + ]; + + if (expectedPayload?.graderSuggestion?.requiresHumanReview === true) { + checklist.unshift("当前样本本来就要求人工复核,不应把外部 AI 结论当成最终裁决。"); + } + + if ( + normalizeStringList(inputPayload?.classification?.failureModes).includes( + "pending_request", + ) + ) { + checklist.push("确认外部 AI 没有把 pending request 误判成已完成。"); + } + + return checklist; +} + +function buildAnalysisContext({ + evidenceArtifacts, + evidenceJson, + evidenceRoot, + expectedPayload, + handoffArtifacts, + handoffJson, + inputPayload, + options, + replayDir, + replayRootArtifacts, + sessionRoot, + title, + workspaceRoot, +}) { + const sanitizedInput = sanitizeValue( + { + session: inputPayload?.session ?? {}, + task: inputPayload?.task ?? {}, + classification: inputPayload?.classification ?? {}, + runtimeContext: { + pendingRequests: inputPayload?.runtimeContext?.pendingRequests ?? [], + queuedTurns: inputPayload?.runtimeContext?.queuedTurns ?? [], + todoItems: inputPayload?.runtimeContext?.todoItems ?? [], + activeSubagents: inputPayload?.runtimeContext?.activeSubagents ?? [], + }, + linkedArtifacts: inputPayload?.linkedArtifacts ?? {}, + }, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ); + + const sanitizedExpected = sanitizeValue( + { + goalSummary: expectedPayload?.goalSummary ?? "", + successCriteria: expectedPayload?.successCriteria ?? [], + blockingChecks: expectedPayload?.blockingChecks ?? [], + artifactChecks: expectedPayload?.artifactChecks ?? [], + graderSuggestion: expectedPayload?.graderSuggestion ?? {}, + nonGoals: expectedPayload?.nonGoals ?? [], + }, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ); + + return { + schemaVersion: "v1", + source: { + contractShape: "lime_external_analysis_handoff", + derivedFrom: [ + "lime_workspace_handoff_bundle", + "lime_workspace_evidence_pack", + "lime_runtime_export_replay_case", + ], + }, + title, + exportedAt: new Date().toISOString(), + sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot, + replayRoot: + sanitizeAbsolutePathForExternalUse( + replayDir, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ) || "", + summary: { + sessionId: sanitizedInput.session.sessionId ?? "", + threadId: sanitizedInput.session.threadId ?? "", + executionStrategy: sanitizedInput.session.executionStrategy ?? "", + model: sanitizedInput.session.model ?? "", + goalSummary: sanitizedInput.task.goalSummary ?? "", + latestTurnStatus: + sanitizedInput.task.latestTurnStatus ?? + handoffJson?.status?.latestTurnStatus ?? + evidenceJson?.thread?.latestTurnStatus ?? + "", + threadStatus: + sanitizedInput.task.threadStatus ?? + handoffJson?.status?.threadStatus ?? + evidenceJson?.thread?.status ?? + "", + primaryBlockingKind: + sanitizedInput.classification.primaryBlockingKind ?? + handoffJson?.diagnostics?.primaryBlockingKind ?? + evidenceJson?.thread?.diagnostics?.primaryBlockingKind ?? + "", + primaryBlockingSummary: + sanitizedInput.task.primaryBlockingSummary ?? + handoffJson?.diagnostics?.primaryBlockingSummary ?? + evidenceJson?.thread?.diagnostics?.primaryBlockingSummary ?? + "", + failureModes: sanitizedInput.classification.failureModes ?? [], + suiteTags: sanitizedInput.classification.suiteTags ?? [], + pendingRequestCount: + Array.isArray(sanitizedInput.runtimeContext.pendingRequests) + ? sanitizedInput.runtimeContext.pendingRequests.length + : handoffJson?.status?.pendingRequestCount ?? + evidenceJson?.thread?.pendingRequestCount ?? + 0, + queuedTurnCount: + Array.isArray(sanitizedInput.runtimeContext.queuedTurns) + ? sanitizedInput.runtimeContext.queuedTurns.length + : handoffJson?.status?.queuedTurnCount ?? + evidenceJson?.thread?.queuedTurnCount ?? + 0, + }, + replay: { + artifacts: replayRootArtifacts, + graderExcerpt: truncateText( + sanitizeValue( + safeReadFile(path.join(replayDir, "grader.md")) ?? "", + workspaceRoot, + options.sanitizedWorkspaceRoot, + ), + ), + input: sanitizedInput, + expected: sanitizedExpected, + }, + handoff: { + artifacts: handoffArtifacts, + progress: sanitizeValue(handoffJson ?? {}, workspaceRoot, options.sanitizedWorkspaceRoot), + handoffExcerpt: truncateText( + sanitizeValue( + safeReadFile(path.join(sessionRoot, "handoff.md")) ?? "", + workspaceRoot, + options.sanitizedWorkspaceRoot, + ), + ), + reviewSummaryExcerpt: truncateText( + sanitizeValue( + safeReadFile(path.join(sessionRoot, "review-summary.md")) ?? "", + workspaceRoot, + options.sanitizedWorkspaceRoot, + ), + ), + }, + evidence: { + artifacts: evidenceArtifacts, + runtime: sanitizeValue(evidenceJson ?? {}, workspaceRoot, options.sanitizedWorkspaceRoot), + summaryExcerpt: truncateText( + sanitizeValue( + safeReadFile(path.join(evidenceRoot, "summary.md")) ?? "", + workspaceRoot, + options.sanitizedWorkspaceRoot, + ), + ), + }, + readingOrder: buildReadingOrder(handoffArtifacts, evidenceArtifacts), + externalAnalysisContract: buildExternalAnalysisPromptContract(), + humanReviewChecklist: buildHumanReviewChecklist(inputPayload, expectedPayload), + }; +} + +function renderArtifactList(artifacts, labelPrefix) { + const available = artifacts.filter((entry) => entry.exists); + if (available.length === 0) { + return ["- 当前未检测到可用文件。"]; + } + + return available.map( + (entry) => + `- \`${labelPrefix}${entry.relativePath}\`${ + entry.absolutePath ? ` (${entry.absolutePath})` : "" + }`, + ); +} + +function buildAnalysisBrief(context) { + const lines = [ + "# 外部分析交接简报", + "", + `- 标题:${context.title}`, + `- 生成时间:${context.exportedAt}`, + `- 会话:\`${context.summary.sessionId || "unknown"}\``, + `- 线程:\`${context.summary.threadId || "unknown"}\``, + `- 执行策略:${context.summary.executionStrategy || "unknown"}`, + `- 模型:${context.summary.model || "unknown"}`, + "", + "## 当前问题", + "", + `- 目标摘要:${context.summary.goalSummary || "未知"}`, + `- 线程状态:${context.summary.threadStatus || "未知"}`, + `- 最新 turn 状态:${context.summary.latestTurnStatus || "未知"}`, + `- 主要阻塞:${context.summary.primaryBlockingKind || "未知"}${context.summary.primaryBlockingSummary ? ` · ${context.summary.primaryBlockingSummary}` : ""}`, + `- failure modes:${ + context.summary.failureModes.length > 0 + ? context.summary.failureModes.join(", ") + : "无" + }`, + `- suite tags:${ + context.summary.suiteTags.length > 0 + ? context.summary.suiteTags.join(", ") + : "无" + }`, + `- pending request:${context.summary.pendingRequestCount}`, + `- queued turn:${context.summary.queuedTurnCount}`, + "", + "## 推荐读取顺序", + "", + ...context.readingOrder.map((entry, index) => `${index + 1}. ${entry}`), + "", + "## Replay 文件", + "", + ...renderArtifactList(context.replay.artifacts, "replay/"), + "", + "## Handoff 文件", + "", + ...renderArtifactList(context.handoff.artifacts, ""), + "", + "## Evidence 文件", + "", + ...renderArtifactList(context.evidence.artifacts, "evidence/"), + "", + "## 可直接给外部 AI 的任务说明", + "", + "```text", + "你将收到一个由 Lime 导出的分析包。你的职责是做问题分析和修复建议,不直接替团队做最终决策。", + "", + "请优先读取 analysis-context.json 与 analysis-brief.md 中提到的 replay / handoff / evidence 文件。", + "", + "输出必须包含以下部分:", + "- 结论", + "- 根因判断", + "- 关键证据", + "- 修复建议", + "- 回归建议", + "- 风险与未知项", + "", + "约束:", + "- 优先引用现有证据,不要假装看到不存在的信息。", + "- 如果证据不足,明确写出缺口和需要人工确认的地方。", + "- 不直接代表团队批准、拒绝或自动应用修复方案。", + "```", + "", + "## 人工审核检查清单", + "", + ...context.humanReviewChecklist.map((entry) => `- ${entry}`), + "", + "## 关键摘录", + "", + "### Replay Grader 摘录", + "", + context.replay.graderExcerpt || "当前无可用摘录。", + "", + "### Handoff 摘录", + "", + context.handoff.handoffExcerpt || "当前无可用摘录。", + "", + "### Evidence 摘录", + "", + context.evidence.summaryExcerpt || "当前无可用摘录。", + "", + "## 注意", + "", + `- 所有路径默认已按 \`${context.sanitizedWorkspaceRoot}\` 占位规则输出,便于外部 AI 消费。`, + "- 这份简报只负责分析交接,不负责自动修复或自动回写 Lime。", + "", + ]; + + return `${lines.join("\n")}\n`; +} + +function renderText(result) { + return [ + `[harness-analysis] title : ${result.title}`, + `[harness-analysis] replay: ${result.replayDir}`, + `[harness-analysis] output: ${result.outputDir}`, + `[harness-analysis] brief : ${result.briefPath}`, + `[harness-analysis] json : ${result.contextPath}`, + `[harness-analysis] dry-run: ${result.dryRun ? "yes" : "no"}`, + ].join("\n").concat("\n"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const workspaceRoot = resolvePath(process.cwd(), options.workspaceRoot); + const replayDir = resolveReplayDirectory(options, workspaceRoot); + validateReplayDirectory(replayDir); + + const inputPayload = readJsonFile(path.join(replayDir, "input.json")); + const expectedPayload = readJsonFile(path.join(replayDir, "expected.json")); + const sessionRoot = deriveSessionRootFromReplayDirectory(replayDir); + const evidenceRoot = path.join(sessionRoot, "evidence"); + const outputDir = options.outputDir + ? resolvePath(process.cwd(), options.outputDir) + : path.join(sessionRoot, "analysis"); + + const workspaceRootFromInput = + inputPayload?.session?.workspaceRoot && typeof inputPayload.session.workspaceRoot === "string" + ? path.resolve(inputPayload.session.workspaceRoot) + : workspaceRoot; + + const replayRootArtifacts = listExistingArtifacts( + replayDir, + REQUIRED_REPLAY_ARTIFACTS, + workspaceRootFromInput, + options.sanitizedWorkspaceRoot, + ); + const handoffArtifacts = listExistingArtifacts( + sessionRoot, + HANDOFF_ARTIFACTS, + workspaceRootFromInput, + options.sanitizedWorkspaceRoot, + ); + const evidenceArtifacts = listExistingArtifacts( + evidenceRoot, + EVIDENCE_ARTIFACTS, + workspaceRootFromInput, + options.sanitizedWorkspaceRoot, + ); + + const handoffJson = safeReadJson(path.join(sessionRoot, "progress.json")); + const evidenceJson = safeReadJson(path.join(evidenceRoot, "runtime.json")); + + const title = deriveTitle(options, inputPayload, replayDir); + const analysisContext = buildAnalysisContext({ + evidenceArtifacts, + evidenceJson, + evidenceRoot, + expectedPayload, + handoffArtifacts, + handoffJson, + inputPayload, + options, + replayDir, + replayRootArtifacts, + sessionRoot, + title, + workspaceRoot: workspaceRootFromInput, + }); + const analysisBrief = buildAnalysisBrief(analysisContext); + + const briefPath = path.join(outputDir, ANALYSIS_BRIEF_FILE_NAME); + const contextPath = path.join(outputDir, ANALYSIS_CONTEXT_FILE_NAME); + + if (!options.dryRun) { + ensureDirectory(outputDir); + fs.writeFileSync(briefPath, analysisBrief, "utf8"); + writeJsonFile(contextPath, analysisContext); + } + + const result = { + briefPath: toPortablePath(briefPath), + contextPath: toPortablePath(contextPath), + dryRun: options.dryRun, + outputDir: toPortablePath(outputDir), + replayDir: toPortablePath(replayDir), + title, + }; + + if (options.format === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + process.stdout.write(renderText(result)); +} + +main(); diff --git a/scripts/harness-eval-runner.mjs b/scripts/harness-eval-runner.mjs new file mode 100644 index 000000000..59616f7c6 --- /dev/null +++ b/scripts/harness-eval-runner.mjs @@ -0,0 +1,700 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const DEFAULT_MANIFEST_PATH = "docs/test/harness-evals.manifest.json"; + +function parseArgs(argv) { + const result = { + format: "text", + help: false, + manifest: DEFAULT_MANIFEST_PATH, + outputJson: "", + outputMarkdown: "", + strict: true, + workspaceRoot: process.cwd(), + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--manifest" && argv[index + 1]) { + result.manifest = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--workspace-root" && argv[index + 1]) { + result.workspaceRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output-json" && argv[index + 1]) { + result.outputJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output-markdown" && argv[index + 1]) { + result.outputMarkdown = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--no-strict") { + result.strict = false; + continue; + } + + if (arg === "--strict") { + result.strict = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Harness Eval Runner + +用法: + node scripts/harness-eval-runner.mjs + node scripts/harness-eval-runner.mjs --format json + node scripts/harness-eval-runner.mjs --workspace-root "/path/to/workspace" + node scripts/harness-eval-runner.mjs --output-json "./tmp/harness-eval-summary.json" --output-markdown "./tmp/harness-eval-summary.md" + +选项: + --manifest PATH 指定 manifest,默认 docs/test/harness-evals.manifest.json + --workspace-root PATH 指定工作区根目录,默认当前目录 + --format FMT 控制标准输出格式:text | json | markdown + --output-json PATH 将 JSON 摘要写入指定路径 + --output-markdown PATH 将 Markdown 摘要写入指定路径 + --strict 严格模式(默认),发现 invalid case 时返回非 0 + --no-strict 非严格模式,只输出摘要,不因 invalid case 退出失败 + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function resolvePath(baseDir, relativePath) { + return path.resolve(baseDir, relativePath); +} + +function ensureParentDirectory(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); +} + +function mergeUniqueStrings(...groups) { + return [...new Set(groups.flatMap((group) => normalizeStringList(group)))]; +} + +function createBreakdownEntry(name) { + return { + name, + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }; +} + +function aggregateCaseBreakdown(cases, selector) { + const breakdownMap = new Map(); + + for (const entry of cases) { + const labels = mergeUniqueStrings(selector(entry)); + for (const label of labels) { + const current = breakdownMap.get(label) ?? createBreakdownEntry(label); + current.caseCount += 1; + if (entry.status === "ready") { + current.readyCount += 1; + } else if (entry.status === "invalid") { + current.invalidCount += 1; + } + if (entry.pendingRequestCount > 0) { + current.pendingRequestCaseCount += 1; + } + if (entry.requiresHumanReview) { + current.needsHumanReviewCount += 1; + } + breakdownMap.set(label, current); + } + } + + return Array.from(breakdownMap.values()).sort((left, right) => { + if (right.caseCount !== left.caseCount) { + return right.caseCount - left.caseCount; + } + return left.name.localeCompare(right.name); + }); +} + +function getValueByPath(target, dottedPath) { + return dottedPath + .split(".") + .reduce( + (current, segment) => (current == null ? undefined : current[segment]), + target, + ); +} + +function isPresentValue(value) { + if (value == null) { + return false; + } + if (typeof value === "string") { + return value.trim().length > 0; + } + if (Array.isArray(value)) { + return value.length > 0; + } + return true; +} + +function collectFieldIssues(jsonPayload, fields, label) { + const issues = []; + for (const field of fields) { + if (!isPresentValue(getValueByPath(jsonPayload, field))) { + issues.push(`${label} 缺少字段: ${field}`); + } + } + return issues; +} + +function listReplayDirectories(rootPath) { + if (!fs.existsSync(rootPath)) { + return []; + } + + const entries = fs.readdirSync(rootPath, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name, "replay")) + .filter((replayPath) => { + try { + return fs.statSync(replayPath).isDirectory(); + } catch { + return false; + } + }) + .sort((left, right) => left.localeCompare(right)); +} + +function validateCaseDirectory(caseDir, caseConfig, defaults, context) { + const requiredArtifacts = normalizeStringList( + caseConfig.requiredArtifacts ?? defaults.requiredArtifacts, + ); + const requiredInputFields = normalizeStringList( + caseConfig.requiredInputFields ?? defaults.requiredInputFields, + ); + const requiredExpectedFields = normalizeStringList( + caseConfig.requiredExpectedFields ?? defaults.requiredExpectedFields, + ); + const requiredEvidenceFields = normalizeStringList( + caseConfig.requiredEvidenceFields ?? defaults.requiredEvidenceFields, + ); + + const issues = []; + const resolvedCaseDir = path.resolve(caseDir); + const files = {}; + + for (const artifactName of requiredArtifacts) { + const artifactPath = path.join(resolvedCaseDir, artifactName); + files[artifactName] = artifactPath; + if (!fs.existsSync(artifactPath)) { + issues.push(`缺少文件: ${artifactName}`); + } + } + + let inputPayload = null; + let expectedPayload = null; + let evidencePayload = null; + + if (fs.existsSync(files["input.json"] ?? "")) { + try { + inputPayload = readJsonFile(files["input.json"]); + issues.push( + ...collectFieldIssues(inputPayload, requiredInputFields, "input.json"), + ); + } catch (error) { + issues.push(`input.json 解析失败: ${String(error.message ?? error)}`); + } + } + + if (fs.existsSync(files["expected.json"] ?? "")) { + try { + expectedPayload = readJsonFile(files["expected.json"]); + issues.push( + ...collectFieldIssues( + expectedPayload, + requiredExpectedFields, + "expected.json", + ), + ); + } catch (error) { + issues.push(`expected.json 解析失败: ${String(error.message ?? error)}`); + } + } + + if (fs.existsSync(files["evidence-links.json"] ?? "")) { + try { + evidencePayload = readJsonFile(files["evidence-links.json"]); + issues.push( + ...collectFieldIssues( + evidencePayload, + requiredEvidenceFields, + "evidence-links.json", + ), + ); + } catch (error) { + issues.push( + `evidence-links.json 解析失败: ${String(error.message ?? error)}`, + ); + } + } + + const pendingRequestCount = Array.isArray( + inputPayload?.runtimeContext?.pendingRequests, + ) + ? inputPayload.runtimeContext.pendingRequests.length + : 0; + const classificationTags = mergeUniqueStrings( + context.tags, + inputPayload?.classification?.suiteTags, + ); + const failureModes = normalizeStringList( + inputPayload?.classification?.failureModes, + ); + const primaryBlockingKind = + typeof inputPayload?.classification?.primaryBlockingKind === "string" + ? inputPayload.classification.primaryBlockingKind.trim() + : ""; + const requiresHumanReview = + expectedPayload?.graderSuggestion?.requiresHumanReview === true; + const preferredMode = + typeof expectedPayload?.graderSuggestion?.preferredMode === "string" + ? expectedPayload.graderSuggestion.preferredMode + : ""; + + return { + caseId: context.caseId, + title: context.title, + suiteId: context.suiteId, + suiteTitle: context.suiteTitle, + source: context.source, + priority: context.priority ?? "", + tags: classificationTags, + failureModes, + primaryBlockingKind, + caseDir: resolvedCaseDir, + relativeCaseDir: path.relative(context.repoRoot, resolvedCaseDir) || ".", + sessionId: + inputPayload?.session?.sessionId ?? + expectedPayload?.sessionId ?? + path.basename(path.dirname(resolvedCaseDir)), + threadId: + inputPayload?.session?.threadId ?? expectedPayload?.threadId ?? "", + goalSummary: + inputPayload?.task?.goalSummary ?? expectedPayload?.goalSummary ?? "", + pendingRequestCount, + requiresHumanReview, + preferredMode, + status: issues.length === 0 ? "ready" : "invalid", + issues, + }; +} + +function expandSuiteCases(suiteConfig, defaults, repoRoot, workspaceRoot) { + const suiteCases = []; + const configuredCases = Array.isArray(suiteConfig.cases) + ? suiteConfig.cases + : []; + + for (const caseConfig of configuredCases) { + const source = String(caseConfig.source ?? "").trim(); + if (source === "repo_fixture") { + const caseDir = resolvePath(repoRoot, String(caseConfig.caseDir ?? "")); + suiteCases.push( + validateCaseDirectory(caseDir, caseConfig, defaults, { + caseId: String(caseConfig.id ?? "unnamed-case"), + priority: suiteConfig.priority, + repoRoot, + source, + suiteId: String(suiteConfig.id ?? "unnamed-suite"), + suiteTitle: String(suiteConfig.title ?? "未命名 Suite"), + tags: caseConfig.tags, + title: String(caseConfig.title ?? caseConfig.id ?? "未命名 Case"), + }), + ); + continue; + } + + if (source === "workspace_replay_discovery") { + const discoveryRoot = resolvePath( + workspaceRoot, + String(caseConfig.root ?? ".lime/harness/sessions"), + ); + const replayDirectories = listReplayDirectories(discoveryRoot); + + if ( + replayDirectories.length === 0 && + caseConfig.allowZeroMatches !== true + ) { + suiteCases.push({ + caseId: String(caseConfig.id ?? "workspace-discovery"), + title: String(caseConfig.title ?? "工作区 Replay 自动发现"), + suiteId: String(suiteConfig.id ?? "unnamed-suite"), + suiteTitle: String(suiteConfig.title ?? "未命名 Suite"), + source, + priority: suiteConfig.priority ?? "", + tags: normalizeStringList(caseConfig.tags), + failureModes: [], + primaryBlockingKind: "", + caseDir: discoveryRoot, + relativeCaseDir: path.relative(repoRoot, discoveryRoot) || ".", + sessionId: "", + threadId: "", + goalSummary: "", + pendingRequestCount: 0, + requiresHumanReview: false, + preferredMode: "", + status: "invalid", + issues: [ + `未发现 replay case 目录: ${path.relative(workspaceRoot, discoveryRoot) || "."}`, + ], + }); + continue; + } + + for (const replayDir of replayDirectories) { + const sessionId = path.basename(path.dirname(replayDir)); + suiteCases.push( + validateCaseDirectory(replayDir, caseConfig, defaults, { + caseId: `${String(caseConfig.id ?? "workspace-case")}:${sessionId}`, + priority: suiteConfig.priority, + repoRoot, + source, + suiteId: String(suiteConfig.id ?? "unnamed-suite"), + suiteTitle: String(suiteConfig.title ?? "未命名 Suite"), + tags: caseConfig.tags, + title: `${String(caseConfig.title ?? "工作区 Replay 样本")} / ${sessionId}`, + }), + ); + } + continue; + } + + suiteCases.push({ + caseId: String(caseConfig.id ?? "unknown-case"), + title: String(caseConfig.title ?? "未命名 Case"), + suiteId: String(suiteConfig.id ?? "unnamed-suite"), + suiteTitle: String(suiteConfig.title ?? "未命名 Suite"), + source, + priority: suiteConfig.priority ?? "", + tags: normalizeStringList(caseConfig.tags), + failureModes: [], + primaryBlockingKind: "", + caseDir: "", + relativeCaseDir: "", + sessionId: "", + threadId: "", + goalSummary: "", + pendingRequestCount: 0, + requiresHumanReview: false, + preferredMode: "", + status: "invalid", + issues: [`不支持的 case source: ${source || "(empty)"}`], + }); + } + + const readyCount = suiteCases.filter( + (entry) => entry.status === "ready", + ).length; + const invalidCount = suiteCases.length - readyCount; + const discoveredCount = suiteCases.filter( + (entry) => entry.source === "workspace_replay_discovery", + ).length; + + return { + id: String(suiteConfig.id ?? "unnamed-suite"), + title: String(suiteConfig.title ?? "未命名 Suite"), + priority: String(suiteConfig.priority ?? ""), + roadmap: String(suiteConfig.roadmap ?? ""), + description: String(suiteConfig.description ?? ""), + upstream: suiteConfig.upstream ?? {}, + cases: suiteCases, + stats: { + configuredCaseCount: configuredCases.length, + discoveredCaseCount: discoveredCount, + caseCount: suiteCases.length, + readyCount, + invalidCount, + }, + }; +} + +function buildSummary(manifest, suites, options) { + const allCases = suites.flatMap((suite) => suite.cases); + const readyCases = allCases.filter((entry) => entry.status === "ready"); + const invalidCases = allCases.filter((entry) => entry.status === "invalid"); + const reviewCases = allCases.filter((entry) => entry.requiresHumanReview); + const pendingCases = allCases.filter( + (entry) => entry.pendingRequestCount > 0, + ); + + return { + manifestVersion: String(manifest.manifestVersion ?? "unknown"), + title: String(manifest.title ?? "Lime Harness Eval Summary"), + generatedAt: new Date().toISOString(), + repoRoot: process.cwd(), + workspaceRoot: path.resolve(options.workspaceRoot), + strict: options.strict, + totals: { + suiteCount: suites.length, + caseCount: allCases.length, + readyCount: readyCases.length, + invalidCount: invalidCases.length, + needsHumanReviewCount: reviewCases.length, + pendingRequestCaseCount: pendingCases.length, + }, + breakdowns: { + suiteTags: aggregateCaseBreakdown(allCases, (entry) => entry.tags), + failureModes: aggregateCaseBreakdown( + allCases, + (entry) => entry.failureModes, + ), + }, + suites, + }; +} + +function renderText(summary) { + const lines = [ + `[harness-eval] manifest: ${summary.title} (${summary.manifestVersion})`, + `[harness-eval] workspace: ${summary.workspaceRoot}`, + `[harness-eval] suites: ${summary.totals.suiteCount}`, + `[harness-eval] cases : ${summary.totals.caseCount}`, + `[harness-eval] ready : ${summary.totals.readyCount}`, + `[harness-eval] invalid: ${summary.totals.invalidCount}`, + `[harness-eval] pending-request cases: ${summary.totals.pendingRequestCaseCount}`, + `[harness-eval] needs-review cases : ${summary.totals.needsHumanReviewCount}`, + ]; + + const topFailureModes = summary.breakdowns.failureModes.slice(0, 5); + if (topFailureModes.length > 0) { + lines.push("[harness-eval] top failure modes:"); + for (const entry of topFailureModes) { + lines.push( + ` - ${entry.name}: case=${entry.caseCount}, invalid=${entry.invalidCount}, pending=${entry.pendingRequestCaseCount}`, + ); + } + } + + const topSuiteTags = summary.breakdowns.suiteTags.slice(0, 5); + if (topSuiteTags.length > 0) { + lines.push("[harness-eval] top suite tags:"); + for (const entry of topSuiteTags) { + 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}`, + ); + for (const entry of suite.cases) { + lines.push( + ` - ${entry.caseId} [${entry.status}] (${entry.source}) ${entry.relativeCaseDir}`, + ); + if (entry.tags.length > 0) { + lines.push(` tags: ${entry.tags.join(", ")}`); + } + if (entry.failureModes.length > 0) { + lines.push(` failure_modes: ${entry.failureModes.join(", ")}`); + } + for (const issue of entry.issues) { + lines.push(` * ${issue}`); + } + } + } + + return `${lines.join("\n")}\n`; +} + +function renderMarkdown(summary) { + const lines = [ + "# Lime Harness Eval Summary", + "", + `- 生成时间:${summary.generatedAt}`, + `- manifest:${summary.title} (${summary.manifestVersion})`, + `- 工作区:\`${summary.workspaceRoot}\``, + `- suite 数:${summary.totals.suiteCount}`, + `- case 数:${summary.totals.caseCount}`, + `- ready:${summary.totals.readyCount}`, + `- invalid:${summary.totals.invalidCount}`, + `- pending request case:${summary.totals.pendingRequestCaseCount}`, + `- needs review case:${summary.totals.needsHumanReviewCount}`, + "", + ]; + + if (summary.breakdowns.failureModes.length > 0) { + lines.push("## Failure Mode 分布"); + lines.push(""); + lines.push( + "| Failure Mode | case | invalid | pending_request | needs_review |", + ); + lines.push("| --- | --- | --- | --- | --- |"); + for (const entry of summary.breakdowns.failureModes) { + lines.push( + `| ${entry.name} | ${entry.caseCount} | ${entry.invalidCount} | ${entry.pendingRequestCaseCount} | ${entry.needsHumanReviewCount} |`, + ); + } + lines.push(""); + } + + if (summary.breakdowns.suiteTags.length > 0) { + lines.push("## Suite Tag 分布"); + lines.push(""); + lines.push("| Suite Tag | case | ready | invalid |"); + lines.push("| --- | --- | --- | --- |"); + for (const entry of summary.breakdowns.suiteTags) { + lines.push( + `| ${entry.name} | ${entry.caseCount} | ${entry.readyCount} | ${entry.invalidCount} |`, + ); + } + lines.push(""); + } + + for (const suite of summary.suites) { + lines.push(`## ${suite.title}`); + lines.push(""); + if (suite.description) { + lines.push(suite.description); + lines.push(""); + } + lines.push(`- ` + `suite_id:\`${suite.id}\``); + if (suite.priority) { + lines.push(`- 优先级:${suite.priority}`); + } + if (suite.roadmap) { + lines.push(`- 路线图:${suite.roadmap}`); + } + lines.push( + `- ready / total:${suite.stats.readyCount} / ${suite.stats.caseCount}`, + ); + lines.push(""); + lines.push("| Case | 状态 | 来源 | 分类 | 目录 | 问题 |"); + lines.push("| --- | --- | --- | --- | --- | --- |"); + for (const entry of suite.cases) { + const issueText = + entry.issues.length === 0 ? "无" : entry.issues.join("
"); + const classificationText = []; + if (entry.tags.length > 0) { + classificationText.push(`tags: ${entry.tags.join(", ")}`); + } + if (entry.failureModes.length > 0) { + classificationText.push(`failure: ${entry.failureModes.join(", ")}`); + } + if (entry.primaryBlockingKind) { + classificationText.push(`blocking: ${entry.primaryBlockingKind}`); + } + lines.push( + `| ${entry.caseId} | ${entry.status} | ${entry.source} | ${classificationText.join("
") || "无"} | \`${entry.relativeCaseDir || "."}\` | ${issueText} |`, + ); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function determineExitCode(summary, options) { + if (!options.strict) { + return 0; + } + return summary.totals.invalidCount > 0 ? 1 : 0; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const repoRoot = process.cwd(); + const manifestPath = resolvePath(repoRoot, options.manifest); + const manifest = readJsonFile(manifestPath); + const defaults = manifest.defaults ?? {}; + const suiteConfigs = Array.isArray(manifest.suites) ? manifest.suites : []; + const suites = suiteConfigs.map((suiteConfig) => + expandSuiteCases( + suiteConfig, + defaults, + repoRoot, + path.resolve(options.workspaceRoot), + ), + ); + + const summary = buildSummary(manifest, suites, options); + const jsonOutput = `${JSON.stringify(summary, null, 2)}\n`; + const markdownOutput = renderMarkdown(summary); + const textOutput = renderText(summary); + + if (options.outputJson) { + const outputPath = resolvePath(repoRoot, options.outputJson); + ensureParentDirectory(outputPath); + fs.writeFileSync(outputPath, jsonOutput, "utf8"); + } + + if (options.outputMarkdown) { + const outputPath = resolvePath(repoRoot, options.outputMarkdown); + ensureParentDirectory(outputPath); + fs.writeFileSync(outputPath, markdownOutput, "utf8"); + } + + if (options.format === "json") { + process.stdout.write(jsonOutput); + } else if (options.format === "markdown") { + process.stdout.write(markdownOutput); + } else { + process.stdout.write(textOutput); + } + + const exitCode = determineExitCode(summary, options); + if (exitCode !== 0) { + process.exit(exitCode); + } +} + +main(); diff --git a/scripts/harness-eval-trend-report.mjs b/scripts/harness-eval-trend-report.mjs new file mode 100644 index 000000000..60a1c5b6d --- /dev/null +++ b/scripts/harness-eval-trend-report.mjs @@ -0,0 +1,639 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const RUNNER_PATH = "scripts/harness-eval-runner.mjs"; + +function parseArgs(argv) { + const result = { + format: "text", + help: false, + historyDir: "", + inputs: [], + outputJson: "", + outputMarkdown: "", + workspaceRoot: process.cwd(), + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--input" && argv[index + 1]) { + result.inputs.push(String(argv[index + 1]).trim()); + index += 1; + continue; + } + + if (arg === "--history-dir" && argv[index + 1]) { + result.historyDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--workspace-root" && argv[index + 1]) { + result.workspaceRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output-json" && argv[index + 1]) { + result.outputJson = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--output-markdown" && argv[index + 1]) { + result.outputMarkdown = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Harness Eval Trend Report + +用法: + node scripts/harness-eval-trend-report.mjs + node scripts/harness-eval-trend-report.mjs --input "./tmp/harness-eval-summary.json" + node scripts/harness-eval-trend-report.mjs --history-dir "./artifacts/history" + node scripts/harness-eval-trend-report.mjs --output-json "./tmp/harness-eval-trend.json" --output-markdown "./tmp/harness-eval-trend.md" + +选项: + --input PATH 显式加入一个或多个 harness eval summary JSON + --history-dir PATH 扫描目录下的历史 summary JSON + --workspace-root PATH 未提供输入时,用该工作区生成当前 summary + --format FMT 标准输出格式:text | json | markdown + --output-json PATH 将 JSON 趋势报告写入指定路径 + --output-markdown PATH 将 Markdown 趋势报告写入指定路径 + -h, --help 显示帮助 +`); +} + +function resolvePath(baseDir, relativePath) { + return path.resolve(baseDir, relativePath); +} + +function ensureParentDirectory(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function collectJsonFiles(rootPath) { + if (!rootPath || !fs.existsSync(rootPath)) { + return []; + } + + const files = []; + const pending = [rootPath]; + + while (pending.length > 0) { + const current = pending.pop(); + const stat = fs.statSync(current); + + if (stat.isDirectory()) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + pending.push(path.join(current, entry.name)); + } + continue; + } + + if (stat.isFile() && current.endsWith(".json")) { + files.push(current); + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +function isHarnessEvalSummary(candidate) { + return ( + candidate != null && + typeof candidate === "object" && + typeof candidate.generatedAt === "string" && + candidate.totals != null && + typeof candidate.totals.caseCount === "number" && + typeof candidate.totals.readyCount === "number" && + typeof candidate.totals.invalidCount === "number" + ); +} + +function buildCurrentSummary(repoRoot, workspaceRoot) { + const nodeCommand = process.execPath; + const runnerPath = resolvePath(repoRoot, RUNNER_PATH); + const output = execFileSync( + nodeCommand, + [runnerPath, "--format", "json", "--workspace-root", workspaceRoot], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }, + ); + return JSON.parse(output); +} + +function normalizeNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function computeReadyRate(summary) { + const caseCount = normalizeNumber(summary?.totals?.caseCount); + if (caseCount <= 0) { + return 0; + } + return normalizeNumber(summary?.totals?.readyCount) / caseCount; +} + +function getSuiteMap(summary) { + const suites = Array.isArray(summary?.suites) ? summary.suites : []; + return new Map( + suites.map((suite) => [ + String(suite.id ?? ""), + { + id: String(suite.id ?? ""), + title: String(suite.title ?? ""), + caseCount: normalizeNumber(suite?.stats?.caseCount), + readyCount: normalizeNumber(suite?.stats?.readyCount), + invalidCount: normalizeNumber(suite?.stats?.invalidCount), + }, + ]), + ); +} + +function getBreakdownMap(summary, key) { + const entries = Array.isArray(summary?.breakdowns?.[key]) + ? summary.breakdowns[key] + : []; + return new Map( + entries.map((entry) => [ + String(entry.name ?? ""), + { + name: String(entry.name ?? ""), + caseCount: normalizeNumber(entry.caseCount), + readyCount: normalizeNumber(entry.readyCount), + invalidCount: normalizeNumber(entry.invalidCount), + pendingRequestCaseCount: normalizeNumber(entry.pendingRequestCaseCount), + needsHumanReviewCount: normalizeNumber(entry.needsHumanReviewCount), + }, + ]), + ); +} + +function buildSuiteDeltas(baseline, latest) { + const baselineSuites = getSuiteMap(baseline); + const latestSuites = getSuiteMap(latest); + const suiteIds = new Set([...baselineSuites.keys(), ...latestSuites.keys()]); + + return Array.from(suiteIds) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) + .map((suiteId) => { + const baselineSuite = baselineSuites.get(suiteId) ?? { + id: suiteId, + title: suiteId, + caseCount: 0, + readyCount: 0, + invalidCount: 0, + }; + const latestSuite = latestSuites.get(suiteId) ?? { + id: suiteId, + title: baselineSuite.title, + caseCount: 0, + readyCount: 0, + invalidCount: 0, + }; + + return { + id: suiteId, + title: latestSuite.title || baselineSuite.title || suiteId, + baseline: baselineSuite, + latest: latestSuite, + delta: { + caseCount: latestSuite.caseCount - baselineSuite.caseCount, + readyCount: latestSuite.readyCount - baselineSuite.readyCount, + invalidCount: latestSuite.invalidCount - baselineSuite.invalidCount, + }, + }; + }); +} + +function buildBreakdownDeltas(baseline, latest, key) { + const baselineMap = getBreakdownMap(baseline, key); + const latestMap = getBreakdownMap(latest, key); + const names = new Set([...baselineMap.keys(), ...latestMap.keys()]); + + return Array.from(names) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)) + .map((name) => { + const baselineEntry = baselineMap.get(name) ?? { + name, + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }; + const latestEntry = latestMap.get(name) ?? { + name, + caseCount: 0, + readyCount: 0, + invalidCount: 0, + pendingRequestCaseCount: 0, + needsHumanReviewCount: 0, + }; + + return { + name, + baseline: baselineEntry, + latest: latestEntry, + delta: { + caseCount: latestEntry.caseCount - baselineEntry.caseCount, + readyCount: latestEntry.readyCount - baselineEntry.readyCount, + invalidCount: latestEntry.invalidCount - baselineEntry.invalidCount, + pendingRequestCaseCount: + latestEntry.pendingRequestCaseCount - + baselineEntry.pendingRequestCaseCount, + needsHumanReviewCount: + latestEntry.needsHumanReviewCount - + baselineEntry.needsHumanReviewCount, + }, + }; + }) + .sort((left, right) => { + const invalidDeltaDiff = + Math.abs(right.delta.invalidCount) - Math.abs(left.delta.invalidCount); + if (invalidDeltaDiff !== 0) { + return invalidDeltaDiff; + } + const caseDeltaDiff = + Math.abs(right.delta.caseCount) - Math.abs(left.delta.caseCount); + if (caseDeltaDiff !== 0) { + return caseDeltaDiff; + } + return left.name.localeCompare(right.name); + }); +} + +function buildStatusSignals(baseline, latest, sampleCount) { + const signals = []; + + if (sampleCount < 2) { + signals.push("样本数不足 2,当前仅形成 trend seed,还不能判断长期退化。"); + return signals; + } + + const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline); + const invalidDelta = + normalizeNumber(latest?.totals?.invalidCount) - + normalizeNumber(baseline?.totals?.invalidCount); + const pendingDelta = + normalizeNumber(latest?.totals?.pendingRequestCaseCount) - + normalizeNumber(baseline?.totals?.pendingRequestCaseCount); + + if (invalidDelta > 0) { + signals.push(`invalid case 增加 ${invalidDelta},存在回归候选。`); + } + + if (readyRateDelta < 0) { + signals.push( + `ready rate 下降 ${(Math.abs(readyRateDelta) * 100).toFixed(1)}%,需检查最近样本或字段漂移。`, + ); + } + + if (pendingDelta > 0) { + signals.push( + `pending request case 增加 ${pendingDelta},需确认是否属于真实阻塞还是样本结构变化。`, + ); + } + + const failureModeDeltas = buildBreakdownDeltas( + baseline, + latest, + "failureModes", + ); + const increasedInvalidFailureMode = failureModeDeltas.find( + (entry) => entry.delta.invalidCount > 0, + ); + if (increasedInvalidFailureMode) { + signals.push( + `failure mode \`${increasedInvalidFailureMode.name}\` 的 invalid case 增加 ${increasedInvalidFailureMode.delta.invalidCount}。`, + ); + } + + if (signals.length === 0) { + signals.push("当前没有检测到明显退化信号。"); + } + + return signals; +} + +function buildTrendReport(samples, repoRoot) { + const sortedSamples = [...samples].sort((left, right) => { + const leftTime = Date.parse(left.summary.generatedAt); + const rightTime = Date.parse(right.summary.generatedAt); + if ( + Number.isFinite(leftTime) && + Number.isFinite(rightTime) && + leftTime !== rightTime + ) { + return leftTime - rightTime; + } + return left.summary.generatedAt.localeCompare(right.summary.generatedAt); + }); + + const baselineEntry = sortedSamples[0]; + const latestEntry = sortedSamples[sortedSamples.length - 1]; + const baseline = baselineEntry.summary; + const latest = latestEntry.summary; + const readyRateDelta = computeReadyRate(latest) - computeReadyRate(baseline); + + return { + reportVersion: "v1", + generatedAt: new Date().toISOString(), + repoRoot, + sampleCount: sortedSamples.length, + baseline: { + generatedAt: baseline.generatedAt, + sourcePath: baselineEntry.sourcePath, + totals: baseline.totals, + }, + latest: { + generatedAt: latest.generatedAt, + sourcePath: latestEntry.sourcePath, + totals: latest.totals, + }, + delta: { + suiteCount: + normalizeNumber(latest?.totals?.suiteCount) - + normalizeNumber(baseline?.totals?.suiteCount), + caseCount: + normalizeNumber(latest?.totals?.caseCount) - + normalizeNumber(baseline?.totals?.caseCount), + readyCount: + normalizeNumber(latest?.totals?.readyCount) - + normalizeNumber(baseline?.totals?.readyCount), + invalidCount: + normalizeNumber(latest?.totals?.invalidCount) - + normalizeNumber(baseline?.totals?.invalidCount), + pendingRequestCaseCount: + normalizeNumber(latest?.totals?.pendingRequestCaseCount) - + normalizeNumber(baseline?.totals?.pendingRequestCaseCount), + needsHumanReviewCount: + normalizeNumber(latest?.totals?.needsHumanReviewCount) - + normalizeNumber(baseline?.totals?.needsHumanReviewCount), + readyRate: readyRateDelta, + }, + signals: buildStatusSignals(baseline, latest, sortedSamples.length), + samples: sortedSamples.map((entry) => ({ + generatedAt: entry.summary.generatedAt, + sourcePath: entry.sourcePath, + totals: entry.summary.totals, + })), + suiteDeltas: buildSuiteDeltas(baseline, latest), + classificationDeltas: { + suiteTags: buildBreakdownDeltas(baseline, latest, "suiteTags"), + failureModes: buildBreakdownDeltas(baseline, latest, "failureModes"), + }, + }; +} + +function renderText(report) { + const lines = [ + `[harness-eval-trend] samples: ${report.sampleCount}`, + `[harness-eval-trend] baseline: ${report.baseline.generatedAt}`, + `[harness-eval-trend] latest : ${report.latest.generatedAt}`, + `[harness-eval-trend] delta caseCount: ${report.delta.caseCount}`, + `[harness-eval-trend] delta readyCount: ${report.delta.readyCount}`, + `[harness-eval-trend] delta invalidCount: ${report.delta.invalidCount}`, + `[harness-eval-trend] delta pendingRequestCaseCount: ${report.delta.pendingRequestCaseCount}`, + `[harness-eval-trend] delta readyRate: ${(report.delta.readyRate * 100).toFixed(1)}%`, + ]; + + for (const signal of report.signals) { + lines.push(`[harness-eval-trend] signal: ${signal}`); + } + + const topFailureModeDeltas = report.classificationDeltas.failureModes.slice( + 0, + 5, + ); + if (topFailureModeDeltas.length > 0) { + lines.push("[harness-eval-trend] top failure mode deltas:"); + for (const entry of topFailureModeDeltas) { + lines.push( + ` - ${entry.name}: delta_case=${entry.delta.caseCount}, delta_invalid=${entry.delta.invalidCount}, delta_pending=${entry.delta.pendingRequestCaseCount}`, + ); + } + } + + return `${lines.join("\n")}\n`; +} + +function renderMarkdown(report) { + const lines = [ + "# Lime Harness Eval Trend", + "", + `- 生成时间:${report.generatedAt}`, + `- 样本数:${report.sampleCount}`, + `- baseline:${report.baseline.generatedAt}`, + `- latest:${report.latest.generatedAt}`, + "", + "## 核心变化", + "", + `- suite 数变化:${report.delta.suiteCount}`, + `- case 数变化:${report.delta.caseCount}`, + `- ready 数变化:${report.delta.readyCount}`, + `- invalid 数变化:${report.delta.invalidCount}`, + `- pending request case 变化:${report.delta.pendingRequestCaseCount}`, + `- needs review case 变化:${report.delta.needsHumanReviewCount}`, + `- ready rate 变化:${(report.delta.readyRate * 100).toFixed(1)}%`, + "", + "## 信号", + "", + ]; + + for (const signal of report.signals) { + lines.push(`- ${signal}`); + } + + if (report.classificationDeltas.failureModes.length > 0) { + lines.push(""); + lines.push("## Failure Mode 变化"); + lines.push(""); + lines.push( + "| Failure Mode | baseline case | latest case | delta case | delta invalid | delta pending_request |", + ); + lines.push("| --- | --- | --- | --- | --- | --- |"); + for (const entry of report.classificationDeltas.failureModes) { + lines.push( + `| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} | ${entry.delta.pendingRequestCaseCount} |`, + ); + } + } + + if (report.classificationDeltas.suiteTags.length > 0) { + lines.push(""); + lines.push("## Suite Tag 变化"); + lines.push(""); + lines.push( + "| Suite Tag | baseline case | latest case | delta case | delta invalid |", + ); + lines.push("| --- | --- | --- | --- | --- |"); + for (const entry of report.classificationDeltas.suiteTags) { + lines.push( + `| ${entry.name} | ${entry.baseline.caseCount} | ${entry.latest.caseCount} | ${entry.delta.caseCount} | ${entry.delta.invalidCount} |`, + ); + } + } + + lines.push(""); + lines.push("## 时间线样本"); + lines.push(""); + lines.push("| 时间 | 来源 | case | ready | invalid | pending_request |"); + lines.push("| --- | --- | --- | --- | --- | --- |"); + for (const sample of report.samples) { + lines.push( + `| ${sample.generatedAt} | \`${sample.sourcePath}\` | ${sample.totals.caseCount} | ${sample.totals.readyCount} | ${sample.totals.invalidCount} | ${sample.totals.pendingRequestCaseCount} |`, + ); + } + + lines.push(""); + lines.push("## Suite 变化"); + lines.push(""); + lines.push( + "| Suite | baseline ready/total | latest ready/total | invalid delta |", + ); + lines.push("| --- | --- | --- | --- |"); + for (const suite of report.suiteDeltas) { + lines.push( + `| ${suite.title} | ${suite.baseline.readyCount}/${suite.baseline.caseCount} | ${suite.latest.readyCount}/${suite.latest.caseCount} | ${suite.delta.invalidCount} |`, + ); + } + + return `${lines.join("\n")}\n`; +} + +function loadSamples(options, repoRoot) { + const sampleEntries = []; + const seenFingerprints = new Set(); + + const candidateFiles = []; + for (const input of options.inputs) { + candidateFiles.push(resolvePath(repoRoot, input)); + } + if (options.historyDir) { + candidateFiles.push( + ...collectJsonFiles(resolvePath(repoRoot, options.historyDir)), + ); + } + + for (const filePath of candidateFiles) { + if (!fs.existsSync(filePath)) { + continue; + } + + let parsed; + try { + parsed = readJsonFile(filePath); + } catch { + continue; + } + + if (!isHarnessEvalSummary(parsed)) { + continue; + } + + const fingerprint = JSON.stringify([ + parsed.generatedAt, + parsed.totals.caseCount, + parsed.totals.readyCount, + parsed.totals.invalidCount, + parsed.totals.pendingRequestCaseCount, + ]); + if (seenFingerprints.has(fingerprint)) { + continue; + } + seenFingerprints.add(fingerprint); + + sampleEntries.push({ + sourcePath: path.relative(repoRoot, filePath) || ".", + summary: parsed, + }); + } + + if (sampleEntries.length === 0) { + const currentSummary = buildCurrentSummary( + repoRoot, + path.resolve(options.workspaceRoot), + ); + sampleEntries.push({ + sourcePath: "(generated-current-summary)", + summary: currentSummary, + }); + } + + return sampleEntries; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const repoRoot = process.cwd(); + const samples = loadSamples(options, repoRoot); + const report = buildTrendReport(samples, repoRoot); + const jsonOutput = `${JSON.stringify(report, null, 2)}\n`; + const markdownOutput = renderMarkdown(report); + const textOutput = renderText(report); + + if (options.outputJson) { + const outputPath = resolvePath(repoRoot, options.outputJson); + ensureParentDirectory(outputPath); + fs.writeFileSync(outputPath, jsonOutput, "utf8"); + } + + if (options.outputMarkdown) { + const outputPath = resolvePath(repoRoot, options.outputMarkdown); + ensureParentDirectory(outputPath); + fs.writeFileSync(outputPath, markdownOutput, "utf8"); + } + + if (options.format === "json") { + process.stdout.write(jsonOutput); + return; + } + + if (options.format === "markdown") { + process.stdout.write(markdownOutput); + return; + } + + process.stdout.write(textOutput); +} + +main(); diff --git a/scripts/harness-replay-promote.mjs b/scripts/harness-replay-promote.mjs new file mode 100644 index 000000000..664bf0124 --- /dev/null +++ b/scripts/harness-replay-promote.mjs @@ -0,0 +1,635 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const DEFAULT_MANIFEST_PATH = "docs/test/harness-evals.manifest.json"; +const DEFAULT_FIXTURES_ROOT = "docs/test/harness-fixtures/replay"; +const DEFAULT_SUITE_ID = "repo-promoted-replays"; +const DEFAULT_SANITIZED_WORKSPACE_ROOT = "/workspace/lime"; +const REQUIRED_ARTIFACTS = [ + "input.json", + "expected.json", + "grader.md", + "evidence-links.json", +]; + +function parseArgs(argv) { + const result = { + caseId: "", + dryRun: false, + fixturesRoot: DEFAULT_FIXTURES_ROOT, + format: "text", + help: false, + manifest: DEFAULT_MANIFEST_PATH, + replace: false, + replayDir: "", + sanitizedWorkspaceRoot: DEFAULT_SANITIZED_WORKSPACE_ROOT, + sessionId: "", + slug: "", + suiteId: DEFAULT_SUITE_ID, + title: "", + workspaceRoot: process.cwd(), + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--session-id" && argv[index + 1]) { + result.sessionId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--replay-dir" && argv[index + 1]) { + result.replayDir = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--workspace-root" && argv[index + 1]) { + result.workspaceRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--manifest" && argv[index + 1]) { + result.manifest = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--fixtures-root" && argv[index + 1]) { + result.fixturesRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--suite-id" && argv[index + 1]) { + result.suiteId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--slug" && argv[index + 1]) { + result.slug = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--case-id" && argv[index + 1]) { + result.caseId = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--title" && argv[index + 1]) { + result.title = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--sanitized-workspace-root" && argv[index + 1]) { + result.sanitizedWorkspaceRoot = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + if (arg === "--replace") { + result.replace = true; + continue; + } + + if (arg === "--dry-run") { + result.dryRun = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime Harness Replay Promote + +用法: + node scripts/harness-replay-promote.mjs --session-id "session-123" --slug "pending-request-runtime" + node scripts/harness-replay-promote.mjs --replay-dir ".lime/harness/sessions/session-123/replay" --slug "pending-request-runtime" + +选项: + --session-id ID 从 /.lime/harness/sessions//replay 提升 + --replay-dir PATH 直接指定 replay 目录;与 --session-id 二选一 + --workspace-root PATH 工作区根目录,默认当前目录 + --manifest PATH manifest 路径,默认 docs/test/harness-evals.manifest.json + --fixtures-root PATH 目标 fixture 根目录,默认 docs/test/harness-fixtures/replay + --suite-id ID 目标 suite,默认 repo-promoted-replays + --slug NAME 目标目录名;未提供时会从 sessionId 推导 + --case-id ID manifest 中的 case id;默认 repo-promoted- + --title TEXT manifest 中的 case 标题;默认用 goal summary 推导 + --sanitized-workspace-root PATH 写入仓库样本时替换绝对工作区路径,默认 /workspace/lime + --replace 已存在同名 case / 目录时覆盖 + --dry-run 只预览,不写文件 + --format FMT 标准输出格式:text | json + -h, --help 显示帮助 +`); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function writeJsonFile(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function ensureDirectory(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function resolvePath(baseDir, targetPath) { + return path.resolve(baseDir, targetPath); +} + +function toPortablePath(value) { + return String(value).replaceAll("\\", "/"); +} + +function normalizeStringList(value) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); +} + +function mergeUniqueStrings(...groups) { + return [...new Set(groups.flatMap((group) => normalizeStringList(group)))]; +} + +function slugify(value) { + return String(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +function deriveReplayDirectory(options, workspaceRoot) { + if (options.replayDir) { + return resolvePath(process.cwd(), options.replayDir); + } + + if (!options.sessionId) { + throw new Error("必须提供 --session-id 或 --replay-dir。"); + } + + return path.join( + workspaceRoot, + ".lime", + "harness", + "sessions", + options.sessionId, + "replay", + ); +} + +function validateReplayDirectory(replayDir) { + if (!fs.existsSync(replayDir) || !fs.statSync(replayDir).isDirectory()) { + throw new Error(`replay 目录不存在: ${replayDir}`); + } + + const missing = REQUIRED_ARTIFACTS.filter( + (artifact) => !fs.existsSync(path.join(replayDir, artifact)), + ); + if (missing.length > 0) { + throw new Error(`replay 目录缺少文件: ${missing.join(", ")}`); + } +} + +function deriveSlug(options, inputPayload, fallbackSessionId) { + if (options.slug) { + return slugify(options.slug); + } + + const derivedFromGoal = slugify( + inputPayload?.task?.goalSummary ?? + inputPayload?.classification?.primaryBlockingKind ?? + "", + ); + if (derivedFromGoal) { + return derivedFromGoal; + } + + const derivedFromSession = slugify(fallbackSessionId); + if (derivedFromSession) { + return derivedFromSession; + } + + return "promoted-replay-case"; +} + +function deriveCaseId(options, slug) { + return options.caseId || `repo-promoted-${slug}`; +} + +function deriveTitle(options, inputPayload, expectedPayload, sessionId) { + if (options.title) { + return options.title; + } + + const goalSummary = + inputPayload?.task?.goalSummary ?? expectedPayload?.goalSummary ?? ""; + if (typeof goalSummary === "string" && goalSummary.trim().length > 0) { + return goalSummary.trim(); + } + + return `工作区 Replay 沉淀 / ${sessionId}`; +} + +function replaceWorkspaceRootInString(value, workspaceRoot, placeholder) { + if (typeof value !== "string" || value.length === 0) { + return value; + } + + let nextValue = value; + const rawRoot = String(workspaceRoot); + const portableRoot = toPortablePath(rawRoot); + + if (rawRoot) { + nextValue = nextValue.replaceAll(rawRoot, placeholder); + } + if (portableRoot && portableRoot !== rawRoot) { + nextValue = nextValue.replaceAll(portableRoot, placeholder); + } + + if (nextValue.includes(placeholder) && nextValue.includes("\\")) { + nextValue = nextValue.replaceAll("\\", "/"); + } + + return nextValue; +} + +function sanitizePayload(value, workspaceRoot, placeholder) { + if (typeof value === "string") { + return replaceWorkspaceRootInString(value, workspaceRoot, placeholder); + } + + if (Array.isArray(value)) { + return value.map((entry) => + sanitizePayload(entry, workspaceRoot, placeholder), + ); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + sanitizePayload(entryValue, workspaceRoot, placeholder), + ]), + ); + } + + return value; +} + +function getRelativeIfInside(rootPath, absolutePath) { + const relativePath = path.relative(rootPath, absolutePath); + if ( + relativePath.startsWith("..") || + path.isAbsolute(relativePath) || + relativePath === "" + ) { + return relativePath === "" ? "." : null; + } + return toPortablePath(relativePath); +} + +function buildPromotionMetadata({ + promotedAt, + replayDir, + sessionId, + workspaceRoot, + sanitizedWorkspaceRoot, +}) { + const replayRelativeDir = getRelativeIfInside(workspaceRoot, replayDir); + const metadata = { + promotedAt, + promotedBy: "scripts/harness-replay-promote.mjs", + sanitizedWorkspaceRoot, + sourceSessionId: sessionId, + }; + + if (replayRelativeDir && replayRelativeDir !== ".") { + metadata.sourceReplayDir = replayRelativeDir; + } + + return metadata; +} + +function appendPromotionSection(graderMarkdown, promotionMetadata) { + if (graderMarkdown.includes("## 仓库沉淀说明")) { + return graderMarkdown; + } + + const lines = [ + graderMarkdown.trimEnd(), + "", + "## 仓库沉淀说明", + "", + `- 提升时间:${promotionMetadata.promotedAt}`, + `- 来源会话:\`${promotionMetadata.sourceSessionId}\``, + `- 脱敏工作区根:\`${promotionMetadata.sanitizedWorkspaceRoot}\``, + ]; + + if (promotionMetadata.sourceReplayDir) { + lines.push(`- 来源 replay 目录:\`${promotionMetadata.sourceReplayDir}\``); + } + + return `${lines.join("\n")}\n`; +} + +function loadSuite(manifestPayload, suiteId) { + const suites = Array.isArray(manifestPayload.suites) ? manifestPayload.suites : []; + const suiteIndex = suites.findIndex( + (suite) => String(suite.id ?? "").trim() === suiteId, + ); + if (suiteIndex === -1) { + throw new Error(`manifest 中未找到目标 suite: ${suiteId}`); + } + return { + suite: suites[suiteIndex], + suiteIndex, + suites, + }; +} + +function buildManifestCaseEntry({ + caseId, + caseTitle, + inputPayload, + targetCaseDirValue, +}) { + return { + id: caseId, + title: caseTitle, + source: "repo_fixture", + caseDir: targetCaseDirValue, + tags: mergeUniqueStrings( + ["repo-promoted"], + inputPayload?.classification?.suiteTags, + ), + }; +} + +function updateManifestCase({ + manifestPath, + suiteId, + caseEntry, + replace, + targetCaseDirValue, +}) { + const manifestPayload = readJsonFile(manifestPath); + const { suite } = loadSuite(manifestPayload, suiteId); + const cases = Array.isArray(suite.cases) ? [...suite.cases] : []; + const normalizedTargetDir = toPortablePath(targetCaseDirValue); + + const existingIndex = cases.findIndex((entry) => { + const caseId = String(entry.id ?? "").trim(); + const caseDir = toPortablePath(String(entry.caseDir ?? "").trim()); + return caseId === caseEntry.id || caseDir === normalizedTargetDir; + }); + + if (existingIndex >= 0 && !replace) { + throw new Error( + `manifest 已存在同名 case 或同目录 case,请使用 --replace 覆盖: ${caseEntry.id}`, + ); + } + + if (existingIndex >= 0) { + const existing = cases[existingIndex]; + cases[existingIndex] = { + ...existing, + ...caseEntry, + tags: mergeUniqueStrings(existing.tags, caseEntry.tags), + }; + } else { + cases.push(caseEntry); + } + + cases.sort((left, right) => + String(left.id ?? "").localeCompare(String(right.id ?? "")), + ); + suite.cases = cases; + writeJsonFile(manifestPath, manifestPayload); + + return { + manifestPayload, + replaced: existingIndex >= 0, + }; +} + +function writePromotedArtifacts({ + evidencePayload, + expectedPayload, + graderMarkdown, + inputPayload, + targetDir, +}) { + ensureDirectory(targetDir); + writeJsonFile(path.join(targetDir, "input.json"), inputPayload); + writeJsonFile(path.join(targetDir, "expected.json"), expectedPayload); + writeJsonFile(path.join(targetDir, "evidence-links.json"), evidencePayload); + fs.writeFileSync(path.join(targetDir, "grader.md"), graderMarkdown, "utf8"); +} + +function toManifestCaseDirValue(repoRoot, targetDir) { + const relativeToRepo = path.relative(repoRoot, targetDir); + if ( + relativeToRepo && + !relativeToRepo.startsWith("..") && + !path.isAbsolute(relativeToRepo) + ) { + return toPortablePath(relativeToRepo); + } + return toPortablePath(targetDir); +} + +function renderText(result) { + const lines = [ + `[harness-replay-promote] suite: ${result.suiteId}`, + `[harness-replay-promote] case : ${result.caseId}`, + `[harness-replay-promote] title: ${result.title}`, + `[harness-replay-promote] replay: ${result.sourceReplayDir}`, + `[harness-replay-promote] target: ${result.targetCaseDir}`, + `[harness-replay-promote] manifest target: ${result.manifestCaseDir}`, + `[harness-replay-promote] dry-run: ${result.dryRun ? "yes" : "no"}`, + `[harness-replay-promote] replaced: ${result.replaced ? "yes" : "no"}`, + ]; + + if (result.tags.length > 0) { + lines.push(`[harness-replay-promote] tags: ${result.tags.join(", ")}`); + } + + return `${lines.join("\n")}\n`; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const repoRoot = process.cwd(); + const workspaceRoot = resolvePath(repoRoot, options.workspaceRoot); + const replayDir = deriveReplayDirectory(options, workspaceRoot); + validateReplayDirectory(replayDir); + + const inputPath = path.join(replayDir, "input.json"); + const expectedPath = path.join(replayDir, "expected.json"); + const graderPath = path.join(replayDir, "grader.md"); + const evidencePath = path.join(replayDir, "evidence-links.json"); + + const originalInputPayload = readJsonFile(inputPath); + const originalExpectedPayload = readJsonFile(expectedPath); + const originalEvidencePayload = readJsonFile(evidencePath); + const originalGraderMarkdown = fs.readFileSync(graderPath, "utf8"); + + const sessionId = + String( + originalInputPayload?.session?.sessionId ?? + path.basename(path.dirname(replayDir)), + ).trim() || "unknown-session"; + const slug = deriveSlug(options, originalInputPayload, sessionId); + if (!slug) { + throw new Error("无法推导目标 slug,请显式提供 --slug。"); + } + + const caseId = deriveCaseId(options, slug); + const title = deriveTitle( + options, + originalInputPayload, + originalExpectedPayload, + sessionId, + ); + const promotedAt = new Date().toISOString(); + const promotionMetadata = buildPromotionMetadata({ + promotedAt, + replayDir, + sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot, + sessionId, + workspaceRoot, + }); + + const inputPayload = sanitizePayload( + originalInputPayload, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ); + inputPayload.source = "lime.repo_promoted.replay_case"; + inputPayload.classification = { + ...(inputPayload.classification ?? {}), + sourceKind: "repo_promoted_fixture", + }; + inputPayload.promotion = promotionMetadata; + + const expectedPayload = sanitizePayload( + originalExpectedPayload, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ); + expectedPayload.promotion = promotionMetadata; + + const evidencePayload = sanitizePayload( + originalEvidencePayload, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ); + evidencePayload.promotion = promotionMetadata; + + const graderMarkdown = appendPromotionSection( + replaceWorkspaceRootInString( + originalGraderMarkdown, + workspaceRoot, + options.sanitizedWorkspaceRoot, + ), + promotionMetadata, + ); + + const fixturesRoot = resolvePath(repoRoot, options.fixturesRoot); + const targetDir = path.join(fixturesRoot, slug); + const targetExists = fs.existsSync(targetDir); + if (targetExists && !options.replace) { + throw new Error(`目标目录已存在,请使用 --replace 覆盖: ${targetDir}`); + } + + const manifestPath = resolvePath(repoRoot, options.manifest); + const manifestCaseDir = toManifestCaseDirValue(repoRoot, targetDir); + const caseEntry = buildManifestCaseEntry({ + caseId, + caseTitle: title, + inputPayload, + targetCaseDirValue: manifestCaseDir, + }); + + let replaced = false; + if (!options.dryRun) { + if (targetExists) { + fs.rmSync(targetDir, { recursive: true, force: true }); + } + writePromotedArtifacts({ + evidencePayload, + expectedPayload, + graderMarkdown, + inputPayload, + targetDir, + }); + const manifestUpdate = updateManifestCase({ + caseEntry, + manifestPath, + replace: options.replace, + suiteId: options.suiteId, + targetCaseDirValue: manifestCaseDir, + }); + replaced = manifestUpdate.replaced; + } + + const result = { + caseId, + dryRun: options.dryRun, + manifestCaseDir, + manifestPath, + replaced, + sanitizedWorkspaceRoot: options.sanitizedWorkspaceRoot, + slug, + sourceReplayDir: toPortablePath(replayDir), + suiteId: options.suiteId, + tags: caseEntry.tags, + targetCaseDir: toPortablePath(targetDir), + title, + }; + + if (options.format === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + process.stdout.write(renderText(result)); +} + +main(); diff --git a/scripts/site-adapter-catalog-smoke.mjs b/scripts/site-adapter-catalog-smoke.mjs new file mode 100644 index 000000000..119281770 --- /dev/null +++ b/scripts/site-adapter-catalog-smoke.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +import process from "node:process"; + +const DEFAULTS = { + healthUrl: "http://127.0.0.1:3030/health", + invokeUrl: "http://127.0.0.1:3030/invoke", + timeoutMs: 60_000, + intervalMs: 1_000, +}; + +function printHelp() { + console.log(` +Lime Site Adapter Catalog Smoke + +用途: + 验证站点适配器目录最小主链可用:目录状态、列表、推荐与检索结果可读。 + +用法: + node scripts/site-adapter-catalog-smoke.mjs [选项] + +选项: + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke + --timeout-ms 等待健康检查超时,默认 60000 + --interval-ms 健康检查轮询间隔,默认 1000 + -h, --help 显示帮助 +`); +} + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--health-url" && argv[index + 1]) { + options.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--invoke-url" && argv[index + 1]) { + options.invokeUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--timeout-ms" && argv[index + 1]) { + options.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--interval-ms" && argv[index + 1]) { + options.intervalMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1_000) { + throw new Error("--timeout-ms 必须是 >= 1000 的数字"); + } + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl, { method: "GET" }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log( + `[smoke:site-adapters] DevBridge 已就绪 (${Date.now() - startedAt}ms)${ + payload?.status ? ` status=${payload.status}` : "" + }`, + ); + return; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[smoke:site-adapters] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`, + ); +} + +async function invoke(invokeUrl, cmd, args) { + const response = await fetch(invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload?.error) { + throw new Error(String(payload.error)); + } + + return payload?.result; +} + +async function main() { + if (typeof fetch !== "function") { + throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); + } + + const options = parseArgs(process.argv.slice(2)); + await waitForHealth(options); + + const status = await invoke(options.invokeUrl, "site_get_adapter_catalog_status"); + assert(status && typeof status === "object", "site_get_adapter_catalog_status 返回为空"); + assert( + typeof status.adapter_count === "number" && status.adapter_count >= 0, + "site_get_adapter_catalog_status 缺少 adapter_count", + ); + assert( + status.source_kind === "bundled" || status.source_kind === "server_synced", + "site_get_adapter_catalog_status 返回了未知 source_kind", + ); + + const adapters = await invoke(options.invokeUrl, "site_list_adapters"); + assert(Array.isArray(adapters), "site_list_adapters 返回不是数组"); + assert(adapters.length > 0, "site_list_adapters 返回为空"); + + const adapter = adapters[0]; + assert( + typeof adapter?.name === "string" && adapter.name.trim(), + "site_list_adapters 首项缺少 name", + ); + assert( + typeof adapter?.domain === "string" && adapter.domain.trim(), + "site_list_adapters 首项缺少 domain", + ); + + const recommendations = await invoke(options.invokeUrl, "site_recommend_adapters", { + request: { + limit: 3, + }, + }); + assert(Array.isArray(recommendations), "site_recommend_adapters 返回不是数组"); + if (recommendations.length > 0) { + const recommendation = recommendations[0]; + assert( + typeof recommendation?.adapter?.name === "string" && + recommendation.adapter.name.trim(), + "site_recommend_adapters 首项缺少 adapter.name", + ); + assert( + typeof recommendation?.reason === "string" && recommendation.reason.trim(), + "site_recommend_adapters 首项缺少 reason", + ); + assert( + typeof recommendation?.entry_url === "string" && + recommendation.entry_url.trim(), + "site_recommend_adapters 首项缺少 entry_url", + ); + } + + const searchResults = await invoke(options.invokeUrl, "site_search_adapters", { + request: { + query: adapter.name, + }, + }); + assert(Array.isArray(searchResults), "site_search_adapters 返回不是数组"); + assert( + searchResults.some((item) => item?.name === adapter.name), + "site_search_adapters 未返回刚刚列出的适配器", + ); + + console.log( + `[smoke:site-adapters] 通过 adapters=${adapters.length} source=${status.source_kind} recommended=${recommendations.length}`, + ); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 2a50f4e28..6da87f92c 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", healthUrl: "http://127.0.0.1:3030/health", - timeoutMs: 120_000, + timeoutMs: 180_000, intervalMs: 1_000, reuseRunning: false, sampleProjectName: "Lime Smoke Workspace", @@ -39,7 +39,7 @@ Lime GUI 冒烟入口 选项: --app-url 前端地址,默认 http://127.0.0.1:1420/ --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health - --timeout-ms 等待 headless / bridge / smoke 的超时,默认 120000 + --timeout-ms 等待 headless / bridge / smoke 的超时,默认 180000 --interval-ms 轮询间隔,默认 1000 --sample-project-name workspace 路径校验使用的示例项目名 --reuse-running 复用已启动的 headless Tauri,不主动拉起 @@ -357,6 +357,34 @@ async function main() { "smoke:workspace-ready", ); + runCommand( + npmCommand, + [ + "run", + "smoke:browser-runtime", + "--", + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ], + "smoke:browser-runtime", + ); + + runCommand( + npmCommand, + [ + "run", + "smoke:site-adapters", + "--", + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ], + "smoke:site-adapters", + ); + console.log("\n[verify:gui-smoke] 通过"); } finally { if (startedByScript) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 984ad9cfc..df6f53363 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.96.0" +version = "0.97.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.96.0" +version = "0.97.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.96.0" +version = "0.97.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.96.0" +version = "0.97.0" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.96.0" +version = "0.97.0" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5307,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.96.0" +version = "0.97.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5342,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.96.0" +version = "0.97.0" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5372,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.96.0" +version = "0.97.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.96.0" +version = "0.97.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5424,7 +5424,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.96.0" +version = "0.97.0" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5443,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.96.0" +version = "0.97.0" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5498,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.96.0" +version = "0.97.0" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5543,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.96.0" +version = "0.97.0" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.96.0" +version = "0.97.0" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5600,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.96.0" +version = "0.97.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.96.0" +version = "0.97.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.96.0" +version = "0.97.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e29bb9a8e..490620bd3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.96.0" +version = "0.97.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.96.0" +version = "0.97.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index a36a684e5..bdf84e64f 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -102,7 +102,11 @@ pub use runtime_queue::{ RuntimeQueueExecutor, }; pub use session_execution_runtime::{ - build_session_execution_runtime, SessionExecutionRuntime, SessionExecutionRuntimeSource, + build_session_execution_runtime, extract_recent_content_id_from_runtime_snapshot, + persist_session_recent_preferences, persist_session_recent_team_selection, + SessionExecutionRuntime, SessionExecutionRuntimePreferences, + SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection, + SessionExecutionRuntimeSource, }; pub use session_query::{ collect_subagent_cascade_session_ids, list_child_subagent_sessions, @@ -115,7 +119,8 @@ pub use session_store::{ list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync, update_session_provider_config_sync, update_session_working_dir_sync, ChildSubagentRuntimeStatus, ChildSubagentSession, PersistedSessionMetadata, SessionDetail, - SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SubagentParentContext, + SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SessionTodoStatus, + SubagentParentContext, }; pub use session_update::{ create_subagent_session, persist_compaction_session_metrics_update, diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index 6d1f4f2cb..bde0835d4 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -1,3 +1,6 @@ +use crate::session_query::read_session; +use crate::session_update::persist_session_extension_data; +use aster::session::extension_data::{ExtensionData, ExtensionState}; use aster::session::{Session, SessionRuntimeSnapshot, TurnOutputSchemaRuntime, TurnStatus}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -29,6 +32,165 @@ pub struct SessionExecutionRuntimePreferences { pub subagent: bool, } +impl ExtensionState for SessionExecutionRuntimePreferences { + const EXTENSION_NAME: &'static str = "lime_recent_preferences"; + const VERSION: &'static str = "v0"; +} + +impl SessionExecutionRuntimePreferences { + fn from_extension_data(extension_data: &ExtensionData) -> Option { + ::from_extension_data(extension_data) + } + + fn from_session(session: &Session) -> Option { + Self::from_extension_data(&session.extension_data) + } + + fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(self, extension_data) + .map_err(|error| error.to_string()) + } + + fn into_updated_extension_data(self, session: &Session) -> Result { + let mut extension_data = session.extension_data.clone(); + self.to_extension_data(&mut extension_data)?; + Ok(extension_data) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionExecutionRuntimeRecentTeamRole { + #[serde(default)] + pub id: String, + #[serde(default)] + pub label: String, + #[serde(default)] + pub summary: String, + #[serde(default, alias = "profile_id")] + pub profile_id: Option, + #[serde(default, alias = "role_key")] + pub role_key: Option, + #[serde(default, alias = "skill_ids")] + pub skill_ids: Vec, +} + +impl SessionExecutionRuntimeRecentTeamRole { + fn normalize(self) -> Option { + let id = self.id.trim().to_string(); + let label = self.label.trim().to_string(); + let summary = self.summary.trim().to_string(); + if label.is_empty() && summary.is_empty() { + return None; + } + + let skill_ids = self + .skill_ids + .into_iter() + .filter_map(|skill_id| normalize_optional_text(Some(skill_id))) + .collect(); + + Some(Self { + id, + label, + summary, + profile_id: normalize_optional_text(self.profile_id), + role_key: normalize_optional_text(self.role_key), + skill_ids, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionExecutionRuntimeRecentTeamSelection { + #[serde(default)] + pub disabled: bool, + #[serde(default)] + pub theme: Option, + #[serde(default, alias = "preferred_team_preset_id")] + pub preferred_team_preset_id: Option, + #[serde(default, alias = "selected_team_id")] + pub selected_team_id: Option, + #[serde(default, alias = "selected_team_source")] + pub selected_team_source: Option, + #[serde(default, alias = "selected_team_label")] + pub selected_team_label: Option, + #[serde(default, alias = "selected_team_description")] + pub selected_team_description: Option, + #[serde(default, alias = "selected_team_summary")] + pub selected_team_summary: Option, + #[serde(default, alias = "selected_team_roles")] + pub selected_team_roles: Option>, +} + +impl ExtensionState for SessionExecutionRuntimeRecentTeamSelection { + const EXTENSION_NAME: &'static str = "lime_recent_team_selection"; + const VERSION: &'static str = "v0"; +} + +impl SessionExecutionRuntimeRecentTeamSelection { + fn normalize(self) -> Option { + let selected_team_roles = self + .selected_team_roles + .map(|roles| { + roles + .into_iter() + .filter_map(SessionExecutionRuntimeRecentTeamRole::normalize) + .collect::>() + }) + .filter(|roles| !roles.is_empty()); + + let normalized = Self { + disabled: self.disabled, + theme: normalize_optional_text(self.theme), + preferred_team_preset_id: normalize_optional_text(self.preferred_team_preset_id), + selected_team_id: normalize_optional_text(self.selected_team_id), + selected_team_source: normalize_optional_text(self.selected_team_source), + selected_team_label: normalize_optional_text(self.selected_team_label), + selected_team_description: normalize_optional_text(self.selected_team_description), + selected_team_summary: normalize_optional_text(self.selected_team_summary), + selected_team_roles, + }; + + if normalized.disabled { + return Some(normalized); + } + + if normalized.preferred_team_preset_id.is_none() + && normalized.selected_team_id.is_none() + && normalized.selected_team_source.is_none() + && normalized.selected_team_label.is_none() + && normalized.selected_team_description.is_none() + && normalized.selected_team_summary.is_none() + && normalized.selected_team_roles.is_none() + { + return None; + } + + Some(normalized) + } + + fn from_extension_data(extension_data: &ExtensionData) -> Option { + ::from_extension_data(extension_data).and_then(Self::normalize) + } + + fn from_session(session: &Session) -> Option { + Self::from_extension_data(&session.extension_data) + } + + fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(self, extension_data) + .map_err(|error| error.to_string()) + } + + fn into_updated_extension_data(self, session: &Session) -> Result { + let mut extension_data = session.extension_data.clone(); + self.to_extension_data(&mut extension_data)?; + Ok(extension_data) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SessionExecutionRuntime { pub session_id: String, @@ -51,6 +213,27 @@ pub struct SessionExecutionRuntime { pub latest_turn_status: Option, #[serde(skip_serializing_if = "Option::is_none")] pub recent_preferences: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_team_selection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_session_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_gate_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_run_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recent_content_id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct RecentHarnessContext { + theme: Option, + session_mode: Option, + gate_key: Option, + run_title: Option, + content_id: Option, } fn resolve_session_model_name(session: &Session) -> Option { @@ -80,6 +263,46 @@ fn extract_bool_from_metadata( .find_map(|key| extract_bool_from_value(metadata.get(*key))) } +fn extract_text_from_value(value: Option<&Value>) -> Option { + normalize_optional_text(value.and_then(Value::as_str).map(ToString::to_string)) +} + +fn extract_text_from_object( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .find_map(|key| extract_text_from_value(object.get(*key))) +} + +fn extract_text_from_metadata( + metadata: &std::collections::HashMap, + keys: &[&str], +) -> Option { + keys.iter() + .find_map(|key| extract_text_from_value(metadata.get(*key))) +} + +fn extract_array_from_object( + object: &serde_json::Map, + keys: &[&str], +) -> Option> { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(Value::as_array) + .cloned() +} + +fn extract_array_from_metadata( + metadata: &std::collections::HashMap, + keys: &[&str], +) -> Option> { + keys.iter() + .filter_map(|key| metadata.get(*key)) + .find_map(Value::as_array) + .cloned() +} + fn extract_recent_preferences_from_metadata( metadata: &std::collections::HashMap, ) -> Option { @@ -118,6 +341,170 @@ fn extract_recent_preferences_from_metadata( }) } +fn extract_recent_team_roles_from_values( + values: Vec, +) -> Option> { + let roles = values + .into_iter() + .filter_map(|value| { + serde_json::from_value::(value).ok() + }) + .filter_map(SessionExecutionRuntimeRecentTeamRole::normalize) + .collect::>(); + + if roles.is_empty() { + None + } else { + Some(roles) + } +} + +fn extract_recent_team_selection_from_metadata( + metadata: &std::collections::HashMap, +) -> Option { + let harness = metadata.get("harness").and_then(Value::as_object); + let resolve_text = |keys: &[&str]| -> Option { + harness + .and_then(|value| extract_text_from_object(value, keys)) + .or_else(|| extract_text_from_metadata(metadata, keys)) + }; + let resolve_bool = |keys: &[&str]| -> Option { + harness + .and_then(|value| extract_bool_from_object(value, keys)) + .or_else(|| extract_bool_from_metadata(metadata, keys)) + }; + let resolve_array = |keys: &[&str]| -> Option> { + harness + .and_then(|value| extract_array_from_object(value, keys)) + .or_else(|| extract_array_from_metadata(metadata, keys)) + }; + + SessionExecutionRuntimeRecentTeamSelection { + disabled: resolve_bool(&["selected_team_disabled", "selectedTeamDisabled"]) + .unwrap_or(false), + theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]), + preferred_team_preset_id: resolve_text(&[ + "preferred_team_preset_id", + "preferredTeamPresetId", + ]), + selected_team_id: resolve_text(&["selected_team_id", "selectedTeamId"]), + selected_team_source: resolve_text(&["selected_team_source", "selectedTeamSource"]), + selected_team_label: resolve_text(&["selected_team_label", "selectedTeamLabel"]), + selected_team_description: resolve_text(&[ + "selected_team_description", + "selectedTeamDescription", + ]), + selected_team_summary: resolve_text(&["selected_team_summary", "selectedTeamSummary"]), + selected_team_roles: resolve_array(&["selected_team_roles", "selectedTeamRoles"]) + .and_then(extract_recent_team_roles_from_values), + } + .normalize() +} + +fn extract_recent_harness_context_from_metadata( + metadata: &std::collections::HashMap, +) -> RecentHarnessContext { + let harness = metadata.get("harness").and_then(Value::as_object); + let resolve_text = |keys: &[&str]| -> Option { + harness + .and_then(|value| extract_text_from_object(value, keys)) + .or_else(|| extract_text_from_metadata(metadata, keys)) + }; + + RecentHarnessContext { + theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]), + session_mode: resolve_text(&["session_mode", "sessionMode"]), + gate_key: resolve_text(&["gate_key", "gateKey"]), + run_title: resolve_text(&["run_title", "runTitle", "title"]), + content_id: resolve_text(&["content_id", "contentId"]), + } +} + +fn extract_recent_harness_context_from_runtime_snapshot( + snapshot: &SessionRuntimeSnapshot, +) -> RecentHarnessContext { + let from_turn = snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .filter_map(|turn| { + let context = turn + .context_override + .as_ref() + .map(|value| extract_recent_harness_context_from_metadata(&value.metadata))?; + Some((turn.updated_at, context)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, context)| context) + .unwrap_or_default(); + + if from_turn.theme.is_some() + && from_turn.session_mode.is_some() + && from_turn.gate_key.is_some() + && from_turn.run_title.is_some() + && from_turn.content_id.is_some() + { + return from_turn; + } + + let from_thread = snapshot + .threads + .iter() + .filter_map(|thread| { + let context = extract_recent_harness_context_from_metadata(&thread.thread.metadata); + if context.theme.is_none() + && context.session_mode.is_none() + && context.content_id.is_none() + { + return None; + } + Some((thread.thread.updated_at, context)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, context)| context) + .unwrap_or_default(); + + RecentHarnessContext { + theme: from_turn.theme.or(from_thread.theme), + session_mode: from_turn.session_mode.or(from_thread.session_mode), + gate_key: from_turn.gate_key.or(from_thread.gate_key), + run_title: from_turn.run_title.or(from_thread.run_title), + content_id: from_turn.content_id.or(from_thread.content_id), + } +} + +pub fn extract_recent_content_id_from_runtime_snapshot( + snapshot: &SessionRuntimeSnapshot, +) -> Option { + extract_recent_harness_context_from_runtime_snapshot(snapshot).content_id +} + +pub async fn persist_session_recent_preferences( + session_id: &str, + preferences: SessionExecutionRuntimePreferences, +) -> Result<(), String> { + let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?; + let extension_data = preferences.into_updated_extension_data(&session)?; + persist_session_extension_data(session_id, extension_data, "持久化会话 recent_preferences") + .await?; + Ok(()) +} + +pub async fn persist_session_recent_team_selection( + session_id: &str, + recent_team_selection: SessionExecutionRuntimeRecentTeamSelection, +) -> Result<(), String> { + let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?; + let extension_data = recent_team_selection.into_updated_extension_data(&session)?; + persist_session_extension_data( + session_id, + extension_data, + "持久化会话 recent_team_selection", + ) + .await?; + Ok(()) +} + fn resolve_latest_turn(snapshot: &SessionRuntimeSnapshot) -> Option<&aster::session::TurnRuntime> { snapshot .threads @@ -161,33 +548,62 @@ pub fn build_session_execution_runtime( latest_turn_id: None, latest_turn_status: None, recent_preferences: None, + recent_team_selection: None, + recent_theme: None, + recent_session_mode: None, + recent_gate_key: None, + recent_run_title: None, + recent_content_id: None, }; - if let Some(latest_turn) = snapshot.and_then(resolve_latest_turn) { - runtime.latest_turn_id = Some(latest_turn.id.clone()); - runtime.latest_turn_status = Some(map_turn_status(latest_turn.status)); - runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone(); - runtime.model_name = latest_turn - .output_schema_runtime - .as_ref() - .and_then(|value| normalize_optional_text(value.model_name.clone())) - .or_else(|| { - latest_turn - .context_override - .as_ref() - .and_then(|value| normalize_optional_text(value.model.clone())) - }) - .or(runtime.model_name); - runtime.provider_name = latest_turn - .output_schema_runtime - .as_ref() - .and_then(|value| normalize_optional_text(value.provider_name.clone())) - .or(runtime.provider_name); - runtime.recent_preferences = latest_turn - .context_override - .as_ref() - .and_then(|value| extract_recent_preferences_from_metadata(&value.metadata)); - runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot; + if let Some(snapshot) = snapshot { + let recent_harness_context = extract_recent_harness_context_from_runtime_snapshot(snapshot); + runtime.recent_theme = recent_harness_context.theme; + runtime.recent_session_mode = recent_harness_context.session_mode; + runtime.recent_gate_key = recent_harness_context.gate_key; + runtime.recent_run_title = recent_harness_context.run_title; + runtime.recent_content_id = recent_harness_context.content_id; + + if let Some(latest_turn) = resolve_latest_turn(snapshot) { + runtime.latest_turn_id = Some(latest_turn.id.clone()); + runtime.latest_turn_status = Some(map_turn_status(latest_turn.status)); + runtime.output_schema_runtime = latest_turn.output_schema_runtime.clone(); + runtime.model_name = latest_turn + .output_schema_runtime + .as_ref() + .and_then(|value| normalize_optional_text(value.model_name.clone())) + .or_else(|| { + latest_turn + .context_override + .as_ref() + .and_then(|value| normalize_optional_text(value.model.clone())) + }) + .or(runtime.model_name); + runtime.provider_name = latest_turn + .output_schema_runtime + .as_ref() + .and_then(|value| normalize_optional_text(value.provider_name.clone())) + .or(runtime.provider_name); + runtime.recent_preferences = latest_turn + .context_override + .as_ref() + .and_then(|value| extract_recent_preferences_from_metadata(&value.metadata)); + runtime.recent_team_selection = latest_turn + .context_override + .as_ref() + .and_then(|value| extract_recent_team_selection_from_metadata(&value.metadata)); + runtime.source = SessionExecutionRuntimeSource::RuntimeSnapshot; + } + } + + if runtime.recent_preferences.is_none() { + runtime.recent_preferences = + session.and_then(SessionExecutionRuntimePreferences::from_session); + } + + if runtime.recent_team_selection.is_none() { + runtime.recent_team_selection = + session.and_then(SessionExecutionRuntimeRecentTeamSelection::from_session); } if runtime.provider_selector.is_none() @@ -195,6 +611,12 @@ pub fn build_session_execution_runtime( && runtime.model_name.is_none() && runtime.output_schema_runtime.is_none() && runtime.recent_preferences.is_none() + && runtime.recent_team_selection.is_none() + && runtime.recent_theme.is_none() + && runtime.recent_session_mode.is_none() + && runtime.recent_gate_key.is_none() + && runtime.recent_run_title.is_none() + && runtime.recent_content_id.is_none() { return None; } @@ -206,6 +628,7 @@ pub fn build_session_execution_runtime( mod tests { use super::{ build_session_execution_runtime, SessionExecutionRuntimePreferences, + SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection, SessionExecutionRuntimeSource, }; use aster::model::ModelConfig; @@ -389,4 +812,385 @@ mod tests { }) ); } + + #[test] + fn keeps_recent_team_selection_from_latest_turn_metadata() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-team".to_string(), + session_id: "session-5".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + metadata: std::collections::HashMap::from([( + "harness".to_string(), + json!({ + "theme": "general", + "preferred_team_preset_id": "code-triage-team", + "selected_team_id": "custom-team-1", + "selected_team_source": "custom", + "selected_team_label": "前端联调团队", + "selected_team_description": "分析、实现、验证三段式推进。", + "selected_team_summary": "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。", + "selected_team_roles": [ + { + "id": "explorer", + "label": "分析", + "summary": "负责定位问题与影响范围。", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["repo-exploration"] + } + ] + }), + )]), + ..TurnContextOverride::default() + }), + output_schema_runtime: None, + created_at: now - Duration::seconds(10), + started_at: Some(now - Duration::seconds(10)), + completed_at: Some(now - Duration::seconds(1)), + updated_at: now, + }; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-5".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-5", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = + build_session_execution_runtime("session-5", None, None, Some(&snapshot), None) + .expect("runtime"); + + assert_eq!( + runtime.recent_team_selection, + Some(SessionExecutionRuntimeRecentTeamSelection { + disabled: false, + theme: Some("general".to_string()), + preferred_team_preset_id: Some("code-triage-team".to_string()), + selected_team_id: Some("custom-team-1".to_string()), + selected_team_source: Some("custom".to_string()), + selected_team_label: Some("前端联调团队".to_string()), + selected_team_description: Some("分析、实现、验证三段式推进。".to_string()), + selected_team_summary: Some( + "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。" + .to_string(), + ), + selected_team_roles: Some(vec![SessionExecutionRuntimeRecentTeamRole { + id: "explorer".to_string(), + label: "分析".to_string(), + summary: "负责定位问题与影响范围。".to_string(), + profile_id: Some("code-explorer".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: vec!["repo-exploration".to_string()], + }]), + }) + ); + } + + #[test] + fn keeps_recent_content_id_from_latest_turn_metadata() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-content".to_string(), + session_id: "session-content".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + metadata: std::collections::HashMap::from([( + "harness".to_string(), + json!({ + "content_id": "content-current" + }), + )]), + ..TurnContextOverride::default() + }), + output_schema_runtime: None, + created_at: now - Duration::seconds(10), + started_at: Some(now - Duration::seconds(10)), + completed_at: Some(now - Duration::seconds(1)), + updated_at: now, + }; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-content".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-content", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = + build_session_execution_runtime("session-content", None, None, Some(&snapshot), None) + .expect("runtime"); + + assert_eq!( + runtime.source, + SessionExecutionRuntimeSource::RuntimeSnapshot + ); + assert_eq!( + runtime.recent_content_id.as_deref(), + Some("content-current") + ); + } + + #[test] + fn keeps_recent_theme_and_session_mode_from_latest_turn_metadata() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-harness".to_string(), + session_id: "session-harness".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride { + metadata: std::collections::HashMap::from([( + "harness".to_string(), + json!({ + "theme": "social-media", + "session_mode": "theme_workbench", + "gate_key": "write_mode", + "run_title": "社媒初稿", + "content_id": "content-current" + }), + )]), + ..TurnContextOverride::default() + }), + output_schema_runtime: None, + created_at: now - Duration::seconds(10), + started_at: Some(now - Duration::seconds(10)), + completed_at: Some(now - Duration::seconds(1)), + updated_at: now, + }; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-harness".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-harness", + PathBuf::from("/tmp/workspace"), + ), + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = + build_session_execution_runtime("session-harness", None, None, Some(&snapshot), None) + .expect("runtime"); + + assert_eq!(runtime.recent_theme.as_deref(), Some("social-media")); + assert_eq!( + runtime.recent_session_mode.as_deref(), + Some("theme_workbench") + ); + assert_eq!(runtime.recent_gate_key.as_deref(), Some("write_mode")); + assert_eq!(runtime.recent_run_title.as_deref(), Some("社媒初稿")); + assert_eq!( + runtime.recent_content_id.as_deref(), + Some("content-current") + ); + } + + #[test] + fn falls_back_to_thread_metadata_recent_content_id() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-without-content".to_string(), + session_id: "session-thread-content".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride::default()), + output_schema_runtime: None, + created_at: now - Duration::seconds(10), + started_at: Some(now - Duration::seconds(10)), + completed_at: Some(now - Duration::seconds(1)), + updated_at: now, + }; + let mut thread = ThreadRuntime::new( + "thread-1", + "session-thread-content", + PathBuf::from("/tmp/workspace"), + ); + thread + .metadata + .insert("content_id".to_string(), json!("content-from-thread")); + thread.updated_at = now; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-thread-content".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread, + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = build_session_execution_runtime( + "session-thread-content", + None, + None, + Some(&snapshot), + None, + ) + .expect("runtime"); + + assert_eq!( + runtime.recent_content_id.as_deref(), + Some("content-from-thread") + ); + } + + #[test] + fn falls_back_to_thread_metadata_recent_theme_and_session_mode() { + let now = Utc::now(); + let latest_turn = TurnRuntime { + id: "turn-without-harness".to_string(), + session_id: "session-thread-harness".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("hello".to_string()), + error_message: None, + context_override: Some(TurnContextOverride::default()), + output_schema_runtime: None, + created_at: now - Duration::seconds(10), + started_at: Some(now - Duration::seconds(10)), + completed_at: Some(now - Duration::seconds(1)), + updated_at: now, + }; + let mut thread = ThreadRuntime::new( + "thread-1", + "session-thread-harness", + PathBuf::from("/tmp/workspace"), + ); + thread + .metadata + .insert("theme".to_string(), json!("document")); + thread + .metadata + .insert("session_mode".to_string(), json!("theme_workbench")); + thread + .metadata + .insert("gate_key".to_string(), json!("publish_confirm")); + thread + .metadata + .insert("run_title".to_string(), json!("发布确认")); + thread + .metadata + .insert("content_id".to_string(), json!("content-from-thread")); + thread.updated_at = now; + let snapshot = SessionRuntimeSnapshot { + session_id: "session-thread-harness".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread, + turns: vec![latest_turn], + items: Vec::new(), + }], + }; + + let runtime = build_session_execution_runtime( + "session-thread-harness", + None, + None, + Some(&snapshot), + None, + ) + .expect("runtime"); + + assert_eq!(runtime.recent_theme.as_deref(), Some("document")); + assert_eq!( + runtime.recent_session_mode.as_deref(), + Some("theme_workbench") + ); + assert_eq!(runtime.recent_gate_key.as_deref(), Some("publish_confirm")); + assert_eq!(runtime.recent_run_title.as_deref(), Some("发布确认")); + assert_eq!( + runtime.recent_content_id.as_deref(), + Some("content-from-thread") + ); + } + + #[test] + fn falls_back_to_session_extension_data_recent_preferences() { + let mut session = Session::default(); + session.id = "session-4".to_string(); + session.extension_data = SessionExecutionRuntimePreferences { + web_search: false, + thinking: true, + task: true, + subagent: false, + } + .into_updated_extension_data(&Session::default()) + .expect("extension data"); + + let runtime = + build_session_execution_runtime("session-4", Some(&session), None, None, None) + .expect("runtime"); + + assert_eq!(runtime.source, SessionExecutionRuntimeSource::Session); + assert_eq!( + runtime.recent_preferences, + Some(SessionExecutionRuntimePreferences { + web_search: false, + thinking: true, + task: true, + subagent: false, + }) + ); + } + + #[test] + fn falls_back_to_session_extension_data_recent_team_selection() { + let mut session = Session::default(); + session.id = "session-6".to_string(); + session.extension_data = SessionExecutionRuntimeRecentTeamSelection { + disabled: true, + theme: Some("general".to_string()), + preferred_team_preset_id: None, + selected_team_id: None, + selected_team_source: None, + selected_team_label: None, + selected_team_description: None, + selected_team_summary: None, + selected_team_roles: None, + } + .into_updated_extension_data(&Session::default()) + .expect("extension data"); + + let runtime = + build_session_execution_runtime("session-6", Some(&session), None, None, None) + .expect("runtime"); + + assert_eq!( + runtime.recent_team_selection, + Some(SessionExecutionRuntimeRecentTeamSelection { + disabled: true, + theme: Some("general".to_string()), + preferred_team_preset_id: None, + selected_team_id: None, + selected_team_source: None, + selected_team_label: None, + selected_team_description: None, + selected_team_summary: None, + selected_team_roles: None, + }) + ); + } } diff --git a/src-tauri/crates/browser-runtime/src/action.rs b/src-tauri/crates/browser-runtime/src/action.rs index 5f61fc6cf..561de2c1b 100644 --- a/src-tauri/crates/browser-runtime/src/action.rs +++ b/src-tauri/crates/browser-runtime/src/action.rs @@ -55,6 +55,7 @@ async fn navigate(session: &CdpSessionHandle, args: &Value) -> Result Result Option { .find_map(|key| args.get(*key).and_then(Value::as_u64)) } +fn resolve_navigation_command_timeout_ms(wait_timeout_ms: u64) -> u64 { + wait_timeout_ms.max(DEFAULT_ACTION_TIMEOUT_MS) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn should_keep_navigation_command_timeout_at_least_default() { + assert_eq!( + resolve_navigation_command_timeout_ms(5_000), + DEFAULT_ACTION_TIMEOUT_MS + ); + assert_eq!(resolve_navigation_command_timeout_ms(20_000), 20_000); + } + #[test] fn should_accept_exact_expected_url_even_when_previous_matches() { assert!(should_accept_navigation_page( diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index d11ddc998..bc5e5e5d2 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -924,6 +924,16 @@ impl Default for ScreenshotChatConfig { } } +/// WebMCP 预留配置 +/// +/// 当前仅作为实验开关预留,不参与实际执行链。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct WebMcpConfig { + /// 是否允许未来接入 WebMCP 实验能力 + #[serde(default)] + pub enabled: bool, +} + /// 实验室功能配置 /// /// 管理所有实验性功能的开关和配置 @@ -932,6 +942,9 @@ pub struct ExperimentalFeatures { /// 截图对话功能配置 #[serde(default)] pub screenshot_chat: ScreenshotChatConfig, + /// WebMCP 预留配置 + #[serde(default)] + pub webmcp: WebMcpConfig, /// 自动更新检查配置 #[serde(default)] pub update_check: UpdateCheckConfig, @@ -3157,11 +3170,18 @@ mod unit_tests { assert_eq!(config.shortcut, "CommandOrControl+Alt+Q"); } + #[test] + fn test_webmcp_config_default() { + let config = WebMcpConfig::default(); + assert!(!config.enabled); + } + #[test] fn test_experimental_features_default() { let config = ExperimentalFeatures::default(); assert!(!config.screenshot_chat.enabled); assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Alt+Q"); + assert!(!config.webmcp.enabled); } #[test] @@ -3171,10 +3191,12 @@ mod unit_tests { enabled: true, shortcut: "CommandOrControl+Alt+X".to_string(), }, + webmcp: WebMcpConfig { enabled: true }, ..Default::default() }; let yaml = serde_yaml::to_string(&config).unwrap(); + assert!(yaml.contains("webmcp")); assert!(yaml.contains("enabled: true")); assert!(yaml.contains("shortcut: CommandOrControl+Alt+X")); @@ -3215,6 +3237,7 @@ mod unit_tests { config.experimental.screenshot_chat.shortcut, "CommandOrControl+Alt+Q" ); + assert!(!config.experimental.webmcp.enabled); // 语音输入测试 assert!(!config.experimental.voice_input.enabled); assert_eq!( diff --git a/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js b/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js index 7c2241a0f..db00b90a0 100644 --- a/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js +++ b/src-tauri/resources/site-adapters/bundled/scripts/bilibili-search.js @@ -40,6 +40,14 @@ async (args, helpers) => { limit, ); + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`, + }; + } + return { ok: true, data: { diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index ac3a4c995..dfb5d0759 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -228,6 +228,20 @@ impl AsterAgentWrapper { lime_agent::update_session_provider_config_sync(db, session_id, provider_name, model_name) } + pub async fn persist_session_recent_preferences( + session_id: &str, + preferences: lime_agent::SessionExecutionRuntimePreferences, + ) -> Result<(), String> { + lime_agent::persist_session_recent_preferences(session_id, preferences).await + } + + pub async fn persist_session_recent_team_selection( + session_id: &str, + recent_team_selection: lime_agent::SessionExecutionRuntimeRecentTeamSelection, + ) -> Result<(), String> { + lime_agent::persist_session_recent_team_selection(session_id, recent_team_selection).await + } + /// 删除会话 pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> { lime_agent::delete_session(db, session_id).await diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index 589785f6b..d1c210888 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -12,6 +12,7 @@ pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task"; pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task"; pub const LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list"; +pub const LIME_SITE_RECOMMEND_TOOL_NAME: &str = "lime_site_recommend"; pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search"; pub const LIME_SITE_INFO_TOOL_NAME: &str = "lime_site_info"; pub const LIME_SITE_RUN_TOOL_NAME: &str = "lime_site_run"; @@ -451,6 +452,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, + ToolCatalogEntry { + name: LIME_SITE_RECOMMEND_TOOL_NAME, + profiles: BROWSER_PROFILES, + capabilities: SITE_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, ToolCatalogEntry { name: LIME_SITE_SEARCH_TOOL_NAME, profiles: BROWSER_PROFILES, @@ -714,14 +724,14 @@ mod tests { .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist()); - assert_eq!(browser.len(), 31); + assert_eq!(browser.len(), 32); assert!(browser .iter() .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); let combined = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist()); - assert_eq!(combined.len(), 39); + assert_eq!(combined.len(), 40); } #[test] @@ -740,9 +750,10 @@ mod tests { let names = workspace_default_allowed_tool_names( WorkspaceToolSurface::creator_with_browser_assist(), ); - assert_eq!(names.len(), 26); + assert_eq!(names.len(), 27); assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(&"tool_search")); + assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME)); assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME)); assert!(!names .iter() diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index cc17d3298..8fca5b8d0 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -910,8 +910,8 @@ mod tests { .map(ToString::to_string) .collect::>(); - assert_eq!(inventory.counts.catalog_total, 39); - assert_eq!(inventory.counts.catalog_current_total, 38); + assert_eq!(inventory.counts.catalog_total, 40); + assert_eq!(inventory.counts.catalog_current_total, 39); assert_eq!(inventory.counts.catalog_compat_total, 1); assert_eq!(inventory.default_allowed_tools, expected_default_allowed); assert_eq!( diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 9d890e6c1..21ce8ec58 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -127,6 +127,22 @@ pub fn init_states(config: &Config) -> Result { } } + { + let conn = database::lock_db(&db).map_err(|e| format!("Failed to lock database: {e}"))?; + let seeded_profiles = + crate::services::browser_profile_service::ensure_default_browser_profiles(&conn) + .map_err(|error| format!("初始化默认浏览器资料失败: {error}"))?; + let seeded_environment_presets = crate::services::browser_environment_service::ensure_default_browser_environment_presets(&conn) + .map_err(|error| format!("初始化默认浏览器环境预设失败: {error}"))?; + if seeded_profiles || seeded_environment_presets { + tracing::info!( + "[Bootstrap] 默认浏览器资源已就绪: profiles_seeded={}, presets_seeded={}", + seeded_profiles, + seeded_environment_presets + ); + } + } + initialize_aster_runtime(db.clone()).map_err(|e| format!("Aster 运行时初始化失败: {e}"))?; // 服务状态 diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 17b90f596..0ff2c1a43 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1287,6 +1287,7 @@ pub fn run() { commands::browser_runtime_cmd::launch_browser_session, commands::browser_runtime_cmd::launch_browser_runtime_assist, commands::site_capability_cmd::site_list_adapters, + commands::site_capability_cmd::site_recommend_adapters, commands::site_capability_cmd::site_search_adapters, commands::site_capability_cmd::site_get_adapter_info, commands::site_capability_cmd::site_get_adapter_catalog_status, @@ -1449,6 +1450,11 @@ pub fn run() { commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions, commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session, commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_thread_read, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_analysis_handoff, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_handoff_bundle, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_evidence_pack, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_review_decision_template, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_export_replay_case, commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory, commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent, commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api.rs index 24b9cdd1c..7a80e2e96 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api.rs @@ -47,7 +47,9 @@ pub(crate) use provider_api::{ aster_agent_reset, aster_agent_status, }; pub(crate) use runtime_api::{ - agent_runtime_compact_session, agent_runtime_get_session, agent_runtime_get_thread_read, + agent_runtime_compact_session, agent_runtime_export_analysis_handoff, + agent_runtime_export_evidence_pack, agent_runtime_export_handoff_bundle, + agent_runtime_export_replay_case, agent_runtime_get_session, agent_runtime_get_thread_read, agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, agent_runtime_replay_request, agent_runtime_resume_thread, agent_runtime_submit_turn, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index 00fe9956c..8a0eab255 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -1,5 +1,21 @@ use super::*; +use crate::services::runtime_analysis_handoff_service::{ + export_runtime_analysis_handoff, RuntimeAnalysisHandoffExportResult, +}; +use crate::services::runtime_evidence_pack_service::{ + export_runtime_evidence_pack, RuntimeEvidencePackExportResult, +}; +use crate::services::runtime_handoff_artifact_service::{ + export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult, +}; +use crate::services::runtime_replay_case_service::{ + export_runtime_replay_case, RuntimeReplayCaseExportResult, +}; +use crate::services::runtime_review_decision_service::{ + export_runtime_review_decision_template, RuntimeReviewDecisionTemplateExportResult, +}; use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection; +use std::path::PathBuf; #[tauri::command] pub async fn agent_runtime_submit_turn( @@ -205,6 +221,274 @@ pub async fn agent_runtime_get_thread_read( )) } +struct RuntimeExportContext { + detail: SessionDetail, + thread_read: AgentRuntimeThreadReadModel, + workspace_root: PathBuf, +} + +fn resolve_runtime_export_workspace_root( + db: &DbConnection, + detail: &SessionDetail, +) -> Result { + if let Some(workspace_id) = detail + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let manager = WorkspaceManager::new(db.clone()); + let workspace_id = workspace_id.to_string(); + let workspace = manager + .get(&workspace_id) + .map_err(|error| format!("读取 workspace 失败: {error}"))? + .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; + let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; + return Ok(ensured.root_path); + } + + if let Some(working_dir) = detail + .working_dir + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(PathBuf::from(working_dir)); + } + + Err("当前会话缺少 workspace / working_dir,无法导出运行时制品".to_string()) +} + +async fn load_runtime_export_context( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + session_id: &str, + action_label: &str, +) -> Result { + if let Err(error) = resume_runtime_queue_if_needed_service( + app.clone(), + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + session_id.to_string(), + build_runtime_queue_executor(), + ) + .await + { + tracing::warn!( + "[AsterAgent][Queue] {} 前恢复排队执行失败: session_id={}, error={}", + action_label, + session_id, + error + ); + } + + let detail = AsterAgentWrapper::get_runtime_session_detail(db, session_id).await?; + let queued_turns = list_runtime_queue_snapshots_service(session_id).await?; + let projection = sync_thread_reliability_projection(db, &detail)?; + let interrupt_marker = state.get_interrupt_marker(session_id).await; + let thread_read = AgentRuntimeThreadReadModel::from_parts( + &detail, + &queued_turns, + projection.pending_requests, + projection.last_outcome, + projection.incidents, + interrupt_marker.as_ref(), + ); + let workspace_root = resolve_runtime_export_workspace_root(db, &detail)?; + + Ok(RuntimeExportContext { + detail, + thread_read, + workspace_root, + }) +} + +/// 统一运行时:导出当前会话的交接制品 bundle。 +#[tauri::command] +pub async fn agent_runtime_export_handoff_bundle( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + session_id: String, +) -> Result { + tracing::info!("[AsterAgent] 导出 handoff bundle: {}", session_id); + let context = load_runtime_export_context( + &app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + &session_id, + "导出 handoff bundle", + ) + .await?; + + export_runtime_handoff_bundle( + &context.detail, + &context.thread_read, + &context.workspace_root, + ) +} + +/// 统一运行时:导出当前会话的最小问题证据包。 +#[tauri::command] +pub async fn agent_runtime_export_evidence_pack( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + session_id: String, +) -> Result { + tracing::info!("[AsterAgent] 导出 evidence pack: {}", session_id); + let context = load_runtime_export_context( + &app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + &session_id, + "导出 evidence pack", + ) + .await?; + + export_runtime_evidence_pack( + &context.detail, + &context.thread_read, + &context.workspace_root, + ) +} + +/// 统一运行时:导出当前会话的外部分析交接包。 +#[tauri::command] +pub async fn agent_runtime_export_analysis_handoff( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + session_id: String, +) -> Result { + tracing::info!("[AsterAgent] 导出 analysis handoff: {}", session_id); + let context = load_runtime_export_context( + &app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + &session_id, + "导出 analysis handoff", + ) + .await?; + + export_runtime_analysis_handoff( + &context.detail, + &context.thread_read, + &context.workspace_root, + ) +} + +/// 统一运行时:导出当前会话的人工审核记录模板。 +#[tauri::command] +pub async fn agent_runtime_export_review_decision_template( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + session_id: String, +) -> Result { + tracing::info!("[AsterAgent] 导出 review decision 模板: {}", session_id); + let context = load_runtime_export_context( + &app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + &session_id, + "导出 review decision 模板", + ) + .await?; + + export_runtime_review_decision_template( + &context.detail, + &context.thread_read, + &context.workspace_root, + ) +} + +/// 统一运行时:导出当前会话的 replay case。 +#[tauri::command] +pub async fn agent_runtime_export_replay_case( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + session_id: String, +) -> Result { + tracing::info!("[AsterAgent] 导出 replay case: {}", session_id); + let context = load_runtime_export_context( + &app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + &session_id, + "导出 replay case", + ) + .await?; + + export_runtime_replay_case( + &context.detail, + &context.thread_read, + &context.workspace_root, + ) +} + /// 统一运行时:重新拉起指定 pending request 的前端交互载荷。 #[tauri::command] pub async fn agent_runtime_replay_request( diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs index 6e2d5e531..33bcba5c3 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs @@ -91,5 +91,21 @@ pub async fn agent_runtime_update_session( )?; } + if let Some(recent_preferences) = request.recent_preferences { + AsterAgentWrapper::persist_session_recent_preferences( + &trimmed_session_id, + recent_preferences, + ) + .await?; + } + + if let Some(recent_team_selection) = request.recent_team_selection { + AsterAgentWrapper::persist_session_recent_team_selection( + &trimmed_session_id, + recent_team_selection, + ) + .await?; + } + Ok(()) } diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index facbd12e8..71b4121ac 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -140,7 +140,7 @@ pub struct AgentRuntimeSubmitTurnRequest { #[serde(default)] pub images: Option>, #[serde(alias = "workspaceId")] - pub workspace_id: String, + pub workspace_id: Option, #[serde(default, alias = "turnConfig")] pub turn_config: Option, #[serde(default, alias = "turnId")] @@ -173,7 +173,7 @@ impl From for AsterChatRequest { .as_ref() .and_then(|config| config.thinking_enabled), project_id: None, - workspace_id: request.workspace_id, + workspace_id: request.workspace_id.unwrap_or_default(), web_search: turn_config.as_ref().and_then(|config| config.web_search), search_mode: turn_config.as_ref().and_then(|config| config.search_mode), execution_strategy: turn_config @@ -1595,6 +1595,10 @@ pub struct AgentRuntimeUpdateSessionRequest { pub model_name: Option, #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, + #[serde(default, alias = "recentPreferences")] + pub recent_preferences: Option, + #[serde(default, alias = "recentTeamSelection")] + pub recent_team_selection: Option, } /// 自动续写参数 diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 758057f33..bc45f0308 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -24,8 +24,8 @@ use crate::agent_tools::catalog::{ LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, LIME_CREATE_VIDEO_TASK_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_LIST_TOOL_NAME, - LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, - TOOL_SEARCH_TOOL_NAME, + LIME_SITE_RECOMMEND_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, + SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME, }; #[cfg(test)] use crate::agent_tools::execution::build_workspace_shell_allow_pattern; @@ -301,6 +301,8 @@ pub(crate) use browser_assist::{ #[allow(unused_imports)] pub(crate) use command_api::{ agent_runtime_close_subagent, agent_runtime_compact_session, agent_runtime_create_session, + agent_runtime_export_analysis_handoff, agent_runtime_export_evidence_pack, + agent_runtime_export_handoff_bundle, agent_runtime_export_replay_case, agent_runtime_get_session, agent_runtime_get_thread_read, agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, agent_runtime_list_sessions, agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, agent_runtime_replay_request, agent_runtime_resume_subagent, @@ -309,21 +311,23 @@ pub(crate) use command_api::{ aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init, aster_agent_reset, aster_agent_status, }; +#[allow(unused_imports)] pub(crate) use dto::{ build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType, AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse, - AgentRuntimeCompactSessionRequest, AgentRuntimeIncidentView, AgentRuntimeInterruptTurnRequest, - AgentRuntimeOutcomeView, AgentRuntimePromoteQueuedTurnRequest, - AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeReplayRequestRequest, - AgentRuntimeReplayedActionRequiredView, AgentRuntimeRequestView, - AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest, + AgentRuntimeCompactSessionRequest, AgentRuntimeDiagnosticPendingRequestSample, + AgentRuntimeDiagnosticWarningSample, AgentRuntimeIncidentView, + AgentRuntimeInterruptTurnRequest, AgentRuntimeOutcomeView, + AgentRuntimePromoteQueuedTurnRequest, AgentRuntimeRemoveQueuedTurnRequest, + AgentRuntimeReplayRequestRequest, AgentRuntimeReplayedActionRequiredView, + AgentRuntimeRequestView, AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest, AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest, AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse, AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse, - AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadReadModel, AgentRuntimeToolInventoryRequest, - AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest, - AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload, - ConfigureFromPoolRequest, ConfigureProviderRequest, + AgentRuntimeSubmitTurnRequest, AgentRuntimeThreadDiagnostics, AgentRuntimeThreadReadModel, + AgentRuntimeToolInventoryRequest, AgentRuntimeUpdateSessionRequest, + AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, + AsterChatRequest, AutoContinuePayload, ConfigureFromPoolRequest, ConfigureProviderRequest, }; pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions}; #[cfg(test)] @@ -351,9 +355,15 @@ use run_metadata::{ resolve_social_run_artifact_descriptor, }; pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor}; +#[cfg(test)] +pub(crate) use runtime_turn::{ + resolve_request_web_search_preference_from_sources, resolve_workspace_id_from_sources, +}; pub(crate) use session_runtime::{ delete_runtime_session_internal, persist_session_provider_routing, - resolve_session_provider_selector, + resolve_recent_preference_from_sources, resolve_session_provider_selector, + resolve_session_recent_harness_context, resolve_session_recent_preferences, + resolve_session_recent_team_selection, SessionRecentHarnessContext, }; pub(crate) use subagent_runtime::{ agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index 8a6589842..152aeb1f8 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -234,32 +234,97 @@ fn render_team_roles(role_items: &[serde_json::Value]) -> Vec { pub(crate) fn build_team_preference_system_prompt( request_metadata: Option<&serde_json::Value>, + session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>, + subagent_mode_enabled: bool, ) -> Option { - let subagent_mode_enabled = extract_harness_bool( - request_metadata, - &["subagent_mode_enabled", "subagentModeEnabled"], - ) - .unwrap_or(false); - let preferred_team_preset_id = extract_harness_string( + let request_has_team_selection = extract_harness_string( request_metadata, &["preferred_team_preset_id", "preferredTeamPresetId"], - ); - let selected_team_source = extract_harness_string( - request_metadata, - &["selected_team_source", "selectedTeamSource"], - ); - let selected_team_label = extract_harness_string( - request_metadata, - &["selected_team_label", "selectedTeamLabel"], - ); - let selected_team_summary = extract_harness_string( - request_metadata, - &["selected_team_summary", "selectedTeamSummary"], - ); - let selected_team_roles = extract_harness_array( - request_metadata, - &["selected_team_roles", "selectedTeamRoles"], - ); + ) + .is_some() + || extract_harness_string(request_metadata, &["selected_team_id", "selectedTeamId"]) + .is_some() + || extract_harness_string( + request_metadata, + &["selected_team_source", "selectedTeamSource"], + ) + .is_some() + || extract_harness_string( + request_metadata, + &["selected_team_label", "selectedTeamLabel"], + ) + .is_some() + || extract_harness_string( + request_metadata, + &["selected_team_summary", "selectedTeamSummary"], + ) + .is_some() + || extract_harness_array( + request_metadata, + &["selected_team_roles", "selectedTeamRoles"], + ) + .is_some(); + + let preferred_team_preset_id = if request_has_team_selection { + extract_harness_string( + request_metadata, + &["preferred_team_preset_id", "preferredTeamPresetId"], + ) + } else { + session_recent_team_selection + .and_then(|selection| selection.preferred_team_preset_id.clone()) + }; + let selected_team_source = if request_has_team_selection { + extract_harness_string( + request_metadata, + &["selected_team_source", "selectedTeamSource"], + ) + } else { + session_recent_team_selection.and_then(|selection| selection.selected_team_source.clone()) + }; + let selected_team_label = if request_has_team_selection { + extract_harness_string( + request_metadata, + &["selected_team_label", "selectedTeamLabel"], + ) + } else { + session_recent_team_selection.and_then(|selection| selection.selected_team_label.clone()) + }; + let selected_team_summary = if request_has_team_selection { + extract_harness_string( + request_metadata, + &["selected_team_summary", "selectedTeamSummary"], + ) + } else { + session_recent_team_selection.and_then(|selection| selection.selected_team_summary.clone()) + }; + let selected_team_roles = if request_has_team_selection { + extract_harness_array( + request_metadata, + &["selected_team_roles", "selectedTeamRoles"], + ) + .cloned() + .filter(|roles| !roles.is_empty()) + } else { + session_recent_team_selection + .and_then(|selection| selection.selected_team_roles.as_ref()) + .map(|roles| { + roles + .iter() + .map(|role| { + serde_json::json!({ + "id": role.id, + "label": role.label, + "summary": role.summary, + "profile_id": role.profile_id, + "role_key": role.role_key, + "skill_ids": role.skill_ids, + }) + }) + .collect::>() + }) + .filter(|roles| !roles.is_empty()) + }; if !subagent_mode_enabled { return None; @@ -299,7 +364,7 @@ pub(crate) fn build_team_preference_system_prompt( lines.push(format!("- Team 摘要:{team_summary}")); } - if let Some(role_items) = selected_team_roles { + if let Some(role_items) = selected_team_roles.as_ref() { let rendered_roles = render_team_roles(role_items); if !rendered_roles.is_empty() { lines.push("- 当前 Team 角色参考:".to_string()); @@ -330,8 +395,14 @@ pub(crate) fn build_team_preference_system_prompt( pub(crate) fn merge_system_prompt_with_team_preference( base_prompt: Option, request_metadata: Option<&serde_json::Value>, + session_recent_team_selection: Option<&lime_agent::SessionExecutionRuntimeRecentTeamSelection>, + subagent_mode_enabled: bool, ) -> Option { - let Some(team_prompt) = build_team_preference_system_prompt(request_metadata) else { + let Some(team_prompt) = build_team_preference_system_prompt( + request_metadata, + session_recent_team_selection, + subagent_mode_enabled, + ) else { return base_prompt; }; diff --git a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs index 471f9f085..651713943 100644 --- a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs @@ -144,28 +144,59 @@ fn message_suggests_content_generation(message: &str) -> bool { .any(|keyword| normalized.contains(keyword)) } -pub(super) fn build_turn_runtime_statuses( +fn resolve_request_thinking_enabled_from_sources( + request: &AsterChatRequest, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, +) -> bool { + request + .thinking_enabled + .or_else(|| { + resolve_recent_preference_from_sources( + request.metadata.as_ref(), + &["thinking_enabled", "thinkingEnabled"], + session_recent_preferences.map(|preferences| preferences.thinking), + ) + }) + .unwrap_or(false) +} + +fn resolve_request_task_enabled_from_sources( + request: &AsterChatRequest, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, +) -> bool { + resolve_recent_preference_from_sources( + request.metadata.as_ref(), + &["task_mode_enabled", "taskModeEnabled"], + session_recent_preferences.map(|preferences| preferences.task), + ) + .unwrap_or(false) +} + +fn resolve_request_subagent_enabled_from_sources( + request: &AsterChatRequest, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, +) -> bool { + resolve_recent_preference_from_sources( + request.metadata.as_ref(), + &["subagent_mode_enabled", "subagentModeEnabled"], + session_recent_preferences.map(|preferences| preferences.subagent), + ) + .unwrap_or(false) +} + +pub(super) async fn build_turn_runtime_statuses( request: &AsterChatRequest, effective_strategy: AsterExecutionStrategy, request_tool_policy: &RequestToolPolicy, model_name: Option<&str>, -) -> (AgentRuntimeStatus, AgentRuntimeStatus) { - let thinking_enabled = extract_harness_bool( - request.metadata.as_ref(), - &["thinking_enabled", "thinkingEnabled"], - ) - .or(request.thinking_enabled) - .unwrap_or(false); - let task_enabled = extract_harness_bool( - request.metadata.as_ref(), - &["task_mode_enabled", "taskModeEnabled"], - ) - .unwrap_or(false); - let subagent_enabled = extract_harness_bool( - request.metadata.as_ref(), - &["subagent_mode_enabled", "subagentModeEnabled"], - ) - .unwrap_or(false); + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, +) -> Result<(AgentRuntimeStatus, AgentRuntimeStatus), String> { + let thinking_enabled = + resolve_request_thinking_enabled_from_sources(request, session_recent_preferences); + let task_enabled = + resolve_request_task_enabled_from_sources(request, session_recent_preferences); + let subagent_enabled = + resolve_request_subagent_enabled_from_sources(request, session_recent_preferences); let reasoning_supported = model_supports_reasoning(model_name); let news_expansion_needed = request_tool_policy.allows_web_search() && message_suggests_news_expansion(&request.message); @@ -306,7 +337,7 @@ pub(super) fn build_turn_runtime_statuses( ) }; - ( + Ok(( AgentRuntimeStatus { phase: "preparing".to_string(), title: "正在理解意图".to_string(), @@ -322,7 +353,7 @@ pub(super) fn build_turn_runtime_statuses( checkpoints: decided.2, metadata: None, }, - ) + )) } fn emit_projected_runtime_item_event( diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index 636b5fc4d..bea662417 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -564,16 +564,26 @@ fn resolve_provider_model_compatibility(provider_key: &str, model_id: &str) -> S model_id.to_string() } -fn extract_request_thinking_enabled(request: &AsterChatRequest) -> bool { - request.thinking_enabled.unwrap_or_else(|| { +fn extract_request_thinking_enabled(request: &AsterChatRequest) -> Option { + request.thinking_enabled.or_else(|| { extract_harness_bool( request.metadata.as_ref(), &["thinking_enabled", "thinkingEnabled"], ) - .unwrap_or(false) }) } +async fn resolve_request_thinking_enabled(request: &AsterChatRequest) -> Result { + if let Some(thinking_enabled) = extract_request_thinking_enabled(request) { + return Ok(thinking_enabled); + } + + Ok(resolve_session_recent_preferences(&request.session_id) + .await? + .map(|preferences| preferences.thinking) + .unwrap_or(false)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RequestPreferenceSource { Request, @@ -699,7 +709,7 @@ pub(super) async fn resolve_runtime_request_provider_config( let context = build_provider_resolution_context(db, api_key_provider_service, &provider_selector)?; let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await; - let thinking_enabled = extract_request_thinking_enabled(request); + let thinking_enabled = resolve_request_thinking_enabled(request).await?; let has_images = request .images .as_ref() diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs index 5c634daf0..c64caa936 100644 --- a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs @@ -125,6 +125,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( ("selectedTeamSource", "selected_team_source"), ("selected_team_label", "selected_team_label"), ("selectedTeamLabel", "selected_team_label"), + ("selected_team_description", "selected_team_description"), + ("selectedTeamDescription", "selected_team_description"), ("selected_team_summary", "selected_team_summary"), ("selectedTeamSummary", "selected_team_summary"), ("selected_team_roles", "selected_team_roles"), @@ -178,6 +180,7 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( request_tool_policy: &RequestToolPolicy, auto_continue_enabled: bool, auto_continue_metadata: Option<&AutoContinuePayload>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, ) -> serde_json::Map { let mut metadata = serde_json::Map::new(); metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id)); @@ -214,6 +217,34 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( serde_json::json!(auto_continue_metadata), ); extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref()); + for (target_key, preference_keys, session_value) in [ + ( + "thinking_enabled", + &["thinking_enabled", "thinkingEnabled"][..], + session_recent_preferences.map(|preferences| preferences.thinking), + ), + ( + "task_mode_enabled", + &["task_mode_enabled", "taskModeEnabled"][..], + session_recent_preferences.map(|preferences| preferences.task), + ), + ( + "subagent_mode_enabled", + &["subagent_mode_enabled", "subagentModeEnabled"][..], + session_recent_preferences.map(|preferences| preferences.subagent), + ), + ] { + if metadata.contains_key(target_key) { + continue; + } + if let Some(value) = resolve_recent_preference_from_sources( + request.metadata.as_ref(), + preference_keys, + session_value, + ) { + metadata.insert(target_key.to_string(), serde_json::json!(value)); + } + } metadata } 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 9cf81a9fb..12e856618 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -56,6 +56,64 @@ fn merge_turn_context_with_artifact_output_schema( ) } +fn normalize_runtime_turn_request_metadata( + request: &mut AsterChatRequest, + session_recent_theme: Option<&str>, + session_recent_session_mode: Option<&str>, + session_recent_gate_key: Option<&str>, + session_recent_run_title: Option<&str>, + session_recent_content_id: Option<&str>, +) { + request.metadata = crate::services::artifact_request_metadata_service:: + normalize_request_metadata_with_artifact_defaults( + request.metadata.take(), + session_recent_theme, + session_recent_session_mode, + session_recent_gate_key, + session_recent_run_title, + session_recent_content_id, + ); +} + +pub(crate) fn resolve_workspace_id_from_sources( + request_workspace_id: Option, + session_workspace_id: Option, +) -> Option { + normalize_optional_text(request_workspace_id) + .or_else(|| normalize_optional_text(session_workspace_id)) +} + +fn resolve_runtime_turn_workspace_id( + db: &DbConnection, + request: &AsterChatRequest, +) -> Result { + if let Some(workspace_id) = + resolve_workspace_id_from_sources(Some(request.workspace_id.clone()), None) + { + return Ok(workspace_id); + } + + let session_workspace_id = + AsterAgentWrapper::get_session_sync(db, &request.session_id)?.workspace_id; + + resolve_workspace_id_from_sources(None, session_workspace_id) + .ok_or_else(|| "workspace_id 必填,请先选择项目工作区".to_string()) +} + +pub(crate) fn resolve_request_web_search_preference_from_sources( + request_web_search: Option, + request_metadata: Option<&serde_json::Value>, + session_recent_preferences: Option<&lime_agent::SessionExecutionRuntimePreferences>, +) -> Option { + request_web_search.or_else(|| { + resolve_recent_preference_from_sources( + request_metadata, + &["web_search_enabled", "webSearchEnabled"], + session_recent_preferences.map(|preferences| preferences.web_search), + ) + }) +} + fn should_skip_artifact_document_autopersist( run_observation: &Arc>, final_text_output: &str, @@ -75,6 +133,7 @@ fn should_skip_artifact_document_autopersist( fn maybe_persist_artifact_document_after_stream( app: &AppHandle, + db: &DbConnection, event_name: &str, timeline_recorder: &Arc>, run_observation: &Arc>, @@ -137,6 +196,19 @@ fn maybe_persist_artifact_document_after_stream( }, ); + if let Err(error) = + crate::services::artifact_document_service::sync_persisted_artifact_document_to_content( + db, + request_metadata, + &persisted, + ) + { + tracing::warn!( + "[AsterAgent] ArtifactDocument 已落盘,但同步内容版本状态失败: {}", + error + ); + } + if persisted.repaired || persisted.status == "failed" { let (code, prefix) = if persisted.status == "failed" { ( @@ -218,20 +290,49 @@ async fn execute_aster_chat_request( { request.provider_config = Some(resolved_provider_config); } + let should_resolve_session_recent_harness_context = extract_harness_string( + request.metadata.as_ref(), + &["theme", "harness_theme", "harnessTheme"], + ) + .is_none() + || extract_harness_string(request.metadata.as_ref(), &["session_mode", "sessionMode"]) + .is_none() + || extract_harness_string(request.metadata.as_ref(), &["gate_key", "gateKey"]).is_none() + || extract_harness_string( + request.metadata.as_ref(), + &["run_title", "runTitle", "title"], + ) + .is_none() + || extract_harness_string(request.metadata.as_ref(), &["content_id", "contentId"]) + .is_none(); + let session_recent_harness_context = if should_resolve_session_recent_harness_context { + resolve_session_recent_harness_context(&request.session_id).await? + } else { + SessionRecentHarnessContext::default() + }; + normalize_runtime_turn_request_metadata( + &mut request, + session_recent_harness_context.theme.as_deref(), + session_recent_harness_context.session_mode.as_deref(), + session_recent_harness_context.gate_key.as_deref(), + session_recent_harness_context.run_title.as_deref(), + session_recent_harness_context.content_id.as_deref(), + ); // 直接使用前端传递的 session_id // LimeSessionStore 会在 add_message 时自动创建不存在的 session // 同时 get_session 也会自动创建不存在的 session let session_id = &request.session_id; - let workspace_id = request.workspace_id.trim().to_string(); - if workspace_id.is_empty() { - let message = "workspace_id 必填,请先选择项目工作区".to_string(); - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } + let workspace_id = match resolve_runtime_turn_workspace_id(db, &request) { + Ok(workspace_id) => workspace_id, + Err(message) => { + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; let manager = WorkspaceManager::new(db.clone()); let workspace = match manager.get(&workspace_id) { @@ -336,12 +437,19 @@ async fn execute_aster_chat_request( ); } + let session_recent_preferences = resolve_session_recent_preferences(session_id).await?; + let session_recent_team_selection = resolve_session_recent_team_selection(session_id).await?; let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); + let resolved_request_web_search = resolve_request_web_search_preference_from_sources( + request.web_search, + request.metadata.as_ref(), + session_recent_preferences.as_ref(), + ); let (request_web_search, request_search_mode) = apply_browser_requirement_to_request_tool_policy( request.metadata.as_ref(), - request.web_search, + resolved_request_web_search, request.search_mode, ); @@ -497,6 +605,15 @@ async fn execute_aster_chat_request( let prompt_with_team_preference = merge_system_prompt_with_team_preference( prompt_with_elicitation, request.metadata.as_ref(), + session_recent_team_selection.as_ref(), + resolve_recent_preference_from_sources( + request.metadata.as_ref(), + &["subagent_mode_enabled", "subagentModeEnabled"], + session_recent_preferences + .as_ref() + .map(|preferences| preferences.subagent), + ) + .unwrap_or(false), ); turn_input_builder.apply_prompt_stage( TurnPromptAugmentationStageKind::TeamPreference, @@ -698,8 +815,6 @@ async fn execute_aster_chat_request( let tracker = ExecutionTracker::new(db.clone()); let cancel_token = state.create_cancel_token(session_id).await; let auto_continue_metadata = auto_continue_config.clone(); - request.metadata = crate::services::artifact_request_metadata_service:: - normalize_request_metadata_with_artifact_defaults(request.metadata.take()); let request_metadata = request.metadata.clone(); sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await; let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref()); @@ -768,6 +883,7 @@ async fn execute_aster_chat_request( &request_tool_policy, auto_continue_enabled, auto_continue_metadata.as_ref(), + session_recent_preferences.as_ref(), ); if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) { run_start_metadata.insert("session_state".to_string(), session_state_value); @@ -830,7 +946,9 @@ async fn execute_aster_chat_request( .provider_config .as_ref() .map(|config| config.model_name.as_str()), - ); + session_recent_preferences.as_ref(), + ) + .await?; for status in [initial_runtime_status, decided_runtime_status] { emit_runtime_status_with_projection( agent, @@ -936,6 +1054,7 @@ async fn execute_aster_chat_request( Ok(execution) => { maybe_persist_artifact_document_after_stream( &app, + db, &request.event_name, &timeline_recorder, &run_observation, @@ -1026,6 +1145,7 @@ async fn execute_aster_chat_request( .map(|execution| { maybe_persist_artifact_document_after_stream( &app, + db, &request.event_name, &timeline_recorder, &run_observation, @@ -1813,6 +1933,7 @@ mod tests { use lime_core::database::schema::create_tables; use lime_services::aster_session_store::LimeSessionStore; use rusqlite::Connection; + use serde_json::{json, Value}; use std::fs; use tokio::sync::OnceCell; @@ -1839,6 +1960,270 @@ mod tests { .await; } + #[test] + fn normalize_runtime_turn_request_metadata_should_enable_artifact_prompt_before_turn_build() { + let mut request = AsterChatRequest { + message: "请基于目标先生成一版演示提纲".to_string(), + session_id: "session-artifact".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + project_id: None, + workspace_id: "workspace-artifact".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(json!({ + "harness": { + "theme": "document", + "session_mode": "theme_workbench", + "content_id": "content-1" + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + let raw_prompt = merge_system_prompt_with_artifact_context( + Some("基础系统提示".to_string()), + request.metadata.as_ref(), + ) + .expect("raw prompt"); + assert!(!raw_prompt.contains("【Artifact 交付策略】")); + + normalize_runtime_turn_request_metadata(&mut request, None, None, None, None, None); + + let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); + assert_eq!( + normalized_metadata + .pointer("/artifact/artifact_mode") + .and_then(Value::as_str), + Some("draft") + ); + + let prompt = merge_system_prompt_with_artifact_context( + Some("基础系统提示".to_string()), + Some(normalized_metadata), + ) + .expect("normalized prompt"); + assert!(prompt.contains("【Artifact 交付策略】")); + assert!(prompt.contains("【Artifact Stage 2 合同】")); + assert!(prompt.contains("artifact:content-1")); + + let mut turn_input_builder = + TurnInputEnvelopeBuilder::new(&request.session_id, &request.workspace_id); + turn_input_builder + .set_base_system_prompt( + TurnSystemPromptSource::Frontend, + Some("基础系统提示".to_string()), + ) + .set_turn_context_metadata_from_value(request.metadata.as_ref()) + .set_effective_user_message(&request.message) + .apply_prompt_stage(TurnPromptAugmentationStageKind::Artifact, Some(prompt)); + + let envelope = turn_input_builder.build(); + let diagnostics = envelope.diagnostics_snapshot(); + let turn_context = envelope.turn_context_override().expect("turn context"); + + assert!(diagnostics.has_turn_context_metadata); + assert!(diagnostics + .turn_context_metadata_keys + .contains(&"artifact".to_string())); + assert_eq!( + turn_context + .metadata + .get("artifact") + .and_then(|artifact| artifact.get("artifact_stage")) + .and_then(Value::as_str), + Some("stage2") + ); + } + + #[test] + fn normalize_runtime_turn_request_metadata_should_backfill_content_id_from_session_runtime() { + let mut request = AsterChatRequest { + message: "继续完善当前文档".to_string(), + session_id: "session-artifact-content-fallback".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + project_id: None, + workspace_id: "workspace-artifact".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(json!({ + "harness": { + "theme": "document", + "session_mode": "theme_workbench" + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + normalize_runtime_turn_request_metadata( + &mut request, + Some("document"), + Some("theme_workbench"), + None, + None, + Some("content-from-session"), + ); + + let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); + assert_eq!( + normalized_metadata + .pointer("/harness/theme") + .and_then(Value::as_str), + Some("document") + ); + assert_eq!( + normalized_metadata + .pointer("/harness/session_mode") + .and_then(Value::as_str), + Some("theme_workbench") + ); + assert_eq!( + normalized_metadata + .pointer("/harness/content_id") + .and_then(Value::as_str), + Some("content-from-session") + ); + assert_eq!( + normalized_metadata + .pointer("/artifact/artifact_request_id") + .and_then(Value::as_str), + Some("artifact:content-from-session") + ); + } + + #[test] + fn normalize_runtime_turn_request_metadata_should_backfill_theme_and_session_mode_from_session_runtime( + ) { + let mut request = AsterChatRequest { + message: "继续推进当前主题工作台".to_string(), + session_id: "session-artifact-theme-fallback".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + project_id: None, + workspace_id: "workspace-artifact".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(json!({ + "harness": { + "content_id": "content-from-session" + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + normalize_runtime_turn_request_metadata( + &mut request, + Some("social-media"), + Some("theme_workbench"), + None, + None, + Some("content-from-session"), + ); + + let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); + assert_eq!( + normalized_metadata + .pointer("/harness/theme") + .and_then(Value::as_str), + Some("social-media") + ); + assert_eq!( + normalized_metadata + .pointer("/harness/session_mode") + .and_then(Value::as_str), + Some("theme_workbench") + ); + assert_eq!( + normalized_metadata + .pointer("/harness/content_id") + .and_then(Value::as_str), + Some("content-from-session") + ); + } + + #[test] + fn normalize_runtime_turn_request_metadata_should_backfill_gate_key_and_run_title_from_session_runtime( + ) { + let mut request = AsterChatRequest { + message: "继续当前社媒运行".to_string(), + session_id: "session-social-gate-fallback".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + project_id: None, + workspace_id: "workspace-social".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: Some(json!({ + "harness": { + "theme": "social-media", + "session_mode": "theme_workbench", + "content_id": "content-social-1" + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + normalize_runtime_turn_request_metadata( + &mut request, + Some("social-media"), + Some("theme_workbench"), + Some("write_mode"), + Some("社媒初稿"), + Some("content-social-1"), + ); + + let normalized_metadata = request.metadata.as_ref().expect("normalized metadata"); + assert_eq!( + normalized_metadata + .pointer("/harness/gate_key") + .and_then(Value::as_str), + Some("write_mode") + ); + assert_eq!( + normalized_metadata + .pointer("/harness/run_title") + .and_then(Value::as_str), + Some("社媒初稿") + ); + } + #[tokio::test] async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() { ensure_runtime_turn_test_session_manager().await; diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs index cbe7704a5..fb72d8a23 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -1,4 +1,5 @@ use super::*; +use aster::session::load_shared_session_runtime_snapshot; #[derive(Debug, Clone, Serialize, Deserialize)] struct SessionProviderRoutingState { @@ -39,6 +40,15 @@ impl SessionProviderRoutingState { } } +#[derive(Debug, Clone, Default)] +pub(crate) struct SessionRecentHarnessContext { + pub(crate) theme: Option, + pub(crate) session_mode: Option, + pub(crate) gate_key: Option, + pub(crate) run_title: Option, + pub(crate) content_id: Option, +} + pub(crate) async fn persist_session_provider_routing( session_id: &str, provider_selector: &str, @@ -59,6 +69,88 @@ pub(crate) fn resolve_session_provider_selector( SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector) } +pub(crate) async fn resolve_session_recent_preferences( + session_id: &str, +) -> Result, String> { + let session = read_session(session_id, false, "读取会话 recent_preferences 失败").await?; + Ok(lime_agent::build_session_execution_runtime( + session_id, + Some(&session), + None, + None, + resolve_session_provider_selector(&session), + ) + .and_then(|runtime| runtime.recent_preferences)) +} + +pub(crate) async fn resolve_session_recent_team_selection( + session_id: &str, +) -> Result, String> { + let session = read_session(session_id, false, "读取会话 recent_team_selection 失败").await?; + Ok(lime_agent::build_session_execution_runtime( + session_id, + Some(&session), + None, + None, + resolve_session_provider_selector(&session), + ) + .and_then(|runtime| runtime.recent_team_selection)) +} + +pub(crate) async fn resolve_session_recent_harness_context( + session_id: &str, +) -> Result { + let trimmed_session_id = session_id.trim(); + if trimmed_session_id.is_empty() { + return Ok(SessionRecentHarnessContext::default()); + } + + match load_shared_session_runtime_snapshot(trimmed_session_id).await { + Ok(snapshot) => { + let runtime = lime_agent::build_session_execution_runtime( + trimmed_session_id, + None, + None, + Some(&snapshot), + None, + ); + Ok(SessionRecentHarnessContext { + theme: runtime + .as_ref() + .and_then(|value| value.recent_theme.clone()), + session_mode: runtime + .as_ref() + .and_then(|value| value.recent_session_mode.clone()), + gate_key: runtime + .as_ref() + .and_then(|value| value.recent_gate_key.clone()), + run_title: runtime + .as_ref() + .and_then(|value| value.recent_run_title.clone()), + content_id: runtime + .as_ref() + .and_then(|value| value.recent_content_id.clone()), + }) + } + Err(error) => { + tracing::debug!( + "[AsterAgent] 读取 runtime snapshot 失败,跳过 recent harness context 回退: session_id={}, error={}", + trimmed_session_id, + error + ); + Ok(SessionRecentHarnessContext::default()) + } + } +} + +pub(crate) fn resolve_recent_preference_from_sources( + request_metadata: Option<&serde_json::Value>, + keys: &[&str], + session_recent_preference: Option, +) -> Option { + extract_harness_bool(request_metadata, keys).or(session_recent_preference) +} + pub(crate) async fn create_runtime_session_internal( db: &DbConnection, working_dir: Option, diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index aacc6171a..33d17694d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -4,8 +4,8 @@ mod tests { use crate::commands::aster_agent_cmd::action_runtime::build_runtime_action_scope; use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope; use async_trait::async_trait; - use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_agent::request_tool_policy::resolve_request_tool_policy; + use lime_agent::AgentEvent as RuntimeAgentEvent; use regex::Regex; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -140,6 +140,28 @@ mod tests { )); } + #[test] + fn test_resolve_workspace_id_from_sources_prefers_request_value() { + assert_eq!( + resolve_workspace_id_from_sources( + Some("workspace-request".to_string()), + Some("workspace-session".to_string()), + ), + Some("workspace-request".to_string()) + ); + } + + #[test] + fn test_resolve_workspace_id_from_sources_falls_back_to_session_value() { + assert_eq!( + resolve_workspace_id_from_sources( + Some(" ".to_string()), + Some("workspace-session".to_string()), + ), + Some("workspace-session".to_string()) + ); + } + #[test] fn test_aster_chat_request_deserialize_with_execution_strategy() { let json = r#"{ @@ -582,6 +604,30 @@ mod tests { ); } + #[test] + fn test_agent_runtime_submit_turn_request_allows_missing_workspace_id() { + let json = r#"{ + "message": "Hello runtime", + "session_id": "runtime-session", + "event_name": "runtime_stream", + "turn_config": { + "execution_strategy": "auto", + "web_search": true + } + }"#; + + let request: AgentRuntimeSubmitTurnRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.workspace_id, None); + + let mapped: AsterChatRequest = request.into(); + assert_eq!(mapped.workspace_id, ""); + assert_eq!( + mapped.execution_strategy, + Some(AsterExecutionStrategy::Auto) + ); + assert_eq!(mapped.web_search, Some(true)); + } + #[test] fn test_build_runtime_user_message_includes_images() { let message = build_runtime_user_message( @@ -749,6 +795,91 @@ mod tests { assert_eq!(request.queued_turn_id, "queued-2"); } + #[test] + fn test_agent_runtime_update_session_request_deserializes_recent_preferences_aliases() { + let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", + "providerName": "openai", + "modelName": "gpt-5.4", + "recentPreferences": { + "webSearch": true, + "thinking": false, + "task": true, + "subagent": true + } + })) + .expect("request should deserialize"); + + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.provider_name.as_deref(), Some("openai")); + assert_eq!(request.model_name.as_deref(), Some("gpt-5.4")); + assert_eq!( + request.recent_preferences, + Some(lime_agent::SessionExecutionRuntimePreferences { + web_search: true, + thinking: false, + task: true, + subagent: true, + }) + ); + } + + #[test] + fn test_agent_runtime_update_session_request_deserializes_recent_team_selection_aliases() { + let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", + "recentTeamSelection": { + "disabled": false, + "theme": "general", + "preferredTeamPresetId": "code-triage-team", + "selectedTeamId": "custom-team-1", + "selectedTeamSource": "custom", + "selectedTeamLabel": "前端联调团队", + "selectedTeamDescription": "分析、实现、验证三段式推进。", + "selectedTeamSummary": "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。", + "selectedTeamRoles": [ + { + "id": "explorer", + "label": "分析", + "summary": "负责定位问题与影响范围。", + "profileId": "code-explorer", + "roleKey": "explorer", + "skillIds": ["repo-exploration"] + } + ] + } + })) + .expect("request should deserialize"); + + assert_eq!(request.session_id, "session-1"); + assert_eq!( + request.recent_team_selection, + Some(lime_agent::SessionExecutionRuntimeRecentTeamSelection { + disabled: false, + theme: Some("general".to_string()), + preferred_team_preset_id: Some("code-triage-team".to_string()), + selected_team_id: Some("custom-team-1".to_string()), + selected_team_source: Some("custom".to_string()), + selected_team_label: Some("前端联调团队".to_string()), + selected_team_description: Some("分析、实现、验证三段式推进。".to_string()), + selected_team_summary: Some( + "分析、实现、验证三段式推进。 角色分工:分析:负责定位问题与影响范围。" + .to_string(), + ), + selected_team_roles: Some(vec![ + lime_agent::SessionExecutionRuntimeRecentTeamRole { + id: "explorer".to_string(), + label: "分析".to_string(), + summary: "负责定位问题与影响范围。".to_string(), + profile_id: Some("code-explorer".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: vec!["repo-exploration".to_string()], + }, + ]), + }) + ); + } + #[test] fn test_extract_artifact_path_from_tool_start_reads_write_file_path() { let path = extract_artifact_path_from_tool_start( @@ -825,6 +956,7 @@ mod tests { }, false, None, + None, ); let mut observation = ChatRunObservation::default(); observation.record_artifact_path( @@ -897,6 +1029,77 @@ mod tests { ); } + #[test] + fn test_resolve_request_web_search_preference_from_sources_prefers_request_flag() { + let metadata = serde_json::json!({ + "harness": { + "preferences": { + "web_search": false + } + } + }); + let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences { + web_search: false, + thinking: true, + task: false, + subagent: true, + }; + + assert_eq!( + resolve_request_web_search_preference_from_sources( + Some(true), + Some(&metadata), + Some(&session_recent_preferences), + ), + Some(true) + ); + } + + #[test] + fn test_resolve_request_web_search_preference_from_sources_reads_nested_metadata() { + let metadata = serde_json::json!({ + "harness": { + "preferences": { + "web_search": true + } + } + }); + let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences { + web_search: false, + thinking: true, + task: false, + subagent: true, + }; + + assert_eq!( + resolve_request_web_search_preference_from_sources( + None, + Some(&metadata), + Some(&session_recent_preferences), + ), + Some(true) + ); + } + + #[test] + fn test_resolve_request_web_search_preference_from_sources_falls_back_to_session_runtime() { + let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences { + web_search: true, + thinking: true, + task: false, + subagent: true, + }; + + assert_eq!( + resolve_request_web_search_preference_from_sources( + None, + None, + Some(&session_recent_preferences), + ), + Some(true) + ); + } + #[test] fn test_build_chat_run_metadata_base_flattens_nested_preferences() { let metadata = build_chat_run_metadata_base( @@ -941,6 +1144,7 @@ mod tests { }, false, None, + None, ); assert_eq!( @@ -963,6 +1167,74 @@ mod tests { ); } + #[test] + fn test_build_chat_run_metadata_base_falls_back_to_session_recent_preferences() { + let session_recent_preferences = lime_agent::SessionExecutionRuntimePreferences { + web_search: false, + thinking: true, + task: true, + subagent: false, + }; + let metadata = build_chat_run_metadata_base( + &AsterChatRequest { + message: "hello".to_string(), + session_id: "session-1".to_string(), + event_name: "event-1".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + project_id: Some("project-1".to_string()), + workspace_id: "workspace-1".to_string(), + web_search: None, + search_mode: None, + execution_strategy: Some(AsterExecutionStrategy::React), + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "theme": "general", + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }, + "workspace-1", + AsterExecutionStrategy::React, + &RequestToolPolicy { + search_mode: RequestToolPolicyMode::Disabled, + effective_web_search: false, + required_tools: vec![], + allowed_tools: vec![], + disallowed_tools: vec![], + }, + false, + None, + Some(&session_recent_preferences), + ); + + assert_eq!( + metadata + .get("thinking_enabled") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + metadata + .get("task_mode_enabled") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + metadata + .get("subagent_mode_enabled") + .and_then(serde_json::Value::as_bool), + Some(false) + ); + } + #[test] fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() { let mut observation = ChatRunObservation::default(); @@ -1493,7 +1765,7 @@ mod tests { "subagent_mode_enabled": true, "preferred_team_preset_id": "code-triage-team", } - }))) + })), None, true) .expect("team prompt should exist"); assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER)); @@ -1505,7 +1777,7 @@ mod tests { "subagent_mode_enabled": false, "preferred_team_preset_id": "code-triage-team", } - }))); + })), None, false); assert!(disabled.is_none()); } @@ -1531,7 +1803,7 @@ mod tests { } ] } - }))) + })), None, true) .expect("team prompt should exist"); assert!(prompt.contains("前端联调团队")); @@ -1561,7 +1833,7 @@ mod tests { } ] } - }))) + })), None, true) .expect("team prompt should exist"); assert!(prompt.contains("当前调试 Team")); @@ -1571,6 +1843,94 @@ mod tests { assert!(prompt.contains("主动汇总关键进展、风险和下一步")); } + #[test] + fn test_build_team_preference_system_prompt_accepts_session_fallback_flag() { + let prompt = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "preferred_team_preset_id": "code-triage-team", + } + })), + None, + true, + ) + .expect("team prompt should exist"); + + assert!(prompt.contains("代码排障团队")); + assert!(prompt.contains("spawn_agent")); + } + + #[test] + fn test_build_team_preference_system_prompt_falls_back_to_session_recent_team_selection() { + let prompt = build_team_preference_system_prompt( + None, + Some(&lime_agent::SessionExecutionRuntimeRecentTeamSelection { + disabled: false, + theme: Some("general".to_string()), + preferred_team_preset_id: Some("code-triage-team".to_string()), + selected_team_id: Some("custom-team-1".to_string()), + selected_team_source: Some("custom".to_string()), + selected_team_label: Some("前端联调团队".to_string()), + selected_team_description: Some("分析、实现、验证三段式推进。".to_string()), + selected_team_summary: Some("分析、实现、验证三段式推进。".to_string()), + selected_team_roles: Some(vec![ + lime_agent::SessionExecutionRuntimeRecentTeamRole { + id: "explorer".to_string(), + label: "分析".to_string(), + summary: "负责定位问题与影响范围。".to_string(), + profile_id: Some("code-explorer".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: vec!["repo-exploration".to_string()], + }, + ]), + }), + true, + ) + .expect("team prompt should exist"); + + assert!(prompt.contains("代码排障团队")); + assert!(prompt.contains("前端联调团队")); + assert!(prompt.contains("来源:custom")); + assert!(prompt.contains("分析、实现、验证三段式推进。")); + assert!(prompt.contains("分析:负责定位问题与影响范围。")); + assert!(prompt.contains("profile: code-explorer")); + assert!(prompt.contains("roleKey: explorer")); + assert!(prompt.contains("skills: repo-exploration")); + } + + #[test] + fn test_build_team_preference_system_prompt_prefers_request_metadata_over_session_recent_team_selection() + { + let prompt = build_team_preference_system_prompt( + Some(&serde_json::json!({ + "harness": { + "selected_team_source": "builtin", + "selected_team_label": "请求内 Team", + "selected_team_summary": "以本次请求为准。", + } + })), + Some(&lime_agent::SessionExecutionRuntimeRecentTeamSelection { + disabled: false, + theme: Some("general".to_string()), + preferred_team_preset_id: Some("research-team".to_string()), + selected_team_id: Some("runtime-team".to_string()), + selected_team_source: Some("custom".to_string()), + selected_team_label: Some("会话 Team".to_string()), + selected_team_description: Some("旧会话描述".to_string()), + selected_team_summary: Some("旧会话摘要".to_string()), + selected_team_roles: None, + }), + true, + ) + .expect("team prompt should exist"); + + assert!(prompt.contains("请求内 Team")); + assert!(prompt.contains("来源:builtin")); + assert!(prompt.contains("以本次请求为准。")); + assert!(!prompt.contains("会话 Team")); + assert!(!prompt.contains("旧会话摘要")); + } + #[test] fn test_build_subagent_customization_state_applies_profile_defaults() { let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest { diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs index 54619cf1b..b834f803c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs @@ -4,43 +4,52 @@ use crate::services::site_capability_service::{ build_site_result_document_body, save_site_result_to_project, }; use crate::services::site_capability_service::{ - get_site_adapter, list_site_adapters, run_site_adapter_with_optional_save, - search_site_adapters, RunSiteAdapterRequest, + get_site_adapter, list_site_adapters, recommend_site_adapters, + run_site_adapter_with_optional_save, search_site_adapters, RunSiteAdapterRequest, }; #[cfg(test)] -use crate::services::site_capability_service::{SiteAdapterDefinition, SiteAdapterRunResult}; +use crate::services::site_capability_service::{ + SiteAdapterDefinition, SiteAdapterRecommendation, SiteAdapterRunResult, +}; use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot}; use serde_json::Value; const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"]; +const CONTENT_ID_ENV_KEYS: &[&str] = &["LIME_CONTENT_ID", "PROXYCAST_CONTENT_ID"]; #[derive(Debug, Clone, Copy)] enum LimeSiteToolKind { List, + Recommend, Search, Info, Run, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LimeSiteProjectSource { +enum LimeSiteSaveTargetSource { ExplicitProject, ContextProject, + ExplicitContent, + ContextContent, } -impl LimeSiteProjectSource { +impl LimeSiteSaveTargetSource { fn as_str(self) -> &'static str { match self { - LimeSiteProjectSource::ExplicitProject => "explicit_project", - LimeSiteProjectSource::ContextProject => "context_project", + LimeSiteSaveTargetSource::ExplicitProject => "explicit_project", + LimeSiteSaveTargetSource::ContextProject => "context_project", + LimeSiteSaveTargetSource::ExplicitContent => "explicit_content", + LimeSiteSaveTargetSource::ContextContent => "context_content", } } } #[derive(Debug, Clone, PartialEq, Eq)] -struct LimeSiteProjectTarget { - project_id: String, - source: LimeSiteProjectSource, +struct LimeSiteSaveTarget { + project_id: Option, + content_id: Option, + source: LimeSiteSaveTargetSource, } #[derive(Debug, Clone)] @@ -140,6 +149,19 @@ impl LimeSiteTool { }) } + fn build_recommend_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "可选返回数量上限;未传时返回按浏览器上下文排序后的推荐列表" + } + }, + "additionalProperties": false, + }) + } + fn build_info_schema() -> serde_json::Value { serde_json::json!({ "type": "object", @@ -168,7 +190,7 @@ impl LimeSiteTool { }, "profile_key": { "type": "string", - "description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话" + "description": "浏览器资料 Key,可选;未传时优先复用当前 browser assist 会话,否则自动选择已连接的 existing_session 或最合适的资料" }, "target_id": { "type": "string", @@ -178,13 +200,17 @@ impl LimeSiteTool { "type": "integer", "description": "脚本执行超时时间,毫秒" }, + "content_id": { + "type": "string", + "description": "可选内容 ID;未传时优先复用当前内容上下文,成功后优先写回当前主稿" + }, "project_id": { "type": "string", - "description": "可选项目 ID;未传时优先复用当前项目上下文,成功后会保存为资源文档" + "description": "可选项目 ID;未传时优先复用当前项目上下文。仅当没有 content_id 时,成功后会保存为新资源文档" }, "save_title": { "type": "string", - "description": "可选保存标题;仅在存在保存目标时生效" + "description": "可选保存标题;仅在保存为新资源文档时生效" } }, "required": ["adapter_name"], @@ -200,6 +226,14 @@ impl LimeSiteTool { .map(ToString::to_string) } + fn extract_content_id_from_value(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + } + fn extract_project_id_from_metadata_map( metadata: &HashMap, ) -> Option { @@ -208,6 +242,14 @@ impl LimeSiteTool { .find_map(|key| Self::extract_project_id_from_value(metadata.get(*key))) } + fn extract_content_id_from_metadata_map( + metadata: &HashMap, + ) -> Option { + ["content_id", "contentId"] + .iter() + .find_map(|key| Self::extract_content_id_from_value(metadata.get(*key))) + } + fn extract_project_id_from_runtime_snapshot( snapshot: &SessionRuntimeSnapshot, ) -> Option { @@ -237,6 +279,35 @@ impl LimeSiteTool { }) } + fn extract_content_id_from_runtime_snapshot( + snapshot: &SessionRuntimeSnapshot, + ) -> Option { + snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .filter_map(|turn| { + let content_id = turn.context_override.as_ref().and_then(|context| { + Self::extract_content_id_from_metadata_map(&context.metadata) + })?; + Some((turn.updated_at, content_id)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, content_id)| content_id) + .or_else(|| { + snapshot + .threads + .iter() + .filter_map(|thread| { + let content_id = + Self::extract_content_id_from_metadata_map(&thread.thread.metadata)?; + Some((thread.thread.updated_at, content_id)) + }) + .max_by_key(|(updated_at, _)| *updated_at) + .map(|(_, content_id)| content_id) + }) + } + fn extract_project_id_from_context_environment(context: &ToolContext) -> Option { PROJECT_ID_ENV_KEYS.iter().find_map(|key| { context @@ -249,6 +320,18 @@ impl LimeSiteTool { }) } + fn extract_content_id_from_context_environment(context: &ToolContext) -> Option { + CONTENT_ID_ENV_KEYS.iter().find_map(|key| { + context + .environment + .get(*key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + } + async fn resolve_context_project_id(context: &ToolContext) -> Option { let session_id = context.session_id.trim(); if !session_id.is_empty() { @@ -273,38 +356,80 @@ impl LimeSiteTool { Self::extract_project_id_from_context_environment(context) } - async fn resolve_project_target( + async fn resolve_context_content_id(context: &ToolContext) -> Option { + let session_id = context.session_id.trim(); + if !session_id.is_empty() { + match load_shared_session_runtime_snapshot(session_id).await { + Ok(snapshot) => { + if let Some(content_id) = + Self::extract_content_id_from_runtime_snapshot(&snapshot) + { + return Some(content_id); + } + } + Err(error) => { + tracing::debug!( + "[AsterAgent][SiteTool] 读取 runtime snapshot 失败,跳过上下文内容解析: session_id={}, error={}", + session_id, + error + ); + } + } + } + + Self::extract_content_id_from_context_environment(context) + } + + async fn resolve_save_target( params: &serde_json::Value, context: &ToolContext, - ) -> Option { + ) -> Option { + if let Some(content_id) = Self::extract_optional_string(params, &["content_id"]) { + return Some(LimeSiteSaveTarget { + project_id: None, + content_id: Some(content_id), + source: LimeSiteSaveTargetSource::ExplicitContent, + }); + } + if let Some(project_id) = Self::extract_optional_string(params, &["project_id"]) { - return Some(LimeSiteProjectTarget { - project_id, - source: LimeSiteProjectSource::ExplicitProject, + return Some(LimeSiteSaveTarget { + project_id: Some(project_id), + content_id: None, + source: LimeSiteSaveTargetSource::ExplicitProject, + }); + } + + if let Some(content_id) = Self::resolve_context_content_id(context).await { + return Some(LimeSiteSaveTarget { + project_id: None, + content_id: Some(content_id), + source: LimeSiteSaveTargetSource::ContextContent, }); } Self::resolve_context_project_id(context) .await - .map(|project_id| LimeSiteProjectTarget { - project_id, - source: LimeSiteProjectSource::ContextProject, + .map(|project_id| LimeSiteSaveTarget { + project_id: Some(project_id), + content_id: None, + source: LimeSiteSaveTargetSource::ContextProject, }) } - fn apply_project_target_to_run_result( + fn apply_save_target_to_run_result( mut result: crate::services::site_capability_service::SiteAdapterRunResult, - project_target: Option<&LimeSiteProjectTarget>, + save_target: Option<&LimeSiteSaveTarget>, ) -> crate::services::site_capability_service::SiteAdapterRunResult { - let Some(project_target) = project_target else { + let Some(save_target) = save_target else { return result; }; - let normalized_source = project_target.source.as_str().to_string(); - if result.saved_project_id.is_some() { + let normalized_source = save_target.source.as_str().to_string(); + if result.saved_content.is_some() || result.saved_project_id.is_some() { result.saved_by = Some(normalized_source.clone()); } - if result.save_skipped_project_id.is_some() { + if result.save_skipped_by.is_some() || result.save_skipped_project_id.is_some() { result.save_skipped_by = Some(normalized_source); } @@ -404,6 +529,21 @@ impl Tool for LimeSiteTool { .with_metadata("tool_family", serde_json::json!("site")) .with_metadata("result", serde_json::json!(result))) } + LimeSiteToolKind::Recommend => { + let limit = params.get("limit").and_then(serde_json::Value::as_u64); + let limit = limit + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0); + let result = recommend_site_adapters(&self.db, limit) + .await + .map_err(ToolError::execution_failed)?; + let payload = serde_json::to_string_pretty(&result).map_err(|error| { + ToolError::execution_failed(format!("序列化站点推荐结果失败: {error}")) + })?; + Ok(ToolResult::success(payload) + .with_metadata("tool_family", serde_json::json!("site")) + .with_metadata("result", serde_json::json!(result))) + } LimeSiteToolKind::Search => { let query = Self::extract_required_string(¶ms, &["query"], "query")?; let result = search_site_adapters(&query); @@ -447,7 +587,7 @@ impl Tool for LimeSiteTool { .unwrap_or_else(|| serde_json::json!({})); let target_id = Self::extract_optional_string(¶ms, &["target_id"]); let timeout_ms = params.get("timeout_ms").and_then(serde_json::Value::as_u64); - let project_target = Self::resolve_project_target(¶ms, context).await; + let save_target = Self::resolve_save_target(¶ms, context).await; let save_title = Self::extract_optional_string(¶ms, &["save_title"]); let run_request = RunSiteAdapterRequest { adapter_name: adapter_name.clone(), @@ -455,23 +595,27 @@ impl Tool for LimeSiteTool { profile_key, target_id, timeout_ms, - project_id: project_target + content_id: save_target .as_ref() - .map(|target| target.project_id.clone()), + .and_then(|target| target.content_id.clone()), + project_id: save_target + .as_ref() + .and_then(|target| target.project_id.clone()), save_title, }; - let result = Self::apply_project_target_to_run_result( + let result = Self::apply_save_target_to_run_result( run_site_adapter_with_optional_save(&self.db, run_request.clone()).await, - project_target.as_ref(), + save_target.as_ref(), ); - let browser_session = match (&result.session_id, &result.target_id) { - (Some(session_id), Some(target_id)) => Some(serde_json::json!({ - "session_id": session_id, - "target_id": target_id, + let browser_session = if result.session_id.is_some() || result.target_id.is_some() { + Some(serde_json::json!({ + "session_id": result.session_id, + "target_id": result.target_id, "profile_key": result.profile_key, - })), - _ => None, + })) + } else { + None }; let payload = serde_json::to_string_pretty(&result).map_err(|error| { ToolError::execution_failed(format!("序列化站点执行结果失败: {error}")) @@ -531,10 +675,22 @@ mod tests { .and_then(serde_json::Value::as_object) .expect("properties should exist"); + assert!(properties.contains_key("content_id")); assert!(properties.contains_key("project_id")); assert!(properties.contains_key("save_title")); } + #[test] + fn should_include_limit_field_in_recommend_schema() { + let schema = LimeSiteTool::build_recommend_schema(); + let properties = schema + .get("properties") + .and_then(serde_json::Value::as_object) + .expect("properties should exist"); + + assert!(properties.contains_key("limit")); + } + #[test] fn should_build_site_result_document_body_with_sections() { let adapter = SiteAdapterDefinition { @@ -556,6 +712,7 @@ mod tests { profile_key: Some("general_browser_assist".to_string()), target_id: None, timeout_ms: Some(20_000), + content_id: None, project_id: None, save_title: None, }; @@ -576,6 +733,7 @@ mod tests { error_code: None, error_message: None, auth_hint: Some("请先登录 GitHub。".to_string()), + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -624,6 +782,7 @@ mod tests { profile_key: Some("general_browser_assist".to_string()), target_id: Some("target-1".to_string()), timeout_ms: Some(20_000), + content_id: None, project_id: None, save_title: None, }; @@ -644,6 +803,7 @@ mod tests { error_code: None, error_message: None, auth_hint: Some("请先登录 GitHub。".to_string()), + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -759,6 +919,57 @@ mod tests { assert_eq!(project_id.as_deref(), Some("project-current")); } + #[test] + fn should_extract_latest_content_id_from_runtime_snapshot() { + let now = Utc::now(); + let mut older_turn = TurnRuntime::new( + "turn-older", + "session-1", + "thread-1", + Some("旧 turn".to_string()), + Some(TurnContextOverride { + metadata: HashMap::from([( + "content_id".to_string(), + serde_json::json!("content-older"), + )]), + ..TurnContextOverride::default() + }), + ); + older_turn.updated_at = now; + + let mut latest_turn = TurnRuntime::new( + "turn-latest", + "session-1", + "thread-1", + Some("新 turn".to_string()), + Some(TurnContextOverride { + metadata: HashMap::from([( + "content_id".to_string(), + serde_json::json!("content-current"), + )]), + ..TurnContextOverride::default() + }), + ); + latest_turn.updated_at = now + ChronoDuration::seconds(5); + + let mut thread = + ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/site-runtime")); + thread.updated_at = latest_turn.updated_at; + + let snapshot = SessionRuntimeSnapshot { + session_id: "session-1".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread, + turns: vec![older_turn, latest_turn], + items: Vec::new(), + }], + }; + + let content_id = LimeSiteTool::extract_content_id_from_runtime_snapshot(&snapshot); + + assert_eq!(content_id.as_deref(), Some("content-current")); + } + #[test] fn should_extract_project_id_from_thread_metadata_when_turn_metadata_missing() { let now = Utc::now(); @@ -796,7 +1007,7 @@ mod tests { let context = ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1"); - let target = runtime.block_on(LimeSiteTool::resolve_project_target( + let target = runtime.block_on(LimeSiteTool::resolve_save_target( &serde_json::json!({ "project_id": "project-explicit" }), @@ -805,9 +1016,33 @@ mod tests { assert_eq!( target, - Some(LimeSiteProjectTarget { - project_id: "project-explicit".to_string(), - source: LimeSiteProjectSource::ExplicitProject, + Some(LimeSiteSaveTarget { + project_id: Some("project-explicit".to_string()), + content_id: None, + source: LimeSiteSaveTargetSource::ExplicitProject, + }) + ); + } + + #[test] + fn should_resolve_content_target_as_explicit_when_param_exists() { + let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败"); + let context = + ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1"); + + let target = runtime.block_on(LimeSiteTool::resolve_save_target( + &serde_json::json!({ + "content_id": "content-explicit" + }), + &context, + )); + + assert_eq!( + target, + Some(LimeSiteSaveTarget { + project_id: None, + content_id: Some("content-explicit".to_string()), + source: LimeSiteSaveTargetSource::ExplicitContent, }) ); } @@ -822,20 +1057,81 @@ mod tests { "project-from-env".to_string(), ); - let target = runtime.block_on(LimeSiteTool::resolve_project_target( + let target = runtime.block_on(LimeSiteTool::resolve_save_target( &serde_json::json!({}), &context, )); assert_eq!( target, - Some(LimeSiteProjectTarget { - project_id: "project-from-env".to_string(), - source: LimeSiteProjectSource::ContextProject, + Some(LimeSiteSaveTarget { + project_id: Some("project-from-env".to_string()), + content_id: None, + source: LimeSiteSaveTargetSource::ContextProject, }) ); } + #[test] + fn should_resolve_content_target_from_context_environment_when_runtime_missing() { + let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败"); + let mut context = + ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("missing"); + context.environment.insert( + "LIME_CONTENT_ID".to_string(), + "content-from-env".to_string(), + ); + + let target = runtime.block_on(LimeSiteTool::resolve_save_target( + &serde_json::json!({}), + &context, + )); + + assert_eq!( + target, + Some(LimeSiteSaveTarget { + project_id: None, + content_id: Some("content-from-env".to_string()), + source: LimeSiteSaveTargetSource::ContextContent, + }) + ); + } + + #[test] + fn should_execute_recommend_tool_and_return_result_metadata() { + let runtime = tokio::runtime::Runtime::new().expect("创建 runtime 失败"); + let tool = LimeSiteTool::new( + LIME_SITE_RECOMMEND_TOOL_NAME.to_string(), + "推荐站点适配器", + LimeSiteTool::build_recommend_schema(), + LimeSiteToolKind::Recommend, + setup_test_db(), + ); + let context = + ToolContext::new(PathBuf::from("/tmp/site-runtime")).with_session_id("session-1"); + + let result = runtime + .block_on(tool.execute( + serde_json::json!({ + "limit": 1 + }), + &context, + )) + .expect("推荐工具应返回 ToolResult"); + + assert!(result.success); + let recommendations = serde_json::from_value::>( + result + .metadata + .get("result") + .cloned() + .expect("metadata 应包含 result"), + ) + .expect("应能解析推荐结果"); + assert_eq!(recommendations.len(), 1); + assert!(!recommendations[0].adapter.name.is_empty()); + } + #[test] fn should_rewrite_saved_source_as_context_project_when_result_comes_from_context() { let result = SiteAdapterRunResult { @@ -851,6 +1147,7 @@ mod tests { error_code: None, error_message: None, auth_hint: None, + report_hint: None, saved_content: Some( crate::services::site_capability_service::SavedSiteAdapterContent { content_id: "content-1".to_string(), @@ -865,11 +1162,12 @@ mod tests { save_error_message: None, }; - let normalized = LimeSiteTool::apply_project_target_to_run_result( + let normalized = LimeSiteTool::apply_save_target_to_run_result( result, - Some(&LimeSiteProjectTarget { - project_id: "project-context".to_string(), - source: LimeSiteProjectSource::ContextProject, + Some(&LimeSiteSaveTarget { + project_id: Some("project-context".to_string()), + content_id: None, + source: LimeSiteSaveTargetSource::ContextProject, }), ); @@ -891,6 +1189,7 @@ mod tests { error_code: Some("adapter_not_found".to_string()), error_message: Some("未找到对应的站点适配器".to_string()), auth_hint: None, + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -899,11 +1198,12 @@ mod tests { save_error_message: None, }; - let normalized = LimeSiteTool::apply_project_target_to_run_result( + let normalized = LimeSiteTool::apply_save_target_to_run_result( result, - Some(&LimeSiteProjectTarget { - project_id: "project-context".to_string(), - source: LimeSiteProjectSource::ContextProject, + Some(&LimeSiteSaveTarget { + project_id: Some("project-context".to_string()), + content_id: None, + source: LimeSiteSaveTargetSource::ContextProject, }), ); @@ -962,6 +1262,7 @@ mod tests { pub(super) fn site_tool_names() -> Vec<&'static str> { vec![ LIME_SITE_LIST_TOOL_NAME, + LIME_SITE_RECOMMEND_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, LIME_SITE_INFO_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, @@ -979,6 +1280,12 @@ pub(super) fn register_site_tools_to_registry( LimeSiteTool::build_list_schema(), LimeSiteToolKind::List, ), + ( + LIME_SITE_RECOMMEND_TOOL_NAME, + "基于当前浏览器资料、已连接标签页和站点范围推荐可直接运行的 Lime 站点适配器,优先复用现有登录态。", + LimeSiteTool::build_recommend_schema(), + LimeSiteToolKind::Recommend, + ), ( LIME_SITE_SEARCH_TOOL_NAME, "按关键词搜索 Lime 内置站点适配器。", diff --git a/src-tauri/src/commands/site_capability_cmd.rs b/src-tauri/src/commands/site_capability_cmd.rs index eb3e645ca..80759b20b 100644 --- a/src-tauri/src/commands/site_capability_cmd.rs +++ b/src-tauri/src/commands/site_capability_cmd.rs @@ -4,9 +4,10 @@ use crate::services::site_adapter_registry::{ get_site_adapter_catalog_status, SiteAdapterCatalogStatus, }; use crate::services::site_capability_service::{ - get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save, - save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest, - SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition, + get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter, + run_site_adapter_with_optional_save, save_existing_site_result_to_project, + search_site_adapters, RunSiteAdapterRequest, SaveSiteAdapterResultRequest, + SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRecommendation, SiteAdapterRunResult, }; use serde::Deserialize; @@ -28,11 +29,25 @@ pub struct SiteAdapterCatalogBootstrapRequest { pub payload: Value, } +#[derive(Debug, Deserialize)] +pub struct SiteAdapterRecommendRequest { + #[serde(default)] + pub limit: Option, +} + #[tauri::command] pub fn site_list_adapters() -> Result, String> { Ok(list_site_adapters()) } +#[tauri::command] +pub async fn site_recommend_adapters( + db: State<'_, DbConnection>, + request: SiteAdapterRecommendRequest, +) -> Result, String> { + recommend_site_adapters(db.inner(), request.limit).await +} + #[tauri::command] pub fn site_search_adapters( request: SiteAdapterSearchRequest, diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index c4aed5d75..2fb8b1649 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -508,6 +508,8 @@ impl BrowserRuntimeAuditRecord { profile_key: Option, requested_backend: Option, selected_backend: Option, + session_id: Option, + target_id: Option, success: bool, error: Option, attempts: Vec, @@ -526,8 +528,8 @@ impl BrowserRuntimeAuditRecord { attempts, environment_preset_id: None, environment_preset_name: None, - target_id: None, - session_id: None, + target_id, + session_id, url: None, reused: None, open_window: None, @@ -1678,6 +1680,8 @@ pub async fn browser_execute_action_with_manager( profile_key.clone(), request.backend.clone(), result.backend.clone(), + result.session_id.clone(), + result.target_id.clone(), true, None, attempts, @@ -1709,6 +1713,8 @@ pub async fn browser_execute_action_with_manager( profile_key.clone(), request.backend.clone(), None, + None, + None, false, Some(error), attempts, @@ -1737,6 +1743,8 @@ pub async fn browser_execute_action_with_manager( profile_key, request.backend, None, + None, + None, false, result.error.clone(), attempts, @@ -3420,4 +3428,42 @@ mod tests { BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear(); } + + #[tokio::test] + async fn browser_runtime_audit_should_store_action_session_keys() { + BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear(); + + append_browser_runtime_audit(BrowserRuntimeAuditRecord::action( + "browser-action-1".to_string(), + "read_page".to_string(), + Some("general_browser_assist".to_string()), + Some(BrowserBackendType::CdpDirect), + Some(BrowserBackendType::CdpDirect), + Some("session-42".to_string()), + Some("target-42".to_string()), + true, + None, + vec![BrowserActionAttempt { + backend: BrowserBackendType::CdpDirect, + success: true, + message: "执行成功".to_string(), + }], + )) + .await; + + let logs = get_browser_action_audit_logs(Some(5)) + .await + .expect("audit logs should be readable"); + let record = logs.first().expect("action audit must exist"); + assert!(matches!(record.kind, BrowserRuntimeAuditKind::Action)); + assert_eq!(record.id, "browser-action-1"); + assert_eq!(record.session_id.as_deref(), Some("session-42")); + assert_eq!(record.target_id.as_deref(), Some("target-42")); + assert_eq!( + record.profile_key.as_deref(), + Some("general_browser_assist") + ); + + BROWSER_RUNTIME_AUDIT_LOGS.lock().await.clear(); + } } diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 4ac80b6aa..77bd2f526 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -312,4 +312,220 @@ mod tests { assert!(status_value["shortcut_registered"].is_boolean()); assert!(status_value["translate_shortcut_registered"].is_boolean()); } + + #[tokio::test] + async fn browser_profile_commands_roundtrip() { + let state = make_test_state(); + + let saved_value = handle_command( + &state, + "save_browser_profile_cmd", + Some(serde_json::json!({ + "request": { + "profile_key": "github-attached", + "name": "GitHub 已登录 Chrome", + "description": "复用当前 Chrome", + "site_scope": "github.com", + "launch_url": "https://github.com/", + "transport_kind": "existing_session" + } + })), + ) + .await + .unwrap(); + + let profile_id = saved_value["id"].as_str().unwrap().to_string(); + assert_eq!(saved_value["profile_key"], "github-attached"); + assert_eq!(saved_value["transport_kind"], "existing_session"); + + let active_list = handle_command( + &state, + "list_browser_profiles_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": false + } + })), + ) + .await + .unwrap(); + assert_eq!(active_list.as_array().unwrap().len(), 1); + + let archived = handle_command( + &state, + "archive_browser_profile_cmd", + Some(serde_json::json!({ + "request": { + "id": profile_id + } + })), + ) + .await + .unwrap(); + assert_eq!(archived, serde_json::json!(true)); + + let active_list_after_archive = handle_command( + &state, + "list_browser_profiles_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": false + } + })), + ) + .await + .unwrap(); + assert!(active_list_after_archive.as_array().unwrap().is_empty()); + + let archived_list = handle_command( + &state, + "list_browser_profiles_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": true + } + })), + ) + .await + .unwrap(); + assert_eq!(archived_list.as_array().unwrap().len(), 1); + assert!(archived_list.as_array().unwrap()[0]["archived_at"] + .as_str() + .is_some()); + + let restored = handle_command( + &state, + "restore_browser_profile_cmd", + Some(serde_json::json!({ + "request": { + "id": archived_list.as_array().unwrap()[0]["id"] + } + })), + ) + .await + .unwrap(); + assert_eq!(restored, serde_json::json!(true)); + + let active_list_after_restore = handle_command( + &state, + "list_browser_profiles_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": false + } + })), + ) + .await + .unwrap(); + assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn browser_environment_preset_commands_roundtrip() { + let state = make_test_state(); + + let saved_value = handle_command( + &state, + "save_browser_environment_preset_cmd", + Some(serde_json::json!({ + "request": { + "name": "GitHub 搜索环境", + "description": "用于仓库线索检索", + "timezone_id": "Asia/Shanghai", + "locale": "zh_CN", + "accept_language": "zh-CN,zh;q=0.9", + "viewport_width": 1440, + "viewport_height": 960, + "device_scale_factor": 1.25 + } + })), + ) + .await + .unwrap(); + + let preset_id = saved_value["id"].as_str().unwrap().to_string(); + assert_eq!(saved_value["name"], "GitHub 搜索环境"); + assert_eq!(saved_value["timezone_id"], "Asia/Shanghai"); + + let active_list = handle_command( + &state, + "list_browser_environment_presets_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": false + } + })), + ) + .await + .unwrap(); + assert_eq!(active_list.as_array().unwrap().len(), 1); + + let archived = handle_command( + &state, + "archive_browser_environment_preset_cmd", + Some(serde_json::json!({ + "request": { + "id": preset_id + } + })), + ) + .await + .unwrap(); + assert_eq!(archived, serde_json::json!(true)); + + let active_list_after_archive = handle_command( + &state, + "list_browser_environment_presets_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": false + } + })), + ) + .await + .unwrap(); + assert!(active_list_after_archive.as_array().unwrap().is_empty()); + + let archived_list = handle_command( + &state, + "list_browser_environment_presets_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": true + } + })), + ) + .await + .unwrap(); + assert_eq!(archived_list.as_array().unwrap().len(), 1); + assert!(archived_list.as_array().unwrap()[0]["archived_at"] + .as_str() + .is_some()); + + let restored = handle_command( + &state, + "restore_browser_environment_preset_cmd", + Some(serde_json::json!({ + "request": { + "id": archived_list.as_array().unwrap()[0]["id"] + } + })), + ) + .await + .unwrap(); + assert_eq!(restored, serde_json::json!(true)); + + let active_list_after_restore = handle_command( + &state, + "list_browser_environment_presets_cmd", + Some(serde_json::json!({ + "request": { + "include_archived": false + } + })), + ) + .await + .unwrap(); + assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1); + } } diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs b/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs index e62ba02bd..1c8982ec5 100644 --- a/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs +++ b/src-tauri/src/dev_bridge/dispatcher/browser/runtime.rs @@ -53,6 +53,131 @@ pub(super) async fn try_handle( .await?, )? } + "list_browser_profiles_cmd" => { + let request: Option = + parse_optional_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_profile_service::list_browser_profiles( + &conn, + request + .map(|payload| payload.include_archived) + .unwrap_or(false), + )?, + )? + } + "save_browser_profile_cmd" => { + let request: crate::commands::browser_profile_cmd::SaveBrowserProfileRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_profile_service::save_browser_profile( + &conn, + crate::services::browser_profile_service::SaveBrowserProfileInput { + id: request.id, + profile_key: request.profile_key, + name: request.name, + description: request.description, + site_scope: request.site_scope, + launch_url: request.launch_url, + transport_kind: request.transport_kind, + }, + )?, + )? + } + "archive_browser_profile_cmd" => { + let request: crate::commands::browser_profile_cmd::BrowserProfileRecordRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_profile_service::archive_browser_profile( + &conn, + &request.id, + )?, + )? + } + "restore_browser_profile_cmd" => { + let request: crate::commands::browser_profile_cmd::BrowserProfileRecordRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_profile_service::restore_browser_profile( + &conn, + &request.id, + )?, + )? + } + "list_browser_environment_presets_cmd" => { + let request: Option< + crate::commands::browser_environment_cmd::ListBrowserEnvironmentPresetsRequest, + > = parse_optional_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_environment_service::list_browser_environment_presets( + &conn, + request + .map(|payload| payload.include_archived) + .unwrap_or(false), + )?, + )? + } + "save_browser_environment_preset_cmd" => { + let request: crate::commands::browser_environment_cmd::SaveBrowserEnvironmentPresetRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_environment_service::save_browser_environment_preset( + &conn, + crate::services::browser_environment_service::SaveBrowserEnvironmentPresetInput { + id: request.id, + name: request.name, + description: request.description, + proxy_server: request.proxy_server, + timezone_id: request.timezone_id, + locale: request.locale, + accept_language: request.accept_language, + geolocation_lat: request.geolocation_lat, + geolocation_lng: request.geolocation_lng, + geolocation_accuracy_m: request.geolocation_accuracy_m, + user_agent: request.user_agent, + platform: request.platform, + viewport_width: request.viewport_width, + viewport_height: request.viewport_height, + device_scale_factor: request.device_scale_factor, + }, + )?, + )? + } + "archive_browser_environment_preset_cmd" => { + let request: crate::commands::browser_environment_cmd::BrowserEnvironmentPresetRecordRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_environment_service::archive_browser_environment_preset( + &conn, + &request.id, + )?, + )? + } + "restore_browser_environment_preset_cmd" => { + let request: crate::commands::browser_environment_cmd::BrowserEnvironmentPresetRecordRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + let conn = crate::database::lock_db(&db)?; + serde_json::to_value( + crate::services::browser_environment_service::restore_browser_environment_preset( + &conn, + &request.id, + )?, + )? + } "launch_browser_runtime_assist" => { let app_handle = require_app_handle(state)?; let request: crate::commands::browser_runtime_cmd::LaunchBrowserRuntimeAssistRequest = diff --git a/src-tauri/src/dev_bridge/dispatcher/browser/site.rs b/src-tauri/src/dev_bridge/dispatcher/browser/site.rs index 77b2ad139..b3c48f813 100644 --- a/src-tauri/src/dev_bridge/dispatcher/browser/site.rs +++ b/src-tauri/src/dev_bridge/dispatcher/browser/site.rs @@ -6,8 +6,9 @@ use crate::services::site_adapter_registry::{ get_site_adapter_catalog_status, }; use crate::services::site_capability_service::{ - get_site_adapter, list_site_adapters, run_site_adapter, run_site_adapter_with_optional_save, - save_existing_site_result_to_project, search_site_adapters, + get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter, + run_site_adapter_with_optional_save, save_existing_site_result_to_project, + search_site_adapters, }; use serde_json::Value as JsonValue; @@ -18,6 +19,12 @@ pub(super) async fn try_handle( ) -> Result, DynError> { let result = match cmd { "site_list_adapters" => serde_json::to_value(list_site_adapters())?, + "site_recommend_adapters" => { + let request: crate::commands::site_capability_cmd::SiteAdapterRecommendRequest = + parse_request(args)?; + let db = get_db(state)?.clone(); + serde_json::to_value(recommend_site_adapters(&db, request.limit).await?)? + } "site_search_adapters" => { let request: crate::commands::site_capability_cmd::SiteAdapterSearchRequest = parse_request(args)?; diff --git a/src-tauri/src/services/artifact_document_service.rs b/src-tauri/src/services/artifact_document_service.rs index 1b4d6903d..cf044be81 100644 --- a/src-tauri/src/services/artifact_document_service.rs +++ b/src-tauri/src/services/artifact_document_service.rs @@ -3,6 +3,9 @@ //! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench //! 提供可直接消费的 snapshot metadata。 +use crate::commands::content_cmd::THEME_WORKBENCH_DOCUMENT_META_KEY; +use crate::content::{ContentManager, ContentUpdateRequest}; +use crate::database::DbConnection; use crate::services::artifact_document_validator::{ validate_or_fallback_artifact_document, validate_or_repair_artifact_document_value, ArtifactDocumentValidationContext, ArtifactDocumentValidationOutcome, @@ -26,6 +29,8 @@ pub struct PersistedArtifactDocument { pub absolute_path: PathBuf, pub serialized_document: String, pub snapshot_metadata: Map, + pub theme_workbench_document_state: Map, + pub content_body: String, pub title: String, pub kind: String, pub status: String, @@ -268,6 +273,9 @@ pub fn persist_artifact_document_from_text( &source_links, version_diff.as_ref(), ); + let theme_workbench_document_state = + build_theme_workbench_document_state(&version_history, current_version.id.as_str()); + let content_body = build_content_body_from_document(&enriched_document); Ok(PersistedArtifactDocument { artifact_id, @@ -277,6 +285,8 @@ pub fn persist_artifact_document_from_text( absolute_path, serialized_document, snapshot_metadata, + theme_workbench_document_state, + content_body, title: outcome.title, kind: outcome.kind, status: outcome.status, @@ -397,6 +407,198 @@ fn build_snapshot_metadata( metadata } +fn parse_rfc3339_to_timestamp_millis(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|parsed| parsed.timestamp_millis()) +} + +fn resolve_topic_branch_status(status: &str) -> Option<&'static str> { + match status.trim() { + "ready" | "success" => Some("merged"), + "draft" | "streaming" | "pending" | "queued" | "running" => Some("pending"), + "failed" | "error" | "timeout" | "canceled" => Some("candidate"), + _ => None, + } +} + +fn build_theme_workbench_document_state( + version_history: &[ArtifactVersionSummary], + current_version_id: &str, +) -> Map { + let mut state = Map::new(); + state.insert( + "currentVersionId".to_string(), + Value::String(current_version_id.to_string()), + ); + state.insert( + "versions".to_string(), + Value::Array( + version_history + .iter() + .rev() + .map(|version| { + let mut record = Map::new(); + record.insert("id".to_string(), Value::String(version.id.clone())); + record.insert( + "createdAt".to_string(), + Value::from( + parse_rfc3339_to_timestamp_millis(version.created_at.as_str()) + .unwrap_or_default(), + ), + ); + record.insert( + "description".to_string(), + Value::String( + version + .summary + .clone() + .unwrap_or_else(|| format!("版本 {}", version.version_no)), + ), + ); + Value::Object(record) + }) + .collect(), + ), + ); + state.insert( + "versionStatusMap".to_string(), + Value::Object( + version_history + .iter() + .filter_map(|version| { + resolve_topic_branch_status(version.status.as_str()) + .map(|status| (version.id.clone(), Value::String(status.to_string()))) + }) + .collect(), + ), + ); + state +} + +fn normalize_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn extract_block_text(block: &Map) -> Option { + normalize_text(block.get("markdown").and_then(Value::as_str)) + .or_else(|| normalize_text(block.get("text").and_then(Value::as_str))) + .or_else(|| normalize_text(block.get("content").and_then(Value::as_str))) + .or_else(|| normalize_text(block.get("summary").and_then(Value::as_str))) + .or_else(|| { + block.get("items").and_then(Value::as_array).map(|items| { + items + .iter() + .filter_map(|item| { + if let Some(text) = item.as_str() { + return normalize_text(Some(text)); + } + + let item = item.as_object()?; + normalize_text(item.get("label").and_then(Value::as_str)) + .or_else(|| normalize_text(item.get("text").and_then(Value::as_str))) + .or_else(|| normalize_text(item.get("title").and_then(Value::as_str))) + }) + .collect::>() + .join("\n") + }) + }) + .and_then(|value| normalize_text(Some(value.as_str()))) +} + +fn build_content_body_from_document(document: &Value) -> String { + let Some(record) = document.as_object() else { + return String::new(); + }; + + let mut sections = Vec::new(); + if let Some(title) = normalize_text(record.get("title").and_then(Value::as_str)) { + sections.push(format!("# {title}")); + } + if let Some(summary) = normalize_text(record.get("summary").and_then(Value::as_str)) { + sections.push(summary); + } + + if let Some(blocks) = record.get("blocks").and_then(Value::as_array) { + for block in blocks.iter().filter_map(Value::as_object) { + let mut parts = Vec::new(); + if let Some(title) = normalize_text(block.get("title").and_then(Value::as_str)) { + parts.push(format!("## {title}")); + } + if let Some(body) = extract_block_text(block) { + parts.push(body); + } + if !parts.is_empty() { + sections.push(parts.join("\n\n")); + } + } + } + + sections.join("\n\n").trim().to_string() +} + +fn extract_content_id_from_request_metadata(request_metadata: Option<&Value>) -> Option { + let root = request_metadata?.as_object()?; + let harness = root + .get("harness") + .and_then(Value::as_object) + .unwrap_or(root); + + ["content_id", "contentId"] + .iter() + .filter_map(|key| harness.get(*key)) + .find_map(Value::as_str) + .and_then(|value| normalize_text(Some(value))) +} + +fn should_sync_snapshot_metadata_key_to_content(key: &str) -> bool { + key.starts_with("artifact") || matches!(key, "previewText" | "lastUpdateSource") +} + +pub fn sync_persisted_artifact_document_to_content( + db: &DbConnection, + request_metadata: Option<&Value>, + persisted: &PersistedArtifactDocument, +) -> Result<(), String> { + let Some(content_id) = extract_content_id_from_request_metadata(request_metadata) else { + return Ok(()); + }; + + let manager = ContentManager::new(db.clone()); + let Some(content) = manager.get(&content_id)? else { + return Err(format!("未找到要同步的内容: {content_id}")); + }; + + let mut next_metadata = content + .metadata + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + for (key, value) in persisted.snapshot_metadata.iter() { + if should_sync_snapshot_metadata_key_to_content(key.as_str()) { + next_metadata.insert(key.clone(), value.clone()); + } + } + next_metadata.insert( + THEME_WORKBENCH_DOCUMENT_META_KEY.to_string(), + Value::Object(persisted.theme_workbench_document_state.clone()), + ); + + manager.update( + &content_id, + ContentUpdateRequest { + body: (!persisted.content_body.trim().is_empty()) + .then(|| persisted.content_body.clone()), + metadata: Some(Value::Object(next_metadata)), + ..Default::default() + }, + )?; + + Ok(()) +} + fn build_version_id(artifact_id: &str, version_no: usize) -> String { format!("{artifact_id}:v{version_no}") } @@ -1075,6 +1277,131 @@ mod tests { assert!(persisted_second .serialized_document .contains("\"currentVersionDiff\"")); + assert_eq!( + persisted_second + .theme_workbench_document_state + .get("currentVersionId") + .and_then(Value::as_str), + Some("artifact-document:artifact:analysis:demo:v2") + ); + assert!(persisted_second.content_body.contains("# 结构化结论")); + } + + #[test] + fn sync_persisted_artifact_document_to_content_should_update_body_and_metadata() { + use crate::content::{ContentCreateRequest, ContentManager}; + use crate::database::init_database; + use crate::workspace::{WorkspaceManager, WorkspaceType}; + + let db = init_database().expect("db should init"); + let workspace_root = tempdir().expect("tempdir").keep(); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "自动化项目".to_string(), + workspace_root.clone(), + WorkspaceType::Document, + ) + .expect("workspace should create"); + let manager = ContentManager::new(db.clone()); + let content = manager + .create(ContentCreateRequest { + project_id: workspace.id.clone(), + title: "自动化日报".to_string(), + content_type: None, + order: None, + body: Some(String::new()), + metadata: Some(serde_json::json!({ + "source": "service_skill" + })), + }) + .expect("content should create"); + + let params = ArtifactDocumentPersistParams { + workspace_root, + workspace_id: Some(workspace.id.clone()), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + request_metadata: Some(serde_json::json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "report", + "artifact_request_id": format!("artifact:{}", content.id.clone()) + }, + "harness": { + "content_id": content.id.clone() + } + })), + }; + + let persisted = persist_artifact_document_from_text( + r#"{ + "type": "artifact_document_draft", + "schemaVersion": "artifact_document.v1", + "artifactId": "artifact-document:artifact:content", + "kind": "report", + "title": "自动化日报", + "status": "ready", + "summary": "最新日报已生成", + "blocks": [ + { "id": "body-1", "type": "rich_text", "markdown": "日报正文内容" } + ] + }"#, + ¶ms, + ) + .expect("persist should succeed"); + + sync_persisted_artifact_document_to_content( + &db, + params.request_metadata.as_ref(), + &persisted, + ) + .expect("sync should succeed"); + + let updated = manager + .get(&content.id) + .expect("get content should succeed") + .expect("updated content should exist"); + assert!(updated.body.contains("日报正文内容")); + let metadata = updated.metadata.expect("metadata should exist"); + assert_eq!( + metadata + .get(THEME_WORKBENCH_DOCUMENT_META_KEY) + .and_then(Value::as_object) + .and_then(|value| value.get("currentVersionId")) + .and_then(Value::as_str), + Some(format!("artifact-document:artifact:{}:v1", content.id).as_str()) + ); + assert_eq!( + metadata.get("artifactKind").and_then(Value::as_str), + Some("report") + ); + assert_eq!( + metadata.get("artifactRequestId").and_then(Value::as_str), + Some(format!("artifact:{}", content.id).as_str()) + ); + assert_eq!( + metadata + .get("artifact_paths") + .and_then(Value::as_array) + .and_then(|paths| paths.first()) + .and_then(Value::as_str), + Some( + format!( + ".lime/artifacts/thread-1/{}.artifact.json", + normalize_slug(format!("artifact:{}", content.id).as_str()) + ) + .as_str() + ) + ); + assert_eq!( + metadata + .get("artifactDocument") + .and_then(Value::as_object) + .and_then(|document| document.get("title")) + .and_then(Value::as_str), + Some("自动化日报") + ); } #[test] diff --git a/src-tauri/src/services/artifact_request_metadata_service.rs b/src-tauri/src/services/artifact_request_metadata_service.rs index 1c35aa99a..8382f8a5a 100644 --- a/src-tauri/src/services/artifact_request_metadata_service.rs +++ b/src-tauri/src/services/artifact_request_metadata_service.rs @@ -151,10 +151,66 @@ fn is_meaningful_artifact_value(value: Option<&Value>) -> bool { .is_some_and(|value| !value.is_empty()) } +fn backfill_harness_string_if_missing( + request_metadata: Value, + keys: &[&str], + fallback: Option<&str>, +) -> Value { + let Some(fallback) = normalize_text(fallback) else { + return request_metadata; + }; + if extract_harness_string(Some(&request_metadata), keys).is_some() { + return request_metadata; + } + + let mut request_metadata = request_metadata; + let Some(root) = request_metadata.as_object_mut() else { + return request_metadata; + }; + + if let Some(harness) = root.get_mut("harness").and_then(Value::as_object_mut) { + harness.insert(keys[0].to_string(), Value::String(fallback)); + return request_metadata; + } + + root.insert(keys[0].to_string(), Value::String(fallback)); + request_metadata +} + pub fn normalize_request_metadata_with_artifact_defaults( request_metadata: Option, + theme_fallback: Option<&str>, + session_mode_fallback: Option<&str>, + gate_key_fallback: Option<&str>, + run_title_fallback: Option<&str>, + content_id_fallback: Option<&str>, ) -> Option { let request_metadata = request_metadata?; + let request_metadata = backfill_harness_string_if_missing( + request_metadata, + &["theme", "harness_theme", "harnessTheme"], + theme_fallback, + ); + let request_metadata = backfill_harness_string_if_missing( + request_metadata, + &["session_mode", "sessionMode"], + session_mode_fallback, + ); + let request_metadata = backfill_harness_string_if_missing( + request_metadata, + &["gate_key", "gateKey"], + gate_key_fallback, + ); + let request_metadata = backfill_harness_string_if_missing( + request_metadata, + &["run_title", "runTitle", "title"], + run_title_fallback, + ); + let request_metadata = backfill_harness_string_if_missing( + request_metadata, + &["content_id", "contentId"], + content_id_fallback, + ); let Some(root) = request_metadata.as_object() else { return Some(request_metadata); }; @@ -250,8 +306,15 @@ mod tests { } }); - let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata)) - .expect("normalized metadata"); + let normalized = normalize_request_metadata_with_artifact_defaults( + Some(metadata), + None, + None, + None, + None, + None, + ) + .expect("normalized metadata"); assert_eq!( normalized @@ -302,8 +365,15 @@ mod tests { } }); - let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata)) - .expect("normalized metadata"); + let normalized = normalize_request_metadata_with_artifact_defaults( + Some(metadata), + None, + None, + None, + None, + None, + ) + .expect("normalized metadata"); assert!(normalized.get("artifact").is_none()); } @@ -320,8 +390,15 @@ mod tests { } }); - let normalized = normalize_request_metadata_with_artifact_defaults(Some(metadata)) - .expect("normalized metadata"); + let normalized = normalize_request_metadata_with_artifact_defaults( + Some(metadata), + None, + None, + None, + None, + None, + ) + .expect("normalized metadata"); assert_eq!( normalized @@ -342,4 +419,107 @@ mod tests { None ); } + + #[test] + fn should_backfill_content_id_before_infer_artifact_request_id() { + let metadata = json!({ + "harness": { + "theme": "document", + "session_mode": "theme_workbench" + } + }); + + let normalized = normalize_request_metadata_with_artifact_defaults( + Some(metadata), + None, + None, + None, + None, + Some("content-from-session"), + ) + .expect("normalized metadata"); + + assert_eq!( + normalized + .pointer("/harness/content_id") + .and_then(Value::as_str), + Some("content-from-session") + ); + assert_eq!( + normalized + .pointer("/artifact/artifact_request_id") + .and_then(Value::as_str), + Some("artifact:content-from-session") + ); + } + + #[test] + fn should_backfill_theme_and_session_mode_before_infer_artifact_defaults() { + let metadata = json!({ + "harness": { + "content_id": "content-1" + } + }); + + let normalized = normalize_request_metadata_with_artifact_defaults( + Some(metadata), + Some("document"), + Some("theme_workbench"), + None, + None, + None, + ) + .expect("normalized metadata"); + + assert_eq!( + normalized.pointer("/harness/theme").and_then(Value::as_str), + Some("document") + ); + assert_eq!( + normalized + .pointer("/harness/session_mode") + .and_then(Value::as_str), + Some("theme_workbench") + ); + assert_eq!( + normalized + .pointer("/artifact/artifact_request_id") + .and_then(Value::as_str), + Some("artifact:content-1") + ); + } + + #[test] + fn should_backfill_gate_key_and_run_title_when_missing() { + let metadata = json!({ + "harness": { + "theme": "social-media", + "session_mode": "theme_workbench", + "content_id": "content-social-1" + } + }); + + let normalized = normalize_request_metadata_with_artifact_defaults( + Some(metadata), + None, + None, + Some("write_mode"), + Some("社媒初稿"), + None, + ) + .expect("normalized metadata"); + + assert_eq!( + normalized + .pointer("/harness/gate_key") + .and_then(Value::as_str), + Some("write_mode") + ); + assert_eq!( + normalized + .pointer("/harness/run_title") + .and_then(Value::as_str), + Some("社媒初稿") + ); + } } diff --git a/src-tauri/src/services/automation_service/mod.rs b/src-tauri/src/services/automation_service/mod.rs index 379c87cf8..b36bf704d 100644 --- a/src-tauri/src/services/automation_service/mod.rs +++ b/src-tauri/src/services/automation_service/mod.rs @@ -1085,34 +1085,79 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map Value::String(parsed_payload.kind().to_string()), ); - if let AutomationPayload::BrowserSession { - profile_id, - profile_key, - url, - environment_preset_id, - target_id, - open_window, - stream_mode, - } = parsed_payload - { - metadata.insert("profile_id".to_string(), Value::String(profile_id)); - if let Some(profile_key) = profile_key { - metadata.insert("profile_key".to_string(), Value::String(profile_key)); + match parsed_payload { + AutomationPayload::AgentTurn { + request_metadata, + content_id, + .. + } => { + if let Some(content_id) = content_id.clone() { + metadata.insert("content_id".to_string(), Value::String(content_id)); + } + + let request_metadata = request_metadata.as_ref().and_then(Value::as_object); + if let Some(service_skill) = request_metadata.and_then(|value| { + value + .get("service_skill") + .or_else(|| value.get("serviceSkill")) + }) { + metadata.insert("service_skill".to_string(), service_skill.clone()); + } + + let harness = request_metadata + .and_then(|value| value.get("harness")) + .and_then(Value::as_object) + .map(|value| { + let mut next = value.clone(); + if let Some(content_id) = content_id.as_ref() { + if !next.contains_key("content_id") && !next.contains_key("contentId") { + next.insert( + "content_id".to_string(), + Value::String(content_id.clone()), + ); + } + } + Value::Object(next) + }) + .or_else(|| { + content_id.map(|value| { + json!({ + "content_id": value + }) + }) + }); + if let Some(harness) = harness { + metadata.insert("harness".to_string(), harness); + } } - if let Some(url) = url { - metadata.insert("url".to_string(), Value::String(url)); + AutomationPayload::BrowserSession { + profile_id, + profile_key, + url, + environment_preset_id, + target_id, + open_window, + stream_mode, + } => { + metadata.insert("profile_id".to_string(), Value::String(profile_id)); + if let Some(profile_key) = profile_key { + metadata.insert("profile_key".to_string(), Value::String(profile_key)); + } + if let Some(url) = url { + metadata.insert("url".to_string(), Value::String(url)); + } + if let Some(environment_preset_id) = environment_preset_id { + metadata.insert( + "environment_preset_id".to_string(), + Value::String(environment_preset_id), + ); + } + if let Some(target_id) = target_id { + metadata.insert("target_id".to_string(), Value::String(target_id)); + } + metadata.insert("open_window".to_string(), Value::Bool(open_window)); + metadata.insert("stream_mode".to_string(), json!(stream_mode)); } - if let Some(environment_preset_id) = environment_preset_id { - metadata.insert( - "environment_preset_id".to_string(), - Value::String(environment_preset_id), - ); - } - if let Some(target_id) = target_id { - metadata.insert("target_id".to_string(), Value::String(target_id)); - } - metadata.insert("open_window".to_string(), Value::Bool(open_window)); - metadata.insert("stream_mode".to_string(), json!(stream_mode)); } } @@ -1335,6 +1380,95 @@ mod tests { ); } + #[test] + fn build_tracker_finish_metadata_should_include_agent_turn_service_skill_context() { + let job = AutomationJob { + id: "job-1".to_string(), + name: "每日趋势摘要".to_string(), + description: Some("围绕指定平台输出趋势摘要".to_string()), + enabled: true, + workspace_id: "workspace-1".to_string(), + execution_mode: AutomationExecutionMode::Skill, + schedule: TaskSchedule::Cron { + expr: "0 9 * * *".to_string(), + tz: Some("Asia/Shanghai".to_string()), + }, + payload: json!({ + "kind": "agent_turn", + "prompt": "[服务型技能] 每日趋势摘要", + "web_search": false, + "content_id": "content-1", + "request_metadata": { + "service_skill": { + "id": "daily-trend-briefing", + "title": "每日趋势摘要", + "runner_type": "scheduled", + "execution_location": "client_default", + "source": "cloud_catalog", + "slot_values": [ + { + "key": "platform", + "label": "监测平台", + "value": "小红书" + } + ], + "user_input": "关注增长最快的话题" + }, + "harness": { + "theme": "social-media" + } + } + }), + delivery: DeliveryConfig::default(), + timeout_secs: None, + max_retries: 3, + next_run_at: None, + last_status: None, + last_error: None, + last_run_at: None, + last_finished_at: None, + running_started_at: None, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: None, + last_delivery: None, + created_at: "2026-03-15T00:00:00Z".to_string(), + updated_at: "2026-03-15T00:00:00Z".to_string(), + }; + + let metadata = build_tracker_finish_metadata( + &job, + Some("session-1"), + "success", + "success", + 0, + 1200, + None, + ); + + assert_eq!(metadata.get("content_id"), Some(&json!("content-1"))); + assert_eq!( + metadata.pointer("/service_skill/title"), + Some(&json!("每日趋势摘要")) + ); + assert_eq!( + metadata.pointer("/service_skill/slot_values/0/label"), + Some(&json!("监测平台")) + ); + assert_eq!( + metadata.pointer("/service_skill/user_input"), + Some(&json!("关注增长最快的话题")) + ); + assert_eq!( + metadata.pointer("/harness/theme"), + Some(&json!("social-media")) + ); + assert_eq!( + metadata.pointer("/harness/content_id"), + Some(&json!("content-1")) + ); + } + #[test] fn build_delivery_context_should_build_stable_attempt_id_without_run_id() { let job = AutomationJob { diff --git a/src-tauri/src/services/browser_environment_service.rs b/src-tauri/src/services/browser_environment_service.rs index 2f2152187..77e5af6c8 100644 --- a/src-tauri/src/services/browser_environment_service.rs +++ b/src-tauri/src/services/browser_environment_service.rs @@ -8,6 +8,21 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; const DEFAULT_CDP_TIMEOUT_MS: u64 = 10_000; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID: &str = "browser-environment-us-desktop"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME: &str = "美区桌面"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION: &str = "美国住宅代理 + 桌面视口"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER: &str = "http://127.0.0.1:7890"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID: &str = "America/Los_Angeles"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE: &str = "en-US"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT: &str = "Mozilla/5.0"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM: &str = "MacIntel"; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH: i64 = 1440; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT: i64 = 900; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR: f64 = 2.0; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT: f64 = 37.7749; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG: f64 = -122.4194; +const DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M: f64 = 100.0; #[derive(Debug, Clone)] pub struct SaveBrowserEnvironmentPresetInput { @@ -105,6 +120,37 @@ pub fn list_browser_environment_presets( .map_err(|error| format!("读取浏览器环境预设失败: {error}")) } +pub fn ensure_default_browser_environment_presets(conn: &Connection) -> Result { + let existing_presets = BrowserEnvironmentPresetDao::list(conn, true) + .map_err(|error| format!("读取浏览器环境预设失败: {error}"))?; + if !existing_presets.is_empty() { + return Ok(false); + } + + save_browser_environment_preset( + conn, + SaveBrowserEnvironmentPresetInput { + id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID.to_string()), + name: DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME.to_string(), + description: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DESCRIPTION.to_string()), + proxy_server: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER.to_string()), + timezone_id: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_TIMEZONE_ID.to_string()), + locale: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LOCALE.to_string()), + accept_language: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_ACCEPT_LANGUAGE.to_string()), + geolocation_lat: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LAT), + geolocation_lng: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_LNG), + geolocation_accuracy_m: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_GEO_ACCURACY_M), + user_agent: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_USER_AGENT.to_string()), + platform: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PLATFORM.to_string()), + viewport_width: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_WIDTH), + viewport_height: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_VIEWPORT_HEIGHT), + device_scale_factor: Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_DEVICE_SCALE_FACTOR), + }, + )?; + + Ok(true) +} + pub fn get_browser_environment_preset( conn: &Connection, id: &str, @@ -405,6 +451,14 @@ fn extract_runtime_value(response: Value) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::database::schema::create_tables; + use rusqlite::Connection; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建数据表失败"); + conn + } #[test] fn should_require_complete_geolocation_pair() { @@ -441,4 +495,54 @@ mod tests { let error = normalize_viewport(Some(1440), None).unwrap_err(); assert!(error.contains("必须同时填写")); } + + #[test] + fn should_seed_default_environment_preset_for_empty_table() { + let conn = setup_db(); + + let seeded = ensure_default_browser_environment_presets(&conn).unwrap(); + let presets = list_browser_environment_presets(&conn, false).unwrap(); + + assert!(seeded); + assert_eq!(presets.len(), 1); + assert_eq!(presets[0].id, DEFAULT_BROWSER_ENVIRONMENT_PRESET_ID); + assert_eq!(presets[0].name, DEFAULT_BROWSER_ENVIRONMENT_PRESET_NAME); + assert_eq!( + presets[0].proxy_server.as_deref(), + Some(DEFAULT_BROWSER_ENVIRONMENT_PRESET_PROXY_SERVER) + ); + } + + #[test] + fn should_not_seed_default_environment_preset_when_table_has_records() { + let conn = setup_db(); + save_browser_environment_preset( + &conn, + SaveBrowserEnvironmentPresetInput { + id: None, + name: "日本桌面".to_string(), + description: None, + proxy_server: None, + timezone_id: Some("Asia/Tokyo".to_string()), + locale: Some("ja-JP".to_string()), + accept_language: Some("ja-JP,ja;q=0.9".to_string()), + geolocation_lat: None, + geolocation_lng: None, + geolocation_accuracy_m: None, + user_agent: None, + platform: Some("MacIntel".to_string()), + viewport_width: Some(1440), + viewport_height: Some(900), + device_scale_factor: Some(2.0), + }, + ) + .unwrap(); + + let seeded = ensure_default_browser_environment_presets(&conn).unwrap(); + let presets = list_browser_environment_presets(&conn, false).unwrap(); + + assert!(!seeded); + assert_eq!(presets.len(), 1); + assert_eq!(presets[0].name, "日本桌面"); + } } diff --git a/src-tauri/src/services/browser_profile_service.rs b/src-tauri/src/services/browser_profile_service.rs index 1bfd7c786..c7889c8f2 100644 --- a/src-tauri/src/services/browser_profile_service.rs +++ b/src-tauri/src/services/browser_profile_service.rs @@ -7,6 +7,12 @@ use lime_core::database::dao::browser_profile::{ use rusqlite::Connection; use url::Url; +const DEFAULT_BROWSER_PROFILE_KEY: &str = "general_browser_assist"; +const DEFAULT_BROWSER_PROFILE_NAME: &str = "通用浏览器资料"; +const DEFAULT_BROWSER_PROFILE_DESCRIPTION: &str = "默认浏览器协助资料"; +const DEFAULT_BROWSER_PROFILE_SITE_SCOPE: &str = "通用"; +const DEFAULT_BROWSER_PROFILE_LAUNCH_URL: &str = "https://www.google.com/"; + #[derive(Debug, Clone)] pub struct SaveBrowserProfileInput { pub id: Option, @@ -63,6 +69,29 @@ pub fn list_browser_profiles( .map_err(|error| format!("读取浏览器资料失败: {error}")) } +pub fn ensure_default_browser_profiles(conn: &Connection) -> Result { + let existing_profiles = BrowserProfileDao::list(conn, true) + .map_err(|error| format!("读取浏览器资料失败: {error}"))?; + if !existing_profiles.is_empty() { + return Ok(false); + } + + save_browser_profile( + conn, + SaveBrowserProfileInput { + id: None, + profile_key: DEFAULT_BROWSER_PROFILE_KEY.to_string(), + name: DEFAULT_BROWSER_PROFILE_NAME.to_string(), + description: Some(DEFAULT_BROWSER_PROFILE_DESCRIPTION.to_string()), + site_scope: Some(DEFAULT_BROWSER_PROFILE_SITE_SCOPE.to_string()), + launch_url: Some(DEFAULT_BROWSER_PROFILE_LAUNCH_URL.to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + }, + )?; + + Ok(true) +} + pub fn get_browser_profile( conn: &Connection, id: &str, @@ -306,4 +335,46 @@ mod tests { assert_eq!(saved.profile_dir, ""); assert_eq!(saved.managed_profile_dir, None); } + + #[test] + fn should_seed_default_profile_for_empty_table() { + let conn = setup_db(); + + let seeded = ensure_default_browser_profiles(&conn).unwrap(); + let profiles = list_browser_profiles(&conn, false).unwrap(); + + assert!(seeded); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].profile_key, DEFAULT_BROWSER_PROFILE_KEY); + assert_eq!(profiles[0].name, DEFAULT_BROWSER_PROFILE_NAME); + assert_eq!( + profiles[0].transport_kind, + BrowserProfileTransportKind::ManagedCdp + ); + } + + #[test] + fn should_not_seed_default_profile_when_table_has_records() { + let conn = setup_db(); + save_browser_profile( + &conn, + SaveBrowserProfileInput { + id: None, + profile_key: "weibo_attach".to_string(), + name: "微博附着".to_string(), + description: Some("依赖当前 Chrome".to_string()), + site_scope: Some("weibo.com".to_string()), + launch_url: Some("https://weibo.com".to_string()), + transport_kind: BrowserProfileTransportKind::ExistingSession, + }, + ) + .unwrap(); + + let seeded = ensure_default_browser_profiles(&conn).unwrap(); + let profiles = list_browser_profiles(&conn, false).unwrap(); + + assert!(!seeded); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].profile_key, "weibo_attach"); + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 53118ef6d..72fa3e287 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -30,6 +30,11 @@ pub mod memory_source_resolver_service; pub mod novel_service; pub mod openclaw_service; pub mod runtime_agents_template_service; +pub mod runtime_analysis_handoff_service; +pub mod runtime_evidence_pack_service; +pub mod runtime_handoff_artifact_service; +pub mod runtime_replay_case_service; +pub mod runtime_review_decision_service; pub mod site_adapter_registry; pub mod site_capability_service; pub mod sysinfo_service; diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs new file mode 100644 index 000000000..2d6ddb95b --- /dev/null +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -0,0 +1,1142 @@ +//! Runtime analysis handoff 导出服务 +//! +//! 将 handoff bundle / evidence pack / replay case 重新包装成 +//! 外部 Claude Code / Codex 更容易直接消费的 analysis handoff。 +//! 这条链仍然只负责导出证据与现成提示词,不在 Lime 内自动分析或自动修复。 + +use crate::agent::SessionDetail; +use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::services::runtime_evidence_pack_service::{ + export_runtime_evidence_pack, RuntimeEvidenceArtifactKind, RuntimeEvidencePackExportResult, +}; +use crate::services::runtime_handoff_artifact_service::{ + export_runtime_handoff_bundle, RuntimeHandoffArtifactKind, RuntimeHandoffBundleExportResult, +}; +use crate::services::runtime_replay_case_service::{ + export_runtime_replay_case, RuntimeReplayArtifactKind, RuntimeReplayCaseExportResult, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions"; +const ANALYSIS_DIR_NAME: &str = "analysis"; +const ANALYSIS_BRIEF_FILE_NAME: &str = "analysis-brief.md"; +const ANALYSIS_CONTEXT_FILE_NAME: &str = "analysis-context.json"; +const DEFAULT_SANITIZED_WORKSPACE_ROOT: &str = "/workspace/lime"; +const MAX_EXCERPT_CHARS: usize = 1200; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeAnalysisArtifactKind { + AnalysisBrief, + AnalysisContext, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeAnalysisArtifact { + pub kind: RuntimeAnalysisArtifactKind, + pub title: String, + pub relative_path: String, + pub absolute_path: String, + pub bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeAnalysisHandoffExportResult { + pub session_id: String, + pub thread_id: String, + pub workspace_id: Option, + pub workspace_root: String, + pub analysis_relative_root: String, + pub analysis_absolute_root: String, + pub handoff_bundle_relative_root: String, + pub evidence_pack_relative_root: String, + pub replay_case_relative_root: String, + pub exported_at: String, + pub title: String, + pub thread_status: String, + pub latest_turn_status: Option, + pub pending_request_count: usize, + pub queued_turn_count: usize, + pub sanitized_workspace_root: String, + pub copy_prompt: String, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisContextDocument { + schema_version: String, + source: AnalysisContextSource, + title: String, + exported_at: String, + sanitized_workspace_root: String, + replay_root: String, + summary: AnalysisContextSummary, + replay: AnalysisReplaySection, + handoff: AnalysisHandoffSection, + evidence: AnalysisEvidenceSection, + reading_order: Vec, + external_analysis_contract: AnalysisExternalContract, + human_review_checklist: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisContextSource { + contract_shape: String, + derived_from: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisContextSummary { + session_id: String, + thread_id: String, + execution_strategy: String, + model: String, + goal_summary: String, + latest_turn_status: String, + thread_status: String, + primary_blocking_kind: String, + primary_blocking_summary: String, + failure_modes: Vec, + suite_tags: Vec, + pending_request_count: usize, + queued_turn_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisArtifactReference { + kind: String, + title: String, + relative_path: String, + absolute_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisReplaySection { + artifacts: Vec, + grader_excerpt: String, + input: Value, + expected: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisHandoffSection { + artifacts: Vec, + progress: Value, + handoff_excerpt: String, + review_summary_excerpt: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisEvidenceSection { + artifacts: Vec, + runtime: Value, + summary_excerpt: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalysisExternalContract { + audience: String, + task: String, + required_sections: Vec, + rules: Vec, +} + +pub fn export_runtime_analysis_handoff( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + workspace_root: &Path, +) -> Result { + let session_id = detail.id.trim(); + if session_id.is_empty() { + return Err("session_id 不能为空,无法导出 analysis handoff".to_string()); + } + + let thread_id = detail.thread_id.trim(); + if thread_id.is_empty() { + return Err("thread_id 不能为空,无法导出 analysis handoff".to_string()); + } + + let workspace_root = workspace_root + .canonicalize() + .unwrap_or_else(|_| workspace_root.to_path_buf()); + let exported_at = Utc::now().to_rfc3339(); + let analysis_relative_root = + format!("{SESSION_RELATIVE_ROOT}/{session_id}/{ANALYSIS_DIR_NAME}"); + let analysis_absolute_root = + workspace_root.join(analysis_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR)); + + let handoff_bundle = + export_runtime_handoff_bundle(detail, thread_read, workspace_root.as_path())?; + let evidence_pack = + export_runtime_evidence_pack(detail, thread_read, workspace_root.as_path())?; + let replay_case = export_runtime_replay_case(detail, thread_read, workspace_root.as_path())?; + + fs::create_dir_all(&analysis_absolute_root).map_err(|error| { + format!( + "创建 analysis handoff 目录失败 {}: {error}", + analysis_absolute_root.display() + ) + })?; + + let replay_root = PathBuf::from(&replay_case.replay_absolute_root); + let session_root = PathBuf::from(&handoff_bundle.bundle_absolute_root); + let evidence_root = PathBuf::from(&evidence_pack.pack_absolute_root); + + let input_payload = read_json_file(&replay_root.join("input.json"))?; + let expected_payload = read_json_file(&replay_root.join("expected.json"))?; + let progress_payload = read_json_file(&session_root.join("progress.json"))?; + let runtime_payload = read_json_file(&evidence_root.join("runtime.json"))?; + let grader_excerpt = truncate_text( + &read_text_file(&replay_root.join("grader.md"))?, + MAX_EXCERPT_CHARS, + ); + let handoff_excerpt = truncate_text( + &read_optional_text_file(&session_root.join("handoff.md"))?, + MAX_EXCERPT_CHARS, + ); + let review_summary_excerpt = truncate_text( + &read_optional_text_file(&session_root.join("review-summary.md"))?, + MAX_EXCERPT_CHARS, + ); + let evidence_summary_excerpt = truncate_text( + &read_optional_text_file(&evidence_root.join("summary.md"))?, + MAX_EXCERPT_CHARS, + ); + + let title = derive_title(&input_payload, session_id); + let failure_modes = value_string_list( + input_payload + .pointer("/classification/failureModes") + .unwrap_or(&Value::Null), + ); + let suite_tags = value_string_list( + input_payload + .pointer("/classification/suiteTags") + .unwrap_or(&Value::Null), + ); + let primary_blocking_kind = value_string( + input_payload + .pointer("/classification/primaryBlockingKind") + .unwrap_or(&Value::Null), + ) + .or_else(|| { + thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.primary_blocking_kind.clone())) + }) + .unwrap_or_default(); + let primary_blocking_summary = value_string( + input_payload + .pointer("/task/primaryBlockingSummary") + .unwrap_or(&Value::Null), + ) + .or_else(|| { + thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.primary_blocking_summary.clone())) + }) + .unwrap_or_default(); + + let replay_refs = replay_case + .artifacts + .iter() + .map(|artifact| AnalysisArtifactReference { + kind: replay_artifact_kind_key(&artifact.kind).to_string(), + title: artifact.title.clone(), + relative_path: artifact.relative_path.clone(), + absolute_path: sanitize_absolute_path_for_external_use( + Path::new(&artifact.absolute_path), + workspace_root.as_path(), + DEFAULT_SANITIZED_WORKSPACE_ROOT, + ), + }) + .collect::>(); + let handoff_refs = handoff_bundle + .artifacts + .iter() + .map(|artifact| AnalysisArtifactReference { + kind: handoff_artifact_kind_key(&artifact.kind).to_string(), + title: artifact.title.clone(), + relative_path: artifact.relative_path.clone(), + absolute_path: sanitize_absolute_path_for_external_use( + Path::new(&artifact.absolute_path), + workspace_root.as_path(), + DEFAULT_SANITIZED_WORKSPACE_ROOT, + ), + }) + .collect::>(); + let evidence_refs = evidence_pack + .artifacts + .iter() + .map(|artifact| AnalysisArtifactReference { + kind: evidence_artifact_kind_key(&artifact.kind).to_string(), + title: artifact.title.clone(), + relative_path: artifact.relative_path.clone(), + absolute_path: sanitize_absolute_path_for_external_use( + Path::new(&artifact.absolute_path), + workspace_root.as_path(), + DEFAULT_SANITIZED_WORKSPACE_ROOT, + ), + }) + .collect::>(); + + let summary = AnalysisContextSummary { + session_id: session_id.to_string(), + thread_id: thread_id.to_string(), + execution_strategy: value_string( + input_payload + .pointer("/session/executionStrategy") + .unwrap_or(&Value::Null), + ) + .or_else(|| normalize_optional_text(detail.execution_strategy.clone())) + .unwrap_or_default(), + model: value_string( + input_payload + .pointer("/session/model") + .unwrap_or(&Value::Null), + ) + .or_else(|| normalize_optional_text(detail.model.clone())) + .unwrap_or_default(), + goal_summary: value_string( + input_payload + .pointer("/task/goalSummary") + .unwrap_or(&Value::Null), + ) + .unwrap_or_default(), + latest_turn_status: value_string( + input_payload + .pointer("/task/latestTurnStatus") + .unwrap_or(&Value::Null), + ) + .or_else(|| { + thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())) + }) + .unwrap_or_default(), + thread_status: value_string( + input_payload + .pointer("/task/threadStatus") + .unwrap_or(&Value::Null), + ) + .unwrap_or_else(|| thread_read.status.clone()), + primary_blocking_kind, + primary_blocking_summary, + failure_modes: failure_modes.clone(), + suite_tags: suite_tags.clone(), + pending_request_count: thread_read.pending_requests.len(), + queued_turn_count: thread_read.queued_turns.len(), + }; + + let reading_order = build_reading_order(); + let review_checklist = + build_human_review_checklist(&input_payload, &expected_payload, &failure_modes); + let external_contract = build_external_analysis_contract(); + + let analysis_context = AnalysisContextDocument { + schema_version: "v1".to_string(), + source: AnalysisContextSource { + contract_shape: "lime_external_analysis_handoff".to_string(), + derived_from: vec![ + "lime_workspace_handoff_bundle".to_string(), + "lime_workspace_evidence_pack".to_string(), + "lime_runtime_export_replay_case".to_string(), + ], + }, + title: title.clone(), + exported_at: exported_at.clone(), + sanitized_workspace_root: DEFAULT_SANITIZED_WORKSPACE_ROOT.to_string(), + replay_root: sanitize_absolute_path_for_external_use( + replay_root.as_path(), + workspace_root.as_path(), + DEFAULT_SANITIZED_WORKSPACE_ROOT, + ), + summary: summary.clone(), + replay: AnalysisReplaySection { + artifacts: replay_refs.clone(), + grader_excerpt: sanitize_text(grader_excerpt, workspace_root.as_path()), + input: sanitize_value(input_payload, workspace_root.as_path()), + expected: sanitize_value(expected_payload, workspace_root.as_path()), + }, + handoff: AnalysisHandoffSection { + artifacts: handoff_refs.clone(), + progress: sanitize_value(progress_payload, workspace_root.as_path()), + handoff_excerpt: sanitize_text(handoff_excerpt, workspace_root.as_path()), + review_summary_excerpt: sanitize_text(review_summary_excerpt, workspace_root.as_path()), + }, + evidence: AnalysisEvidenceSection { + artifacts: evidence_refs.clone(), + runtime: sanitize_value(runtime_payload, workspace_root.as_path()), + summary_excerpt: sanitize_text(evidence_summary_excerpt, workspace_root.as_path()), + }, + reading_order: reading_order.clone(), + external_analysis_contract: external_contract.clone(), + human_review_checklist: review_checklist.clone(), + }; + + let analysis_brief = build_analysis_brief( + &title, + &exported_at, + &summary, + &replay_refs, + &handoff_refs, + &evidence_refs, + &reading_order, + &review_checklist, + &analysis_context.replay.grader_excerpt, + &analysis_context.handoff.handoff_excerpt, + &analysis_context.evidence.summary_excerpt, + ); + + let artifacts = vec![ + write_analysis_file( + &analysis_absolute_root, + session_id, + ANALYSIS_BRIEF_FILE_NAME, + RuntimeAnalysisArtifactKind::AnalysisBrief, + "外部分析简报", + analysis_brief, + )?, + write_analysis_file( + &analysis_absolute_root, + session_id, + ANALYSIS_CONTEXT_FILE_NAME, + RuntimeAnalysisArtifactKind::AnalysisContext, + "外部分析上下文", + format!( + "{}\n", + serde_json::to_string_pretty(&analysis_context) + .map_err(|error| format!("序列化 analysis context 失败: {error}"))? + ), + )?, + ]; + + let copy_prompt = build_copy_prompt( + &title, + &summary, + &artifacts, + &handoff_bundle, + &evidence_pack, + &replay_case, + ); + + Ok(RuntimeAnalysisHandoffExportResult { + session_id: session_id.to_string(), + thread_id: thread_id.to_string(), + workspace_id: normalize_optional_text(detail.workspace_id.clone()), + workspace_root: workspace_root.to_string_lossy().to_string(), + analysis_relative_root, + analysis_absolute_root: analysis_absolute_root.to_string_lossy().to_string(), + handoff_bundle_relative_root: handoff_bundle.bundle_relative_root, + evidence_pack_relative_root: evidence_pack.pack_relative_root, + replay_case_relative_root: replay_case.replay_relative_root, + exported_at, + title, + thread_status: thread_read.status.clone(), + latest_turn_status: thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())), + pending_request_count: thread_read.pending_requests.len(), + queued_turn_count: thread_read.queued_turns.len(), + sanitized_workspace_root: DEFAULT_SANITIZED_WORKSPACE_ROOT.to_string(), + copy_prompt, + artifacts, + }) +} + +fn build_analysis_brief( + title: &str, + exported_at: &str, + summary: &AnalysisContextSummary, + replay_refs: &[AnalysisArtifactReference], + handoff_refs: &[AnalysisArtifactReference], + evidence_refs: &[AnalysisArtifactReference], + reading_order: &[String], + review_checklist: &[String], + grader_excerpt: &str, + handoff_excerpt: &str, + evidence_excerpt: &str, +) -> String { + let mut lines = vec![ + "# 外部分析交接简报".to_string(), + String::new(), + format!("- 标题:{title}"), + format!("- 生成时间:{exported_at}"), + format!("- 会话:`{}`", summary.session_id), + format!("- 线程:`{}`", summary.thread_id), + format!( + "- 执行策略:{}", + empty_fallback(&summary.execution_strategy, "unknown") + ), + format!("- 模型:{}", empty_fallback(&summary.model, "unknown")), + String::new(), + "## 当前问题".to_string(), + String::new(), + format!( + "- 目标摘要:{}", + empty_fallback(&summary.goal_summary, "未知") + ), + format!( + "- 线程状态:{}", + empty_fallback(&summary.thread_status, "未知") + ), + format!( + "- 最新 turn 状态:{}", + empty_fallback(&summary.latest_turn_status, "未知") + ), + format!( + "- 主要阻塞:{}{}", + empty_fallback(&summary.primary_blocking_kind, "未知"), + if summary.primary_blocking_summary.is_empty() { + String::new() + } else { + format!(" · {}", summary.primary_blocking_summary) + } + ), + format!( + "- failure modes:{}", + join_or_fallback(&summary.failure_modes, "无") + ), + format!( + "- suite tags:{}", + join_or_fallback(&summary.suite_tags, "无") + ), + format!("- pending request:{}", summary.pending_request_count), + format!("- queued turn:{}", summary.queued_turn_count), + String::new(), + "## 推荐读取顺序".to_string(), + String::new(), + ]; + + for (index, item) in reading_order.iter().enumerate() { + lines.push(format!("{}. {}", index + 1, item)); + } + + lines.extend([String::new(), "## Replay 文件".to_string(), String::new()]); + lines.extend(render_artifact_lines(replay_refs)); + lines.extend([String::new(), "## Handoff 文件".to_string(), String::new()]); + lines.extend(render_artifact_lines(handoff_refs)); + lines.extend([String::new(), "## Evidence 文件".to_string(), String::new()]); + lines.extend(render_artifact_lines(evidence_refs)); + lines.extend([ + String::new(), + "## 可直接给外部 AI 的任务说明".to_string(), + String::new(), + "```text".to_string(), + "你将收到一个由 Lime 导出的 analysis handoff。你的职责是先诊断问题,再给出最小可执行修复方案;如果证据已足够明确,也可以直接在工作区内实施修复。".to_string(), + String::new(), + "请优先读取 analysis-context.json 与 analysis-brief.md,再按其中给出的 replay / handoff / evidence 顺序继续下钻。".to_string(), + String::new(), + "输出至少包含:".to_string(), + "- 结论".to_string(), + "- 根因判断".to_string(), + "- 关键证据".to_string(), + "- 修复建议".to_string(), + "- 如果已修改代码,列出改动与回归点".to_string(), + "- 风险与未知项".to_string(), + String::new(), + "约束:".to_string(), + "- 优先引用现有证据,不要假装看到不存在的信息。".to_string(), + "- 如果证据不足,明确写出缺口和需要人工确认的地方。".to_string(), + "- 不顺手扩大到无关重构。".to_string(), + "```".to_string(), + String::new(), + "## 人工审核检查清单".to_string(), + String::new(), + ]); + lines.extend(review_checklist.iter().map(|item| format!("- {item}"))); + lines.extend([ + String::new(), + "## 关键摘录".to_string(), + String::new(), + "### Replay Grader 摘录".to_string(), + String::new(), + empty_fallback(grader_excerpt, "当前无可用摘录。").to_string(), + String::new(), + "### Handoff 摘录".to_string(), + String::new(), + empty_fallback(handoff_excerpt, "当前无可用摘录。").to_string(), + String::new(), + "### Evidence 摘录".to_string(), + String::new(), + empty_fallback(evidence_excerpt, "当前无可用摘录。").to_string(), + String::new(), + "## 注意".to_string(), + String::new(), + format!( + "- 所有路径默认已按 `{DEFAULT_SANITIZED_WORKSPACE_ROOT}` 占位规则输出,便于外部 AI 消费。" + ), + "- 这份简报只负责分析交接,不负责 Lime 内部自动修复。".to_string(), + String::new(), + ]); + + format!("{}\n", lines.join("\n")) +} + +fn build_copy_prompt( + title: &str, + summary: &AnalysisContextSummary, + artifacts: &[RuntimeAnalysisArtifact], + handoff_bundle: &RuntimeHandoffBundleExportResult, + evidence_pack: &RuntimeEvidencePackExportResult, + replay_case: &RuntimeReplayCaseExportResult, +) -> String { + let analysis_brief_path = artifacts + .iter() + .find(|artifact| artifact.kind == RuntimeAnalysisArtifactKind::AnalysisBrief) + .map(|artifact| to_portable_path(artifact.absolute_path.as_str())) + .unwrap_or_default(); + let analysis_context_path = artifacts + .iter() + .find(|artifact| artifact.kind == RuntimeAnalysisArtifactKind::AnalysisContext) + .map(|artifact| to_portable_path(artifact.absolute_path.as_str())) + .unwrap_or_default(); + + let lines = vec![ + "# Lime 外部诊断与修复任务".to_string(), + String::new(), + "你现在位于一个可读写的 Lime 工作区。请不要向我继续追问额外上下文,直接基于现有证据先诊断问题,再给出最小修复方案;如果证据已经足够明确,也可以直接修改代码完成修复。".to_string(), + String::new(), + "请先读取下面两份文件:".to_string(), + format!("1. `{analysis_brief_path}`"), + format!("2. `{analysis_context_path}`"), + String::new(), + "如果需要继续下钻,再按 analysis brief 里的顺序读取 replay / handoff / evidence 文件。".to_string(), + String::new(), + "当前任务摘要:".to_string(), + format!("- 标题:{title}"), + format!("- 会话:`{}`", summary.session_id), + format!("- 线程:`{}`", summary.thread_id), + format!("- 线程状态:{}", empty_fallback(&summary.thread_status, "未知")), + format!( + "- 主要阻塞:{}{}", + empty_fallback(&summary.primary_blocking_kind, "未知"), + if summary.primary_blocking_summary.is_empty() { + String::new() + } else { + format!(" · {}", summary.primary_blocking_summary) + } + ), + format!("- Handoff 根目录:`{}`", to_portable_path(&handoff_bundle.bundle_absolute_root)), + format!("- Evidence 根目录:`{}`", to_portable_path(&evidence_pack.pack_absolute_root)), + format!("- Replay 根目录:`{}`", to_portable_path(&replay_case.replay_absolute_root)), + String::new(), + "输出要求:".to_string(), + "- 先给出结论与根因判断。".to_string(), + "- 明确引用关键证据文件,不要凭空推断。".to_string(), + "- 给出最小修复方案;如果已经修改代码,请列出改动点和原因。".to_string(), + "- 给出回归建议、风险与未知项。".to_string(), + String::new(), + "约束:".to_string(), + "- 优先做最小修复,不顺手扩大到无关重构。".to_string(), + "- 如果证据不足,明确列出缺口。".to_string(), + "- 最终是否接受修复仍由人工审核决定。".to_string(), + String::new(), + ]; + + format!("{}\n", lines.join("\n")) +} + +fn build_reading_order() -> Vec { + vec![ + "先读 replay/input.json 与 replay/expected.json,确认任务目标与判定标准。".to_string(), + "再读 handoff/handoff.md 与 handoff/progress.json,确认当前状态、待继续事项与恢复顺序。" + .to_string(), + "再读 evidence/summary.md 与 evidence/runtime.json,确认当前阻塞、pending request 与 diagnostics。" + .to_string(), + "如需复盘过程,再读 evidence/timeline.json。".to_string(), + "最后回看 replay/grader.md,按约定输出根因、修复建议、回归建议与风险项。" + .to_string(), + ] +} + +fn build_external_analysis_contract() -> AnalysisExternalContract { + AnalysisExternalContract { + audience: "Claude Code / Codex".to_string(), + task: "基于 Lime 导出的结构化证据做问题分析与修复建议,不直接代替团队做最终决策。" + .to_string(), + required_sections: vec![ + "结论".to_string(), + "根因判断".to_string(), + "关键证据".to_string(), + "修复建议".to_string(), + "回归建议".to_string(), + "风险与未知项".to_string(), + ], + rules: vec![ + "优先引用现有证据文件,不要求重建完整会话。".to_string(), + "如果证据不足,显式列出缺口,不要假装已经确认。".to_string(), + "只给分析与建议,不直接替团队批准或拒绝修复方案。".to_string(), + "如果怀疑路径、凭证或外部系统状态影响结论,先标注为待人工复核。".to_string(), + ], + } +} + +fn build_human_review_checklist( + input_payload: &Value, + expected_payload: &Value, + failure_modes: &[String], +) -> Vec { + let mut checklist = vec![ + "确认外部 AI 是否引用了现有证据,而不是凭空推断。".to_string(), + "确认修复建议是否直接服务当前失败模式,而不是顺手扩大范围。".to_string(), + "确认回归建议是否能沉淀为 replay / eval / smoke,而不是停留在口头建议。".to_string(), + ]; + + let requires_human_review = expected_payload + .pointer("/graderSuggestion/requiresHumanReview") + .and_then(Value::as_bool) + .unwrap_or(false); + if requires_human_review { + checklist.insert( + 0, + "当前样本本来就要求人工复核,不应把外部 AI 结论当成最终裁决。".to_string(), + ); + } + + if failure_modes.iter().any(|mode| mode == "pending_request") + || !value_array( + input_payload + .pointer("/runtimeContext/pendingRequests") + .unwrap_or(&Value::Null), + ) + .is_empty() + { + checklist.push("确认外部 AI 没有把 pending request 误判成已完成。".to_string()); + } + + checklist +} + +fn handoff_artifact_kind_key(kind: &RuntimeHandoffArtifactKind) -> &'static str { + match kind { + RuntimeHandoffArtifactKind::Plan => "plan", + RuntimeHandoffArtifactKind::Progress => "progress", + RuntimeHandoffArtifactKind::Handoff => "handoff", + RuntimeHandoffArtifactKind::ReviewSummary => "review_summary", + } +} + +fn evidence_artifact_kind_key(kind: &RuntimeEvidenceArtifactKind) -> &'static str { + match kind { + RuntimeEvidenceArtifactKind::Summary => "summary", + RuntimeEvidenceArtifactKind::Runtime => "runtime", + RuntimeEvidenceArtifactKind::Timeline => "timeline", + RuntimeEvidenceArtifactKind::Artifacts => "artifacts", + } +} + +fn replay_artifact_kind_key(kind: &RuntimeReplayArtifactKind) -> &'static str { + match kind { + RuntimeReplayArtifactKind::Input => "input", + RuntimeReplayArtifactKind::Expected => "expected", + RuntimeReplayArtifactKind::Grader => "grader", + RuntimeReplayArtifactKind::EvidenceLinks => "evidence_links", + } +} + +fn write_analysis_file( + analysis_root: &Path, + session_id: &str, + file_name: &str, + kind: RuntimeAnalysisArtifactKind, + title: &str, + content: String, +) -> Result { + let absolute_path = analysis_root.join(file_name); + fs::write(&absolute_path, content.as_bytes()).map_err(|error| { + format!( + "写入 analysis handoff 文件失败 {}: {error}", + absolute_path.display() + ) + })?; + + Ok(RuntimeAnalysisArtifact { + kind, + title: title.to_string(), + relative_path: format!( + "{SESSION_RELATIVE_ROOT}/{session_id}/{ANALYSIS_DIR_NAME}/{file_name}" + ), + absolute_path: absolute_path.to_string_lossy().to_string(), + bytes: content.len(), + }) +} + +fn derive_title(input_payload: &Value, session_id: &str) -> String { + value_string( + input_payload + .pointer("/task/goalSummary") + .unwrap_or(&Value::Null), + ) + .unwrap_or_else(|| format!("外部分析交接 / {session_id}")) +} + +fn read_json_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 JSON 文件失败 {}: {error}", path.display()))?; + serde_json::from_str(&content) + .map_err(|error| format!("解析 JSON 文件失败 {}: {error}", path.display())) +} + +fn read_text_file(path: &Path) -> Result { + fs::read_to_string(path) + .map_err(|error| format!("读取文本文件失败 {}: {error}", path.display())) +} + +fn read_optional_text_file(path: &Path) -> Result { + if !path.exists() { + return Ok(String::new()); + } + read_text_file(path) +} + +fn sanitize_value(value: Value, workspace_root: &Path) -> Value { + match value { + Value::String(text) => Value::String(sanitize_text(text, workspace_root)), + Value::Array(values) => Value::Array( + values + .into_iter() + .map(|entry| sanitize_value(entry, workspace_root)) + .collect(), + ), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(key, entry)| (key, sanitize_value(entry, workspace_root))) + .collect(), + ), + other => other, + } +} + +fn sanitize_text(text: String, workspace_root: &Path) -> String { + replace_workspace_root_in_string(text, workspace_root, DEFAULT_SANITIZED_WORKSPACE_ROOT) +} + +fn replace_workspace_root_in_string( + text: String, + workspace_root: &Path, + placeholder: &str, +) -> String { + if text.is_empty() { + return text; + } + + let raw_root = workspace_root.to_string_lossy().to_string(); + let portable_root = to_portable_path(&raw_root); + let mut next = text.replace(raw_root.as_str(), placeholder); + if portable_root != raw_root { + next = next.replace(portable_root.as_str(), placeholder); + } + if next.contains(placeholder) { + next = next.replace('\\', "/"); + } + next +} + +fn sanitize_absolute_path_for_external_use( + absolute_path: &Path, + workspace_root: &Path, + placeholder: &str, +) -> String { + match absolute_path.strip_prefix(workspace_root) { + Ok(relative_path) => to_portable_path( + Path::new(placeholder) + .join(relative_path) + .to_string_lossy() + .as_ref(), + ), + Err(_) => String::new(), + } +} + +fn truncate_text(text: &str, max_chars: usize) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + + trimmed.chars().take(max_chars).collect::() + "…" +} + +fn render_artifact_lines(entries: &[AnalysisArtifactReference]) -> Vec { + if entries.is_empty() { + return vec!["- 当前未检测到可用文件。".to_string()]; + } + + entries + .iter() + .map(|entry| { + if entry.absolute_path.is_empty() { + format!("- `{}`", entry.relative_path) + } else { + format!("- `{}` ({})", entry.relative_path, entry.absolute_path) + } + }) + .collect() +} + +fn value_string(value: &Value) -> Option { + value + .as_str() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn value_string_list(value: &Value) -> Vec { + match value { + Value::Array(values) => values.iter().filter_map(value_string).collect(), + _ => Vec::new(), + } +} + +fn value_array(value: &Value) -> Vec { + value.as_array().cloned().unwrap_or_default() +} + +fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn join_or_fallback(values: &[String], fallback: &str) -> String { + if values.is_empty() { + fallback.to_string() + } else { + values.join(", ") + } +} + +fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str { + if value.trim().is_empty() { + fallback + } else { + value + } +} + +fn to_portable_path(value: &str) -> String { + value.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::QueuedTurnSnapshot; + use crate::commands::aster_agent_cmd::{ + AgentRuntimeDiagnosticPendingRequestSample, AgentRuntimeRequestView, + AgentRuntimeThreadDiagnostics, AgentRuntimeThreadReadModel, + }; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, + AgentThreadTurnStatus, + }; + use serde_json::json; + use tempfile::TempDir; + + fn build_detail() -> SessionDetail { + SessionDetail { + id: "session-1".to_string(), + thread_id: "thread-1".to_string(), + workspace_id: Some("workspace-1".to_string()), + name: "Harness Demo".to_string(), + model: Some("gpt-5.4".to_string()), + working_dir: Some("/tmp/workspace".to_string()), + created_at: 1, + updated_at: 2, + execution_strategy: Some("react".to_string()), + messages: Vec::new(), + execution_runtime: None, + turns: vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "请把当前 pending request 会话导成分析交接包。".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-27T10:00:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + error_message: None, + created_at: "2026-03-27T10:00:00Z".to_string(), + updated_at: "2026-03-27T10:01:00Z".to_string(), + }], + items: vec![ + AgentThreadItem { + id: "item-plan-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:10Z".to_string(), + completed_at: Some("2026-03-27T10:00:10Z".to_string()), + updated_at: "2026-03-27T10:00:10Z".to_string(), + payload: AgentThreadItemPayload::Plan { + text: "补 analysis handoff GUI 入口".to_string(), + }, + }, + AgentThreadItem { + id: "item-artifact-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + 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: ".lime/artifacts/thread-1/analysis-gap.md".to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }, + AgentThreadItem { + id: "item-summary-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:01:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + updated_at: "2026-03-27T10:01:00Z".to_string(), + payload: AgentThreadItemPayload::TurnSummary { + text: "已完成 handoff / evidence / replay,下一步把问题交给外部 AI 诊断并修复。" + .to_string(), + }, + }, + ], + todo_items: vec![lime_agent::SessionTodoItem { + content: "补分析交接 GUI 入口".to_string(), + status: serde_json::from_value(json!("in_progress")).expect("status"), + active_form: None, + }], + child_subagent_sessions: vec![], + subagent_parent_context: None, + } + } + + fn build_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "waiting_request".to_string(), + active_turn_id: Some("turn-1".to_string()), + pending_requests: vec![AgentRuntimeRequestView { + id: "req-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: Some("request-1".to_string()), + request_type: "approval_request".to_string(), + status: "pending".to_string(), + title: Some("允许写入 analysis 目录".to_string()), + payload: None, + decision: None, + scope: None, + created_at: None, + resolved_at: None, + }], + last_outcome: None, + incidents: Vec::new(), + queued_turns: vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "继续补 HarnessStatusPanel".to_string(), + message_text: "继续补 HarnessStatusPanel".to_string(), + created_at: 3, + image_count: 0, + position: 1, + }], + interrupt_state: None, + updated_at: Some("2026-03-27T10:01:20Z".to_string()), + diagnostics: Some(AgentRuntimeThreadDiagnostics { + latest_turn_status: Some("action_required".to_string()), + latest_turn_started_at: None, + latest_turn_completed_at: None, + latest_turn_updated_at: None, + latest_turn_elapsed_seconds: None, + latest_turn_stalled_seconds: None, + latest_turn_error_message: None, + interrupt_reason: None, + runtime_interrupt_source: None, + runtime_interrupt_requested_at: None, + runtime_interrupt_wait_seconds: None, + warning_count: 0, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + oldest_pending_request_wait_seconds: None, + primary_blocking_kind: Some("pending_request".to_string()), + primary_blocking_summary: Some("等待用户确认 analysis 导出".to_string()), + latest_warning: None, + latest_context_compaction: None, + latest_failed_tool: None, + latest_failed_command: None, + latest_pending_request: Some(AgentRuntimeDiagnosticPendingRequestSample { + request_id: "req-1".to_string(), + turn_id: Some("turn-1".to_string()), + request_type: "approval_request".to_string(), + title: Some("允许写入 analysis 目录".to_string()), + waited_seconds: Some(10), + created_at: None, + }), + }), + } + } + + #[test] + fn should_export_runtime_analysis_handoff_to_workspace() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + + let result = export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + assert_eq!( + result.analysis_relative_root, + ".lime/harness/sessions/session-1/analysis" + ); + assert_eq!(result.artifacts.len(), 2); + assert_eq!(result.pending_request_count, 1); + assert!(result.copy_prompt.contains("外部诊断与修复任务")); + assert!(result.copy_prompt.contains("analysis-brief.md")); + assert!(result.copy_prompt.contains("analysis-context.json")); + + 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"); + + assert!(brief_path.exists()); + assert!(context_path.exists()); + + let brief = fs::read_to_string(brief_path).expect("brief"); + assert!(brief.contains("外部分析交接简报")); + assert!(brief.contains("pending request:1")); + assert!(brief.contains("/workspace/lime")); + + let context = fs::read_to_string(context_path).expect("context"); + assert!(context.contains("\"schemaVersion\": \"v1\"")); + assert!(context.contains("\"contractShape\": \"lime_external_analysis_handoff\"")); + assert!(context.contains("\"pendingRequestCount\": 1")); + assert!(context.contains("/workspace/lime")); + assert!(!context.contains(temp_dir.path().to_string_lossy().as_ref())); + } +} diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs new file mode 100644 index 000000000..d10ee4c3e --- /dev/null +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -0,0 +1,740 @@ +//! Runtime evidence pack 导出服务 +//! +//! 将当前 Lime 会话的 runtime / timeline / artifact 事实, +//! 导出为最小可复盘的问题证据包。 + +use crate::agent::SessionDetail; +use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use chrono::Utc; +use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions"; +const EVIDENCE_DIR_NAME: &str = "evidence"; +const SUMMARY_FILE_NAME: &str = "summary.md"; +const RUNTIME_FILE_NAME: &str = "runtime.json"; +const TIMELINE_FILE_NAME: &str = "timeline.json"; +const ARTIFACTS_FILE_NAME: &str = "artifacts.json"; +const MAX_RECENT_ARTIFACTS: usize = 12; +const MAX_PREVIEW_CHARS: usize = 200; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeEvidenceArtifactKind { + Summary, + Runtime, + Timeline, + Artifacts, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEvidenceArtifact { + pub kind: RuntimeEvidenceArtifactKind, + pub title: String, + pub relative_path: String, + pub absolute_path: String, + pub bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEvidencePackExportResult { + pub session_id: String, + pub thread_id: String, + pub workspace_id: Option, + pub workspace_root: String, + pub pack_relative_root: String, + pub pack_absolute_root: String, + pub exported_at: String, + pub thread_status: String, + pub latest_turn_status: Option, + pub turn_count: usize, + pub item_count: usize, + pub pending_request_count: usize, + pub queued_turn_count: usize, + pub recent_artifact_count: usize, + pub known_gaps: Vec, + pub artifacts: Vec, +} + +pub fn export_runtime_evidence_pack( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + workspace_root: &Path, +) -> Result { + let session_id = detail.id.trim(); + if session_id.is_empty() { + return Err("session_id 不能为空,无法导出问题证据包".to_string()); + } + + let thread_id = detail.thread_id.trim(); + if thread_id.is_empty() { + return Err("thread_id 不能为空,无法导出问题证据包".to_string()); + } + + let workspace_root = workspace_root + .canonicalize() + .unwrap_or_else(|_| workspace_root.to_path_buf()); + let exported_at = Utc::now().to_rfc3339(); + let pack_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}"); + let pack_absolute_root = + workspace_root.join(pack_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR)); + + fs::create_dir_all(&pack_absolute_root).map_err(|error| { + format!( + "创建 evidence pack 目录失败 {}: {error}", + pack_absolute_root.display() + ) + })?; + + let recent_artifacts = collect_recent_artifact_paths(detail); + let latest_turn_summary = collect_latest_turn_summary(detail); + let known_gaps = build_known_gaps(&recent_artifacts); + + let artifacts = vec![ + write_evidence_file( + &pack_absolute_root, + session_id, + SUMMARY_FILE_NAME, + RuntimeEvidenceArtifactKind::Summary, + "问题摘要", + build_summary_markdown( + detail, + thread_read, + &recent_artifacts, + latest_turn_summary.as_deref(), + &known_gaps, + exported_at.as_str(), + ), + )?, + write_evidence_file( + &pack_absolute_root, + session_id, + RUNTIME_FILE_NAME, + RuntimeEvidenceArtifactKind::Runtime, + "运行时快照", + build_runtime_json( + detail, + thread_read, + workspace_root.as_path(), + &recent_artifacts, + &known_gaps, + exported_at.as_str(), + )?, + )?, + write_evidence_file( + &pack_absolute_root, + session_id, + TIMELINE_FILE_NAME, + RuntimeEvidenceArtifactKind::Timeline, + "时间线快照", + build_timeline_json(detail, exported_at.as_str())?, + )?, + write_evidence_file( + &pack_absolute_root, + session_id, + ARTIFACTS_FILE_NAME, + RuntimeEvidenceArtifactKind::Artifacts, + "产物与验证线索", + build_artifacts_json( + detail, + thread_read, + &recent_artifacts, + &known_gaps, + exported_at.as_str(), + )?, + )?, + ]; + + Ok(RuntimeEvidencePackExportResult { + session_id: session_id.to_string(), + thread_id: thread_id.to_string(), + workspace_id: normalize_optional_text(detail.workspace_id.clone()), + workspace_root: workspace_root.to_string_lossy().to_string(), + pack_relative_root, + pack_absolute_root: pack_absolute_root.to_string_lossy().to_string(), + exported_at, + thread_status: thread_read.status.trim().to_string(), + latest_turn_status: thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())), + turn_count: detail.turns.len(), + item_count: detail.items.len(), + pending_request_count: thread_read.pending_requests.len(), + queued_turn_count: thread_read.queued_turns.len(), + recent_artifact_count: recent_artifacts.len(), + known_gaps, + artifacts, + }) +} + +fn write_evidence_file( + pack_root: &Path, + session_id: &str, + file_name: &str, + kind: RuntimeEvidenceArtifactKind, + title: &str, + content: String, +) -> Result { + let absolute_path = pack_root.join(file_name); + fs::write(&absolute_path, content.as_bytes()).map_err(|error| { + format!( + "写入 evidence pack 文件失败 {}: {error}", + absolute_path.display() + ) + })?; + + Ok(RuntimeEvidenceArtifact { + kind, + title: title.to_string(), + relative_path: format!( + "{SESSION_RELATIVE_ROOT}/{session_id}/{EVIDENCE_DIR_NAME}/{file_name}" + ), + absolute_path: absolute_path.to_string_lossy().to_string(), + bytes: content.len(), + }) +} + +fn build_summary_markdown( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + recent_artifacts: &[String], + latest_turn_summary: Option<&str>, + known_gaps: &[String], + exported_at: &str, +) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# 问题证据包"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "> 当前证据包继续沿用 Codex 的结构化交接思路,运行时事实承接 Aster 的 session / thread / diagnostics,最终制品由 Lime 落盘到工作区。" + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- 会话:`{}`", detail.id); + let _ = writeln!(markdown, "- 线程:`{}`", detail.thread_id); + let _ = writeln!(markdown, "- 导出时间:{exported_at}"); + let _ = writeln!(markdown, "- 线程状态:{}", thread_read.status); + let _ = writeln!( + markdown, + "- Pending request:{} · 排队 turn:{}", + thread_read.pending_requests.len(), + thread_read.queued_turns.len() + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 最近摘要"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "{}", + latest_turn_summary + .unwrap_or("当前没有结构化 turn summary,请先读 runtime.json 与 timeline.json。") + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 证据概览"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- Turns:{}", detail.turns.len()); + let _ = writeln!(markdown, "- Timeline items:{}", detail.items.len()); + let _ = writeln!(markdown, "- 最近产物:{}", recent_artifacts.len()); + if let Some(blocking_summary) = thread_read + .diagnostics + .as_ref() + .and_then(|value| value.primary_blocking_summary.clone()) + { + let _ = writeln!(markdown, "- 当前主要阻塞:{blocking_summary}"); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 建议读取顺序"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "1. 先读 `summary.md`,确认会话状态和当前阻塞。"); + let _ = writeln!( + markdown, + "2. 再读 `runtime.json`,查看 pending request / queued turn / diagnostics。" + ); + let _ = writeln!( + markdown, + "3. 再读 `timeline.json`,回放最近 turns 与 items。" + ); + let _ = writeln!( + markdown, + "4. 最后读 `artifacts.json`,确认最近产物与当前证据缺口。" + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 已知缺口"); + let _ = writeln!(markdown); + for gap in known_gaps { + let _ = writeln!(markdown, "- {gap}"); + } + + markdown +} + +fn build_runtime_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + workspace_root: &Path, + recent_artifacts: &[String], + known_gaps: &[String], + exported_at: &str, +) -> Result { + let payload = json!({ + "schemaVersion": "v1", + "source": { + "contractShape": "codex_trace_evidence_pack", + "runtimeSubstrate": "aster_session_thread_runtime", + "productSurface": "lime_workspace_evidence_pack" + }, + "session": { + "sessionId": detail.id, + "threadId": detail.thread_id, + "name": detail.name, + "workspaceId": detail.workspace_id, + "workspaceRoot": workspace_root.to_string_lossy().to_string(), + "exportedAt": exported_at, + "updatedAt": detail.updated_at, + "executionStrategy": detail.execution_strategy, + "model": detail.model + }, + "thread": { + "status": thread_read.status, + "activeTurnId": thread_read.active_turn_id, + "interruptState": thread_read.interrupt_state, + "latestTurnStatus": thread_read.diagnostics.as_ref().and_then(|value| value.latest_turn_status.clone()), + "pendingRequestCount": thread_read.pending_requests.len(), + "queuedTurnCount": thread_read.queued_turns.len(), + "diagnostics": { + "warningCount": thread_read.diagnostics.as_ref().map(|value| value.warning_count).unwrap_or(0), + "contextCompactionCount": thread_read.diagnostics.as_ref().map(|value| value.context_compaction_count).unwrap_or(0), + "failedToolCallCount": thread_read.diagnostics.as_ref().map(|value| value.failed_tool_call_count).unwrap_or(0), + "failedCommandCount": thread_read.diagnostics.as_ref().map(|value| value.failed_command_count).unwrap_or(0), + "primaryBlockingKind": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_kind.clone()), + "primaryBlockingSummary": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_summary.clone()), + "latestWarning": thread_read.diagnostics.as_ref().and_then(|value| value.latest_warning.as_ref().map(|warning| json!({ + "code": warning.code, + "message": warning.message, + "updatedAt": warning.updated_at + }))), + "latestFailedTool": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_tool.as_ref().map(|tool| json!({ + "toolName": tool.tool_name, + "error": tool.error, + "updatedAt": tool.updated_at + }))), + "latestFailedCommand": thread_read.diagnostics.as_ref().and_then(|value| value.latest_failed_command.as_ref().map(|command| json!({ + "command": command.command, + "exitCode": command.exit_code, + "error": command.error, + "updatedAt": command.updated_at + }))) + } + }, + "pendingRequests": thread_read.pending_requests.iter().map(|item| { + json!({ + "id": item.id, + "type": item.request_type, + "status": item.status, + "title": item.title, + "turnId": item.turn_id + }) + }).collect::>(), + "queuedTurns": thread_read.queued_turns.iter().map(|item| { + json!({ + "id": item.queued_turn_id, + "position": item.position, + "preview": item.message_preview, + "createdAt": item.created_at + }) + }).collect::>(), + "subagents": detail.child_subagent_sessions.iter().map(|session| { + json!({ + "id": session.id, + "name": session.name, + "runtimeStatus": session.runtime_status, + "latestTurnStatus": session.latest_turn_status, + "taskSummary": session.task_summary, + "roleHint": session.role_hint, + "updatedAt": session.updated_at + }) + }).collect::>(), + "recentArtifacts": recent_artifacts, + "knownGaps": known_gaps + }); + + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 runtime.json 失败: {error}")) +} + +fn build_timeline_json(detail: &SessionDetail, exported_at: &str) -> Result { + let payload = json!({ + "schemaVersion": "v1", + "exportedAt": exported_at, + "turns": detail.turns.iter().map(|turn| { + json!({ + "id": turn.id, + "status": serialize_enum_as_string(&turn.status, "unknown"), + "promptPreview": truncate_text(turn.prompt_text.as_str()), + "startedAt": turn.started_at, + "completedAt": turn.completed_at, + "updatedAt": turn.updated_at + }) + }).collect::>(), + "items": detail.items.iter().map(|item| { + let (payload_kind, payload_summary) = summarize_item_payload(&item.payload); + json!({ + "id": item.id, + "turnId": item.turn_id, + "sequence": item.sequence, + "status": serialize_enum_as_string(&item.status, "unknown"), + "payloadKind": payload_kind, + "payloadSummary": payload_summary, + "updatedAt": item.updated_at + }) + }).collect::>() + }); + + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 timeline.json 失败: {error}")) +} + +fn build_artifacts_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + recent_artifacts: &[String], + known_gaps: &[String], + exported_at: &str, +) -> Result { + let payload = json!({ + "schemaVersion": "v1", + "exportedAt": exported_at, + "recentArtifacts": recent_artifacts, + "artifactCount": recent_artifacts.len(), + "verification": { + "artifactValidatorIssues": [], + "browserEvidence": [], + "guiSmoke": null + }, + "requests": { + "pending": thread_read.pending_requests.iter().map(|item| { + json!({ + "id": item.id, + "type": item.request_type, + "title": item.title, + "status": item.status + }) + }).collect::>(), + "knownGap": "provider request token / retry 摘要尚未接入当前 evidence pack" + }, + "workspace": { + "workspaceId": detail.workspace_id, + "workingDir": detail.working_dir + }, + "knownGaps": known_gaps + }); + + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 artifacts.json 失败: {error}")) +} + +fn build_known_gaps(recent_artifacts: &[String]) -> Vec { + let mut gaps = vec![ + "当前 Evidence Pack 尚未纳入 provider 请求级 token / retry / duration 摘要。".to_string(), + "当前 Evidence Pack 尚未纳入 GUI smoke / browser 验证结果。".to_string(), + ]; + + if recent_artifacts.is_empty() { + gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string()); + } + + gaps +} + +fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { + detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + AgentThreadItemPayload::TurnSummary { text } => { + normalize_optional_text(Some(text.clone())) + } + _ => None, + }) +} + +fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut paths = Vec::new(); + + for item in detail.items.iter().rev() { + let Some(path) = (match &item.payload { + AgentThreadItemPayload::FileArtifact { path, .. } => { + normalize_optional_text(Some(path.clone())) + } + _ => None, + }) else { + continue; + }; + + if seen.insert(path.clone()) { + paths.push(path); + } + if paths.len() >= MAX_RECENT_ARTIFACTS { + break; + } + } + + paths +} + +fn summarize_item_payload(payload: &AgentThreadItemPayload) -> (&'static str, Option) { + match payload { + AgentThreadItemPayload::Plan { text } => { + ("plan", normalize_optional_text(Some(truncate_text(text)))) + } + AgentThreadItemPayload::TurnSummary { text } => ( + "turn_summary", + normalize_optional_text(Some(truncate_text(text))), + ), + AgentThreadItemPayload::FileArtifact { path, .. } => { + ("file_artifact", normalize_optional_text(Some(path.clone()))) + } + _ => ("other", None), + } +} + +fn truncate_text(value: &str) -> String { + let normalized = value.trim(); + if normalized.chars().count() <= MAX_PREVIEW_CHARS { + return normalized.to_string(); + } + + normalized + .chars() + .take(MAX_PREVIEW_CHARS) + .collect::() + + "..." +} + +fn serialize_enum_as_string(value: &T, fallback: &str) -> String { + serde_json::to_value(value) + .ok() + .and_then(|item| item.as_str().map(str::to_string)) + .unwrap_or_else(|| fallback.to_string()) +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::QueuedTurnSnapshot; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, + AgentThreadTurnStatus, + }; + use tempfile::TempDir; + + fn build_detail() -> SessionDetail { + SessionDetail { + id: "session-1".to_string(), + name: "P2 evidence".to_string(), + created_at: 1, + updated_at: 2, + thread_id: "thread-1".to_string(), + model: Some("gpt-5.4".to_string()), + working_dir: Some("/tmp/workspace".to_string()), + workspace_id: Some("workspace-1".to_string()), + messages: Vec::new(), + execution_strategy: Some("react".to_string()), + execution_runtime: None, + turns: vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续推进 evidence pack".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-27T10:00:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + error_message: None, + created_at: "2026-03-27T10:00:00Z".to_string(), + updated_at: "2026-03-27T10:01:00Z".to_string(), + }], + items: vec![ + AgentThreadItem { + id: "plan-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:05Z".to_string(), + completed_at: Some("2026-03-27T10:00:05Z".to_string()), + updated_at: "2026-03-27T10:00:05Z".to_string(), + payload: AgentThreadItemPayload::Plan { + text: "先导出 handoff,再导出 evidence pack".to_string(), + }, + }, + AgentThreadItem { + id: "artifact-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + 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: ".lime/artifacts/thread-1/report.md".to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }, + AgentThreadItem { + id: "summary-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:30Z".to_string(), + completed_at: Some("2026-03-27T10:00:30Z".to_string()), + updated_at: "2026-03-27T10:00:30Z".to_string(), + payload: AgentThreadItemPayload::TurnSummary { + text: "已拿到 handoff 四件套,下一步补问题证据包。".to_string(), + }, + }, + ], + todo_items: Vec::new(), + child_subagent_sessions: Vec::new(), + subagent_parent_context: None, + } + } + + fn build_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "running".to_string(), + active_turn_id: Some("turn-1".to_string()), + pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView { + id: "req-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + request_type: "ask_user".to_string(), + status: "pending".to_string(), + title: Some("确认是否导出问题证据包".to_string()), + payload: None, + decision: None, + scope: None, + created_at: None, + resolved_at: None, + }], + last_outcome: None, + incidents: Vec::new(), + queued_turns: vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "继续补证据包 UI".to_string(), + message_text: "继续补证据包 UI".to_string(), + created_at: 3, + image_count: 0, + position: 1, + }], + interrupt_state: None, + updated_at: Some("2026-03-27T10:01:00Z".to_string()), + diagnostics: Some( + crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics { + latest_turn_status: Some("running".to_string()), + latest_turn_started_at: None, + latest_turn_completed_at: None, + latest_turn_updated_at: None, + latest_turn_elapsed_seconds: None, + latest_turn_stalled_seconds: None, + latest_turn_error_message: None, + interrupt_reason: None, + runtime_interrupt_source: None, + runtime_interrupt_requested_at: None, + runtime_interrupt_wait_seconds: None, + warning_count: 1, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + oldest_pending_request_wait_seconds: None, + primary_blocking_kind: Some("pending_request".to_string()), + primary_blocking_summary: Some("等待用户确认是否导出问题证据包".to_string()), + latest_warning: Some( + crate::commands::aster_agent_cmd::AgentRuntimeDiagnosticWarningSample { + item_id: "warning-1".to_string(), + turn_id: Some("turn-1".to_string()), + code: Some("runtime.pending".to_string()), + message: "存在待处理请求".to_string(), + updated_at: "2026-03-27T10:01:00Z".to_string(), + }, + ), + latest_context_compaction: None, + latest_failed_tool: None, + latest_failed_command: None, + latest_pending_request: None, + }, + ), + } + } + + #[test] + fn should_export_runtime_evidence_pack_to_workspace() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert_eq!( + result.pack_relative_root, + ".lime/harness/sessions/session-1/evidence" + ); + assert_eq!(result.artifacts.len(), 4); + assert_eq!(result.turn_count, 1); + assert_eq!(result.item_count, 3); + assert_eq!(result.pending_request_count, 1); + assert_eq!(result.queued_turn_count, 1); + assert_eq!(result.recent_artifact_count, 1); + assert!(!result.known_gaps.is_empty()); + + let summary_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/summary.md"); + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let timeline_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/timeline.json"); + + assert!(summary_path.exists()); + assert!(runtime_path.exists()); + assert!(timeline_path.exists()); + + let summary = fs::read_to_string(summary_path).expect("summary"); + assert!(summary.contains("问题证据包")); + assert!(summary.contains("等待用户确认是否导出问题证据包")); + + let runtime = fs::read_to_string(runtime_path).expect("runtime"); + assert!(runtime.contains("\"sessionId\": \"session-1\"")); + assert!(runtime.contains("\"pendingRequestCount\": 1")); + + let timeline = fs::read_to_string(timeline_path).expect("timeline"); + assert!(timeline.contains("\"payloadKind\": \"plan\"")); + assert!(timeline.contains("\"status\": \"completed\"")); + } +} diff --git a/src-tauri/src/services/runtime_handoff_artifact_service.rs b/src-tauri/src/services/runtime_handoff_artifact_service.rs new file mode 100644 index 000000000..92032a6a5 --- /dev/null +++ b/src-tauri/src/services/runtime_handoff_artifact_service.rs @@ -0,0 +1,1052 @@ +//! Runtime handoff bundle 导出服务 +//! +//! 将当前 Lime 会话的 runtime / timeline / queue / subagent 事实, +//! 导出为工作区内可被后续会话直接消费的交接制品。 + +use crate::agent::SessionDetail; +use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use chrono::Utc; +use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +const HANDOFF_RELATIVE_ROOT: &str = ".lime/harness/sessions"; +const PLAN_FILE_NAME: &str = "plan.md"; +const PROGRESS_FILE_NAME: &str = "progress.json"; +const HANDOFF_FILE_NAME: &str = "handoff.md"; +const REVIEW_SUMMARY_FILE_NAME: &str = "review-summary.md"; +const MAX_RECENT_ARTIFACTS: usize = 8; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeHandoffArtifactKind { + Plan, + Progress, + Handoff, + ReviewSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeHandoffArtifact { + pub kind: RuntimeHandoffArtifactKind, + pub title: String, + pub relative_path: String, + pub absolute_path: String, + pub bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeHandoffBundleExportResult { + pub session_id: String, + pub thread_id: String, + pub workspace_id: Option, + pub workspace_root: String, + pub bundle_relative_root: String, + pub bundle_absolute_root: String, + pub exported_at: String, + pub thread_status: String, + pub latest_turn_status: Option, + pub pending_request_count: usize, + pub queued_turn_count: usize, + pub active_subagent_count: usize, + pub todo_total: usize, + pub todo_pending: usize, + pub todo_in_progress: usize, + pub todo_completed: usize, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TodoSummary { + total: usize, + pending: usize, + in_progress: usize, + completed: usize, +} + +pub fn export_runtime_handoff_bundle( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + workspace_root: &Path, +) -> Result { + let session_id = detail.id.trim(); + if session_id.is_empty() { + return Err("session_id 不能为空,无法导出 handoff bundle".to_string()); + } + + let thread_id = detail.thread_id.trim(); + if thread_id.is_empty() { + return Err("thread_id 不能为空,无法导出 handoff bundle".to_string()); + } + + let workspace_root = workspace_root + .canonicalize() + .unwrap_or_else(|_| workspace_root.to_path_buf()); + let exported_at = Utc::now().to_rfc3339(); + let bundle_relative_root = format!("{HANDOFF_RELATIVE_ROOT}/{session_id}"); + let bundle_absolute_root = + workspace_root.join(bundle_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR)); + + fs::create_dir_all(&bundle_absolute_root).map_err(|error| { + format!( + "创建 handoff bundle 目录失败 {}: {error}", + bundle_absolute_root.display() + ) + })?; + + let todo_summary = summarize_todos(detail); + let recent_artifacts = collect_recent_artifact_paths(detail); + let latest_turn_summary = collect_latest_turn_summary(detail); + let review_actions = build_review_actions(thread_read); + + let artifacts = vec![ + write_bundle_file( + &bundle_absolute_root, + session_id, + PLAN_FILE_NAME, + RuntimeHandoffArtifactKind::Plan, + "计划摘要", + build_plan_markdown( + detail, + thread_read, + &todo_summary, + &recent_artifacts, + latest_turn_summary.as_deref(), + exported_at.as_str(), + ), + )?, + write_bundle_file( + &bundle_absolute_root, + session_id, + PROGRESS_FILE_NAME, + RuntimeHandoffArtifactKind::Progress, + "结构化进度", + build_progress_json( + detail, + thread_read, + &todo_summary, + &recent_artifacts, + latest_turn_summary.as_deref(), + &review_actions, + workspace_root.as_path(), + exported_at.as_str(), + )?, + )?, + write_bundle_file( + &bundle_absolute_root, + session_id, + HANDOFF_FILE_NAME, + RuntimeHandoffArtifactKind::Handoff, + "交接摘要", + build_handoff_markdown( + detail, + thread_read, + &todo_summary, + &recent_artifacts, + latest_turn_summary.as_deref(), + &review_actions, + exported_at.as_str(), + ), + )?, + write_bundle_file( + &bundle_absolute_root, + session_id, + REVIEW_SUMMARY_FILE_NAME, + RuntimeHandoffArtifactKind::ReviewSummary, + "审查摘要", + build_review_summary_markdown( + detail, + thread_read, + &recent_artifacts, + &review_actions, + exported_at.as_str(), + ), + )?, + ]; + + Ok(RuntimeHandoffBundleExportResult { + session_id: session_id.to_string(), + thread_id: thread_id.to_string(), + workspace_id: normalize_optional_text(detail.workspace_id.clone()), + workspace_root: workspace_root.to_string_lossy().to_string(), + bundle_relative_root, + bundle_absolute_root: bundle_absolute_root.to_string_lossy().to_string(), + exported_at, + thread_status: thread_read.status.trim().to_string(), + latest_turn_status: thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())), + pending_request_count: thread_read.pending_requests.len(), + queued_turn_count: thread_read.queued_turns.len(), + active_subagent_count: count_active_subagents(detail), + todo_total: todo_summary.total, + todo_pending: todo_summary.pending, + todo_in_progress: todo_summary.in_progress, + todo_completed: todo_summary.completed, + artifacts, + }) +} + +fn write_bundle_file( + bundle_root: &Path, + session_id: &str, + file_name: &str, + kind: RuntimeHandoffArtifactKind, + title: &str, + content: String, +) -> Result { + let absolute_path = bundle_root.join(file_name); + fs::write(&absolute_path, content.as_bytes()).map_err(|error| { + format!( + "写入 handoff bundle 文件失败 {}: {error}", + absolute_path.display() + ) + })?; + + Ok(RuntimeHandoffArtifact { + kind, + title: title.to_string(), + relative_path: format!("{HANDOFF_RELATIVE_ROOT}/{session_id}/{file_name}"), + absolute_path: absolute_path.to_string_lossy().to_string(), + bytes: content.len(), + }) +} + +fn build_plan_markdown( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + todo_summary: &TodoSummary, + recent_artifacts: &[String], + latest_turn_summary: Option<&str>, + exported_at: &str, +) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# 会话计划"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "> 形状参考 Codex 的 `plan / handoff` 合同,运行时事实承接 Aster 的 `session / runtime / resume`,当前工作区制品由 Lime 导出。" + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- 会话:`{}`", detail.id); + let _ = writeln!(markdown, "- 线程:`{}`", detail.thread_id); + let _ = writeln!(markdown, "- 导出时间:{exported_at}"); + let _ = writeln!(markdown, "- 线程状态:{}", thread_read.status); + if let Some(strategy) = normalize_optional_text(detail.execution_strategy.clone()) { + let _ = writeln!(markdown, "- 执行策略:{strategy}"); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 当前目标"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "{}", + latest_turn_summary + .unwrap_or("当前没有可直接复用的 turn summary,恢复时请先阅读 handoff.md。") + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## Todo"); + let _ = writeln!(markdown); + if detail.todo_items.is_empty() { + let fallback_plans = collect_plan_lines(detail); + if fallback_plans.is_empty() { + let _ = writeln!(markdown, "- 当前没有显式 Todo 列表。"); + } else { + for plan in fallback_plans { + let _ = writeln!(markdown, "- {plan}"); + } + } + } else { + let _ = writeln!( + markdown, + "- 总数:{},待开始 {},进行中 {},已完成 {}", + todo_summary.total, + todo_summary.pending, + todo_summary.in_progress, + todo_summary.completed + ); + for item in &detail.todo_items { + let status = todo_status_value(item); + let marker = match status.as_str() { + "completed" => "[x]", + "in_progress" => "[-]", + _ => "[ ]", + }; + if let Some(active_form) = normalize_optional_text(item.active_form.clone()) { + let _ = writeln!(markdown, "- {marker} {} ({active_form})", item.content); + } else { + let _ = writeln!(markdown, "- {marker} {}", item.content); + } + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 当前阻塞"); + let _ = writeln!(markdown); + let blocking_lines = build_blocking_lines(thread_read); + if blocking_lines.is_empty() { + let _ = writeln!(markdown, "- 当前未检测到显式阻塞。"); + } else { + for line in blocking_lines { + let _ = writeln!(markdown, "- {line}"); + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 最近产物"); + let _ = writeln!(markdown); + if recent_artifacts.is_empty() { + let _ = writeln!(markdown, "- 当前未发现最近产物路径。"); + } else { + for path in recent_artifacts { + let _ = writeln!(markdown, "- `{path}`"); + } + } + + markdown +} + +fn build_progress_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + todo_summary: &TodoSummary, + recent_artifacts: &[String], + latest_turn_summary: Option<&str>, + review_actions: &[String], + workspace_root: &Path, + exported_at: &str, +) -> Result { + let progress = json!({ + "schemaVersion": "v1", + "source": { + "contractShape": "codex_plan_handoff", + "runtimeSubstrate": "aster_session_runtime_resume", + "productSurface": "lime_workspace_handoff_bundle" + }, + "session": { + "sessionId": detail.id, + "threadId": detail.thread_id, + "name": detail.name, + "workspaceId": detail.workspace_id, + "workspaceRoot": workspace_root.to_string_lossy().to_string(), + "exportedAt": exported_at, + "updatedAt": detail.updated_at, + "executionStrategy": detail.execution_strategy, + "model": detail.model + }, + "status": { + "threadStatus": thread_read.status, + "latestTurnStatus": thread_read.diagnostics.as_ref().and_then(|value| value.latest_turn_status.clone()), + "activeTurnId": thread_read.active_turn_id, + "pendingRequestCount": thread_read.pending_requests.len(), + "queuedTurnCount": thread_read.queued_turns.len(), + "interruptState": thread_read.interrupt_state + }, + "todo": { + "total": todo_summary.total, + "pending": todo_summary.pending, + "inProgress": todo_summary.in_progress, + "completed": todo_summary.completed, + "items": detail.todo_items.iter().map(|item| { + json!({ + "content": item.content, + "status": todo_status_value(item), + "activeForm": item.active_form + }) + }).collect::>() + }, + "pendingRequests": thread_read.pending_requests.iter().map(|item| { + json!({ + "id": item.id, + "type": item.request_type, + "status": item.status, + "title": item.title, + "turnId": item.turn_id + }) + }).collect::>(), + "queuedTurns": thread_read.queued_turns.iter().map(|item| { + json!({ + "id": item.queued_turn_id, + "position": item.position, + "preview": item.message_preview, + "createdAt": item.created_at + }) + }).collect::>(), + "subagents": detail.child_subagent_sessions.iter().map(|session| { + json!({ + "id": session.id, + "name": session.name, + "runtimeStatus": session.runtime_status, + "roleHint": session.role_hint, + "taskSummary": session.task_summary, + "updatedAt": session.updated_at + }) + }).collect::>(), + "artifacts": recent_artifacts, + "latestTurnSummary": latest_turn_summary, + "diagnostics": { + "primaryBlockingKind": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_kind.clone()), + "primaryBlockingSummary": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_summary.clone()), + "latestWarning": thread_read.diagnostics.as_ref().and_then(|value| { + value.latest_warning.as_ref().map(|warning| { + json!({ + "code": warning.code, + "message": warning.message, + "updatedAt": warning.updated_at + }) + }) + }), + "latestFailedTool": thread_read.diagnostics.as_ref().and_then(|value| { + value.latest_failed_tool.as_ref().map(|tool| { + json!({ + "toolName": tool.tool_name, + "error": tool.error, + "updatedAt": tool.updated_at + }) + }) + }), + "latestFailedCommand": thread_read.diagnostics.as_ref().and_then(|value| { + value.latest_failed_command.as_ref().map(|command| { + json!({ + "command": command.command, + "exitCode": command.exit_code, + "error": command.error, + "updatedAt": command.updated_at + }) + }) + }) + }, + "resumeOrder": build_resume_order(), + "reviewActions": review_actions + }); + + serde_json::to_string_pretty(&progress) + .map_err(|error| format!("序列化 progress.json 失败: {error}")) +} + +fn build_handoff_markdown( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + todo_summary: &TodoSummary, + recent_artifacts: &[String], + latest_turn_summary: Option<&str>, + review_actions: &[String], + exported_at: &str, +) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# 会话交接摘要"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- 会话:`{}`", detail.id); + let _ = writeln!(markdown, "- 导出时间:{exported_at}"); + let _ = writeln!(markdown, "- 当前状态:{}", thread_read.status); + if let Some(latest_turn_status) = thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())) + { + let _ = writeln!(markdown, "- 最新 turn 状态:{latest_turn_status}"); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 最近摘要"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "{}", + latest_turn_summary + .unwrap_or("当前没有结构化 turn summary,请优先阅读 progress.json 与 plan.md。") + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 推荐接手顺序"); + let _ = writeln!(markdown); + for (index, step) in build_resume_order().iter().enumerate() { + let _ = writeln!(markdown, "{}. {}", index + 1, step); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 当前待继续事项"); + let _ = writeln!(markdown); + if todo_summary.total == 0 { + let _ = writeln!( + markdown, + "- 当前没有 Todo 列表,请结合 review-summary.md 决定下一刀。" + ); + } else { + let _ = writeln!( + markdown, + "- Todo 总数 {},待开始 {},进行中 {},已完成 {}", + todo_summary.total, + todo_summary.pending, + todo_summary.in_progress, + todo_summary.completed + ); + for item in &detail.todo_items { + let status = todo_status_value(item); + if status != "completed" { + let _ = writeln!( + markdown, + "- {}:{}", + todo_status_label(status.as_str()), + item.content + ); + } + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 审查与恢复建议"); + let _ = writeln!(markdown); + if review_actions.is_empty() { + let _ = writeln!(markdown, "- 当前未检测到额外恢复动作。"); + } else { + for action in review_actions { + let _ = writeln!(markdown, "- {action}"); + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 最近产物"); + let _ = writeln!(markdown); + if recent_artifacts.is_empty() { + let _ = writeln!(markdown, "- 当前没有最近产物路径。"); + } else { + for artifact in recent_artifacts { + let _ = writeln!(markdown, "- `{artifact}`"); + } + } + if !detail.child_subagent_sessions.is_empty() { + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 协作成员"); + let _ = writeln!(markdown); + for session in &detail.child_subagent_sessions { + let status = session + .runtime_status + .map(subagent_status_label) + .unwrap_or("未知"); + let summary = normalize_optional_text(session.task_summary.clone()) + .unwrap_or_else(|| "暂无任务摘要".to_string()); + let _ = writeln!(markdown, "- {} · {} · {}", session.name, status, summary); + } + } + + markdown +} + +fn build_review_summary_markdown( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + recent_artifacts: &[String], + review_actions: &[String], + exported_at: &str, +) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# 审查摘要"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- 会话:`{}`", detail.id); + let _ = writeln!(markdown, "- 导出时间:{exported_at}"); + let _ = writeln!( + markdown, + "- 诊断结论:{}", + if review_actions.is_empty() { + "当前未检测到强阻塞,可继续推进" + } else { + "存在待处理恢复 / 审查动作" + } + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 线程诊断"); + let _ = writeln!(markdown); + let diagnostics = thread_read.diagnostics.as_ref(); + let _ = writeln!(markdown, "- 状态:{}", thread_read.status); + let _ = writeln!( + markdown, + "- Pending request:{}", + thread_read.pending_requests.len() + ); + let _ = writeln!(markdown, "- Queue:{}", thread_read.queued_turns.len()); + if let Some(value) = diagnostics.and_then(|item| item.primary_blocking_summary.clone()) { + let _ = writeln!(markdown, "- 主要阻塞:{value}"); + } + if let Some(value) = diagnostics.and_then(|item| item.latest_warning.as_ref()) { + let _ = writeln!(markdown, "- 最近 warning:{}", value.message); + } + if let Some(value) = diagnostics.and_then(|item| item.latest_failed_tool.as_ref()) { + let _ = writeln!( + markdown, + "- 最近失败工具:{}{}", + value.tool_name, + value + .error + .as_ref() + .map(|error| format!(" ({error})")) + .unwrap_or_default() + ); + } + if let Some(value) = diagnostics.and_then(|item| item.latest_failed_command.as_ref()) { + let _ = writeln!( + markdown, + "- 最近失败命令:{}{}", + value.command, + value + .error + .as_ref() + .map(|error| format!(" ({error})")) + .unwrap_or_default() + ); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 建议动作"); + let _ = writeln!(markdown); + if review_actions.is_empty() { + let _ = writeln!( + markdown, + "- 继续执行下一步实现,并在完成后刷新 handoff bundle。" + ); + } else { + for action in review_actions { + let _ = writeln!(markdown, "- {action}"); + } + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 重点产物"); + let _ = writeln!(markdown); + if recent_artifacts.is_empty() { + let _ = writeln!(markdown, "- 当前没有可关联的文件产物。"); + } else { + for artifact in recent_artifacts { + let _ = writeln!(markdown, "- `{artifact}`"); + } + } + + markdown +} + +fn build_resume_order() -> Vec<&'static str> { + vec![ + "先读 `handoff.md`,确认当前目标、最近摘要和建议接手顺序。", + "再读 `progress.json`,获取结构化状态、排队 turn、审批与子任务信息。", + "需要继续编码时再读 `plan.md` 与 `review-summary.md`,确认 Todo、阻塞和验证动作。", + ] +} + +fn build_review_actions(thread_read: &AgentRuntimeThreadReadModel) -> Vec { + let mut actions = Vec::new(); + + if !thread_read.pending_requests.is_empty() { + actions.push(format!( + "优先处理 {} 个待确认 / 待输入请求,避免线程继续阻塞。", + thread_read.pending_requests.len() + )); + } + + if !thread_read.queued_turns.is_empty() { + actions.push(format!( + "当前还有 {} 个排队 turn,恢复线程前先确认是否需要立即执行。", + thread_read.queued_turns.len() + )); + } + + if let Some(diagnostics) = thread_read.diagnostics.as_ref() { + if let Some(failed_tool) = diagnostics.latest_failed_tool.as_ref() { + actions.push(format!( + "检查失败工具 `{}`,必要时先修复工具链或输入参数。", + failed_tool.tool_name + )); + } + if let Some(failed_command) = diagnostics.latest_failed_command.as_ref() { + actions.push(format!( + "复盘失败命令 `{}` 的环境 / 依赖问题,再继续后续执行。", + failed_command.command + )); + } + if let Some(blocking_summary) = diagnostics.primary_blocking_summary.as_ref() { + actions.push(format!("优先消除当前主要阻塞:{blocking_summary}")); + } + } + + actions +} + +fn build_blocking_lines(thread_read: &AgentRuntimeThreadReadModel) -> Vec { + let mut lines = Vec::new(); + + if let Some(diagnostics) = thread_read.diagnostics.as_ref() { + if let Some(summary) = diagnostics.primary_blocking_summary.as_ref() { + lines.push(summary.clone()); + } + if let Some(request) = diagnostics.latest_pending_request.as_ref() { + let title = request + .title + .clone() + .unwrap_or_else(|| request.request_type.clone()); + lines.push(format!("待处理请求:{title}")); + } + } + + if !thread_read.queued_turns.is_empty() { + lines.push(format!( + "存在 {} 个排队 turn。", + thread_read.queued_turns.len() + )); + } + + lines +} + +fn collect_plan_lines(detail: &SessionDetail) -> Vec { + detail + .items + .iter() + .filter_map(|item| match &item.payload { + AgentThreadItemPayload::Plan { text } => normalize_optional_text(Some(text.clone())), + _ => None, + }) + .collect() +} + +fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { + detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + AgentThreadItemPayload::TurnSummary { text } => { + normalize_optional_text(Some(text.clone())) + } + _ => None, + }) +} + +fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut paths = Vec::new(); + + for item in detail.items.iter().rev() { + let Some(path) = (match &item.payload { + AgentThreadItemPayload::FileArtifact { path, .. } => { + normalize_optional_text(Some(path.clone())) + } + _ => None, + }) else { + continue; + }; + + if seen.insert(path.clone()) { + paths.push(path); + } + if paths.len() >= MAX_RECENT_ARTIFACTS { + break; + } + } + + paths +} + +fn summarize_todos(detail: &SessionDetail) -> TodoSummary { + let mut summary = TodoSummary::default(); + for item in &detail.todo_items { + summary.total += 1; + match todo_status_value(item).as_str() { + "completed" => summary.completed += 1, + "in_progress" => summary.in_progress += 1, + _ => summary.pending += 1, + } + } + summary +} + +fn count_active_subagents(detail: &SessionDetail) -> usize { + detail + .child_subagent_sessions + .iter() + .filter(|session| { + matches!( + session.runtime_status, + Some(crate::agent::ChildSubagentRuntimeStatus::Queued) + | Some(crate::agent::ChildSubagentRuntimeStatus::Running) + ) + }) + .count() +} + +fn todo_status_value(item: &lime_agent::SessionTodoItem) -> String { + serde_json::to_value(&item.status) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| "pending".to_string()) +} + +fn todo_status_label(status: &str) -> &'static str { + match status { + "completed" => "已完成", + "in_progress" => "进行中", + _ => "待开始", + } +} + +fn subagent_status_label(status: crate::agent::ChildSubagentRuntimeStatus) -> &'static str { + match status { + crate::agent::ChildSubagentRuntimeStatus::Idle => "空闲", + crate::agent::ChildSubagentRuntimeStatus::Queued => "排队中", + crate::agent::ChildSubagentRuntimeStatus::Running => "处理中", + crate::agent::ChildSubagentRuntimeStatus::Completed => "已完成", + crate::agent::ChildSubagentRuntimeStatus::Failed => "失败", + crate::agent::ChildSubagentRuntimeStatus::Aborted => "已中止", + crate::agent::ChildSubagentRuntimeStatus::Closed => "已关闭", + } +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::QueuedTurnSnapshot; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, + AgentThreadTurnStatus, + }; + use tempfile::TempDir; + + fn build_detail() -> SessionDetail { + SessionDetail { + id: "session-1".to_string(), + name: "P2 handoff".to_string(), + created_at: 1, + updated_at: 2, + thread_id: "thread-1".to_string(), + model: Some("gpt-5.4".to_string()), + working_dir: Some("/tmp/workspace".to_string()), + workspace_id: Some("workspace-1".to_string()), + messages: Vec::new(), + execution_strategy: Some("react".to_string()), + execution_runtime: None, + turns: vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续推进".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-27T10:00:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + error_message: None, + created_at: "2026-03-27T10:00:00Z".to_string(), + updated_at: "2026-03-27T10:01:00Z".to_string(), + }], + items: vec![ + AgentThreadItem { + id: "plan-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:05Z".to_string(), + completed_at: Some("2026-03-27T10:00:05Z".to_string()), + updated_at: "2026-03-27T10:00:05Z".to_string(), + payload: AgentThreadItemPayload::Plan { + text: "先导出交接制品,再补 UI 入口".to_string(), + }, + }, + AgentThreadItem { + id: "artifact-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + 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: ".lime/artifacts/thread-1/report.md".to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }, + AgentThreadItem { + id: "summary-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:30Z".to_string(), + completed_at: Some("2026-03-27T10:00:30Z".to_string()), + updated_at: "2026-03-27T10:00:30Z".to_string(), + payload: AgentThreadItemPayload::TurnSummary { + text: "已完成后端导出链路,下一步补前端入口。".to_string(), + }, + }, + ], + todo_items: vec![ + lime_agent::SessionTodoItem { + content: "补前端入口".to_string(), + status: serde_json::from_value(json!("in_progress")).expect("status"), + active_form: None, + }, + lime_agent::SessionTodoItem { + content: "跑契约测试".to_string(), + status: serde_json::from_value(json!("pending")).expect("status"), + active_form: None, + }, + ], + child_subagent_sessions: vec![crate::agent::ChildSubagentSession { + id: "sub-1".to_string(), + name: "Review".to_string(), + created_at: 1, + updated_at: 2, + session_type: "subagent".to_string(), + model: None, + provider_name: None, + working_dir: None, + workspace_id: None, + task_summary: Some("复查交接制品是否完整".to_string()), + role_hint: Some("审查".to_string()), + origin_tool: None, + created_from_turn_id: None, + blueprint_role_id: None, + blueprint_role_label: None, + profile_id: None, + profile_name: None, + role_key: None, + team_preset_id: None, + theme: None, + output_contract: None, + skill_ids: Vec::new(), + skills: Vec::new(), + runtime_status: Some(crate::agent::ChildSubagentRuntimeStatus::Running), + latest_turn_status: None, + queued_turn_count: 0, + team_phase: None, + team_parallel_budget: None, + team_active_count: None, + team_queued_count: None, + provider_concurrency_group: None, + provider_parallel_budget: None, + queue_reason: None, + retryable_overload: false, + }], + subagent_parent_context: None, + } + } + + fn build_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "running".to_string(), + active_turn_id: Some("turn-1".to_string()), + pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView { + id: "req-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + request_type: "tool_confirmation".to_string(), + status: "pending".to_string(), + title: Some("确认写入交接文件".to_string()), + payload: None, + decision: None, + scope: None, + created_at: None, + resolved_at: None, + }], + last_outcome: None, + incidents: Vec::new(), + queued_turns: vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "继续补交接 UI".to_string(), + message_text: "继续补交接 UI".to_string(), + created_at: 3, + image_count: 0, + position: 1, + }], + interrupt_state: None, + updated_at: Some("2026-03-27T10:01:00Z".to_string()), + diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics { + latest_turn_status: Some("completed".to_string()), + latest_turn_started_at: None, + latest_turn_completed_at: None, + latest_turn_updated_at: None, + latest_turn_elapsed_seconds: None, + latest_turn_stalled_seconds: None, + latest_turn_error_message: None, + interrupt_reason: None, + runtime_interrupt_source: None, + runtime_interrupt_requested_at: None, + runtime_interrupt_wait_seconds: None, + warning_count: 0, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + oldest_pending_request_wait_seconds: None, + primary_blocking_kind: Some("pending_request".to_string()), + primary_blocking_summary: Some("等待用户确认写入交接文件".to_string()), + latest_warning: None, + latest_context_compaction: None, + latest_failed_tool: None, + latest_failed_command: None, + latest_pending_request: Some( + crate::commands::aster_agent_cmd::AgentRuntimeDiagnosticPendingRequestSample { + request_id: "req-1".to_string(), + turn_id: Some("turn-1".to_string()), + request_type: "tool_confirmation".to_string(), + title: Some("确认写入交接文件".to_string()), + waited_seconds: Some(12), + created_at: None, + }, + ), + }), + } + } + + #[test] + fn should_export_runtime_handoff_bundle_to_workspace() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + + let result = + export_runtime_handoff_bundle(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert_eq!( + result.bundle_relative_root, + ".lime/harness/sessions/session-1" + ); + assert_eq!(result.artifacts.len(), 4); + assert_eq!(result.pending_request_count, 1); + assert_eq!(result.queued_turn_count, 1); + assert_eq!(result.active_subagent_count, 1); + assert_eq!(result.todo_total, 2); + + let plan_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/plan.md"); + let handoff_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/handoff.md"); + let progress_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/progress.json"); + + assert!(plan_path.exists()); + assert!(handoff_path.exists()); + assert!(progress_path.exists()); + + let plan = fs::read_to_string(plan_path).expect("plan"); + assert!(plan.contains("会话计划")); + assert!(plan.contains("补前端入口")); + + let handoff = fs::read_to_string(handoff_path).expect("handoff"); + assert!(handoff.contains("已完成后端导出链路")); + assert!(handoff.contains("推荐接手顺序")); + + let progress = fs::read_to_string(progress_path).expect("progress"); + assert!(progress.contains("\"sessionId\": \"session-1\"")); + assert!(progress.contains("\"pendingRequestCount\": 1")); + } +} diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs new file mode 100644 index 000000000..d5dd44573 --- /dev/null +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -0,0 +1,1261 @@ +//! Runtime replay case 导出服务 +//! +//! 目标是把当前 Lime 会话沉淀为最小可复盘、可评分、可回归的 replay case。 +//! 这条主链参考 Codex 的 replay fidelity 与 Aster 的 eval / bench 组织方式, +//! 但最终制品仍然落在 Lime 工作区,复用现有 handoff bundle 与 evidence pack。 + +use crate::agent::SessionDetail; +use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::services::runtime_evidence_pack_service::{ + export_runtime_evidence_pack, RuntimeEvidencePackExportResult, +}; +use crate::services::runtime_handoff_artifact_service::{ + export_runtime_handoff_bundle, RuntimeHandoffBundleExportResult, +}; +use chrono::Utc; +use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions"; +const REPLAY_DIR_NAME: &str = "replay"; +const INPUT_FILE_NAME: &str = "input.json"; +const EXPECTED_FILE_NAME: &str = "expected.json"; +const GRADER_FILE_NAME: &str = "grader.md"; +const EVIDENCE_LINKS_FILE_NAME: &str = "evidence-links.json"; +const MAX_RECENT_ARTIFACTS: usize = 8; +const MAX_RECENT_TIMELINE_ITEMS: usize = 6; +const MAX_PENDING_REQUESTS: usize = 3; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeReplayArtifactKind { + Input, + Expected, + Grader, + EvidenceLinks, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeReplayArtifact { + pub kind: RuntimeReplayArtifactKind, + pub title: String, + pub relative_path: String, + pub absolute_path: String, + pub bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeReplayCaseExportResult { + pub session_id: String, + pub thread_id: String, + pub workspace_id: Option, + pub workspace_root: String, + pub replay_relative_root: String, + pub replay_absolute_root: String, + pub handoff_bundle_relative_root: String, + pub evidence_pack_relative_root: String, + pub exported_at: String, + pub thread_status: String, + pub latest_turn_status: Option, + pub pending_request_count: usize, + pub queued_turn_count: usize, + pub linked_handoff_artifact_count: usize, + pub linked_evidence_artifact_count: usize, + pub recent_artifact_count: usize, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct ReplayPendingRequestInput { + request_id: String, + request_type: String, + title: Option, + action_type: Option, + prompt: Option, + tool_name: Option, + arguments: Option, + questions: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct ReplayTimelineItem { + item_id: String, + turn_id: String, + payload_kind: String, + status: String, + summary: Option, + updated_at: String, +} + +pub fn export_runtime_replay_case( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + workspace_root: &Path, +) -> Result { + let session_id = detail.id.trim(); + if session_id.is_empty() { + return Err("session_id 不能为空,无法导出 replay case".to_string()); + } + + let thread_id = detail.thread_id.trim(); + if thread_id.is_empty() { + return Err("thread_id 不能为空,无法导出 replay case".to_string()); + } + + let workspace_root = workspace_root + .canonicalize() + .unwrap_or_else(|_| workspace_root.to_path_buf()); + let exported_at = Utc::now().to_rfc3339(); + let replay_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{REPLAY_DIR_NAME}"); + let replay_absolute_root = + workspace_root.join(replay_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR)); + + let handoff_bundle = + export_runtime_handoff_bundle(detail, thread_read, workspace_root.as_path())?; + let evidence_pack = + export_runtime_evidence_pack(detail, thread_read, workspace_root.as_path())?; + + fs::create_dir_all(&replay_absolute_root).map_err(|error| { + format!( + "创建 replay case 目录失败 {}: {error}", + replay_absolute_root.display() + ) + })?; + + let latest_turn_summary = collect_latest_turn_summary(detail); + let latest_plan = collect_latest_plan(detail); + let goal_summary = latest_turn_summary + .clone() + .or(latest_plan.clone()) + .or_else(|| { + detail + .turns + .last() + .and_then(|turn| normalize_optional_text(Some(turn.prompt_text.clone()))) + }); + let recent_artifacts = collect_recent_artifact_paths(detail); + let pending_requests = collect_pending_request_inputs(detail, thread_read); + let recent_timeline = collect_recent_timeline_items(detail); + let success_criteria = build_success_criteria( + detail, + thread_read, + goal_summary.as_deref(), + &recent_artifacts, + &pending_requests, + ); + let blocking_checks = build_blocking_checks(thread_read, &pending_requests); + let artifact_checks = build_artifact_checks(&recent_artifacts); + + let artifacts = vec![ + write_replay_file( + &replay_absolute_root, + session_id, + INPUT_FILE_NAME, + RuntimeReplayArtifactKind::Input, + "回放输入", + build_input_json( + detail, + thread_read, + &handoff_bundle, + &evidence_pack, + goal_summary.as_deref(), + latest_plan.as_deref(), + latest_turn_summary.as_deref(), + &recent_artifacts, + &recent_timeline, + &pending_requests, + workspace_root.as_path(), + exported_at.as_str(), + )?, + )?, + write_replay_file( + &replay_absolute_root, + session_id, + EXPECTED_FILE_NAME, + RuntimeReplayArtifactKind::Expected, + "期望结果", + build_expected_json( + detail, + thread_read, + goal_summary.as_deref(), + &success_criteria, + &blocking_checks, + &artifact_checks, + exported_at.as_str(), + )?, + )?, + write_replay_file( + &replay_absolute_root, + session_id, + GRADER_FILE_NAME, + RuntimeReplayArtifactKind::Grader, + "评分说明", + build_grader_markdown( + detail, + goal_summary.as_deref(), + &success_criteria, + &blocking_checks, + handoff_bundle.bundle_relative_root.as_str(), + evidence_pack.pack_relative_root.as_str(), + exported_at.as_str(), + ), + )?, + write_replay_file( + &replay_absolute_root, + session_id, + EVIDENCE_LINKS_FILE_NAME, + RuntimeReplayArtifactKind::EvidenceLinks, + "证据链接", + build_evidence_links_json( + &handoff_bundle, + &evidence_pack, + &recent_artifacts, + exported_at.as_str(), + )?, + )?, + ]; + + Ok(RuntimeReplayCaseExportResult { + session_id: session_id.to_string(), + thread_id: thread_id.to_string(), + workspace_id: normalize_optional_text(detail.workspace_id.clone()), + workspace_root: workspace_root.to_string_lossy().to_string(), + replay_relative_root, + replay_absolute_root: replay_absolute_root.to_string_lossy().to_string(), + handoff_bundle_relative_root: handoff_bundle.bundle_relative_root, + evidence_pack_relative_root: evidence_pack.pack_relative_root, + exported_at, + thread_status: thread_read.status.trim().to_string(), + latest_turn_status: thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())), + pending_request_count: thread_read.pending_requests.len(), + queued_turn_count: thread_read.queued_turns.len(), + linked_handoff_artifact_count: handoff_bundle.artifacts.len(), + linked_evidence_artifact_count: evidence_pack.artifacts.len(), + recent_artifact_count: recent_artifacts.len(), + artifacts, + }) +} + +fn write_replay_file( + replay_root: &Path, + session_id: &str, + file_name: &str, + kind: RuntimeReplayArtifactKind, + title: &str, + content: String, +) -> Result { + let absolute_path = replay_root.join(file_name); + fs::write(&absolute_path, content.as_bytes()).map_err(|error| { + format!( + "写入 replay case 文件失败 {}: {error}", + absolute_path.display() + ) + })?; + + Ok(RuntimeReplayArtifact { + kind, + title: title.to_string(), + relative_path: format!( + "{SESSION_RELATIVE_ROOT}/{session_id}/{REPLAY_DIR_NAME}/{file_name}" + ), + absolute_path: absolute_path.to_string_lossy().to_string(), + bytes: content.len(), + }) +} + +#[allow(clippy::too_many_arguments)] +fn build_input_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + handoff_bundle: &RuntimeHandoffBundleExportResult, + evidence_pack: &RuntimeEvidencePackExportResult, + goal_summary: Option<&str>, + latest_plan: Option<&str>, + latest_turn_summary: Option<&str>, + recent_artifacts: &[String], + recent_timeline: &[ReplayTimelineItem], + pending_requests: &[ReplayPendingRequestInput], + workspace_root: &Path, + exported_at: &str, +) -> Result { + let latest_turn = detail.turns.last(); + let suite_tags = infer_replay_suite_tags(detail, thread_read, handoff_bundle, evidence_pack); + let failure_modes = infer_replay_failure_modes(detail, thread_read); + let primary_blocking_kind = thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.primary_blocking_kind.clone())); + let payload = json!({ + "replayCaseVersion": "v1", + "source": "lime.runtime_export.replay_case", + "exportedAt": exported_at, + "session": { + "sessionId": detail.id.as_str(), + "threadId": detail.thread_id.as_str(), + "workspaceId": detail.workspace_id.clone(), + "workspaceRoot": workspace_root.to_string_lossy().to_string(), + "model": detail.model.clone(), + "executionStrategy": detail.execution_strategy.clone(), + }, + "task": { + "goalSummary": goal_summary, + "latestPlan": latest_plan, + "latestTurnSummary": latest_turn_summary, + "latestTurnPrompt": latest_turn.map(|turn| turn.prompt_text.clone()), + "latestTurnId": latest_turn.map(|turn| turn.id.clone()), + "latestTurnStatus": latest_turn.map(|turn| turn.status.as_str().to_string()), + "threadStatus": thread_read.status.as_str(), + "primaryBlockingSummary": thread_read + .diagnostics + .as_ref() + .and_then(|value| value.primary_blocking_summary.clone()), + }, + "classification": { + "sourceKind": "runtime_export", + "suiteTags": suite_tags, + "failureModes": failure_modes, + "primaryBlockingKind": primary_blocking_kind, + }, + "runtimeContext": { + "pendingRequests": pending_requests, + "queuedTurns": thread_read.queued_turns.iter().map(|turn| { + json!({ + "queuedTurnId": turn.queued_turn_id, + "messagePreview": turn.message_preview, + "position": turn.position, + }) + }).collect::>(), + "todoItems": detail.todo_items.iter().map(|item| { + json!({ + "content": item.content.as_str(), + "status": session_todo_status_label(item), + }) + }).collect::>(), + "activeSubagents": detail.child_subagent_sessions.iter().map(|session| { + json!({ + "id": session.id.as_str(), + "name": session.name.as_str(), + "roleHint": session.role_hint.clone(), + "taskSummary": session.task_summary.clone(), + "runtimeStatus": session + .runtime_status + .as_ref() + .map(child_subagent_runtime_status_label), + }) + }).collect::>(), + "recentArtifacts": recent_artifacts, + "recentTimeline": recent_timeline, + "lastOutcome": &thread_read.last_outcome, + "incidents": &thread_read.incidents, + }, + "linkedArtifacts": { + "handoffBundle": { + "relativeRoot": handoff_bundle.bundle_relative_root.as_str(), + "artifactCount": handoff_bundle.artifacts.len(), + }, + "evidencePack": { + "relativeRoot": evidence_pack.pack_relative_root.as_str(), + "artifactCount": evidence_pack.artifacts.len(), + "knownGaps": &evidence_pack.known_gaps, + }, + }, + }); + + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 input.json 失败: {error}")) +} + +fn build_expected_json( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + goal_summary: Option<&str>, + success_criteria: &[String], + blocking_checks: &[String], + artifact_checks: &[String], + exported_at: &str, +) -> Result { + let payload = json!({ + "replayCaseVersion": "v1", + "exportedAt": exported_at, + "sessionId": detail.id.as_str(), + "threadId": detail.thread_id.as_str(), + "goalSummary": goal_summary, + "successCriteria": success_criteria, + "blockingChecks": blocking_checks, + "artifactChecks": artifact_checks, + "nonGoals": [ + "不要要求与原始会话完全相同的工具调用顺序", + "不要把措辞差异当作失败,除非它改变了交付结果或风险判断", + ], + "graderSuggestion": { + "preferredMode": if pending_request_like_state(thread_read) { + "result_artifact_and_request_resolution" + } else { + "result_and_artifact" + }, + "requiresHumanReview": !thread_read.incidents.is_empty(), + }, + }); + + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 expected.json 失败: {error}")) +} + +fn build_grader_markdown( + detail: &SessionDetail, + goal_summary: Option<&str>, + success_criteria: &[String], + blocking_checks: &[String], + handoff_relative_root: &str, + evidence_relative_root: &str, + exported_at: &str, +) -> String { + let mut markdown = String::new(); + let _ = writeln!(markdown, "# Replay Case 评分说明"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- 会话:`{}`", detail.id); + let _ = writeln!(markdown, "- 线程:`{}`", detail.thread_id); + let _ = writeln!(markdown, "- 导出时间:{exported_at}"); + if let Some(summary) = goal_summary { + let _ = writeln!(markdown, "- 目标摘要:{summary}"); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 建议读取顺序"); + let _ = writeln!(markdown); + let _ = writeln!( + markdown, + "1. 先读 `input.json`,理解当前任务与运行时上下文。" + ); + let _ = writeln!(markdown, "2. 再读 `expected.json`,确认只评估结果与风险。"); + let _ = writeln!( + markdown, + "3. 再读 `evidence-links.json`,跳转到已有证据源。" + ); + let _ = writeln!( + markdown, + "4. 如需补证据,优先回看 `{handoff_relative_root}` 与 `{evidence_relative_root}`。" + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 评分原则"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "- 只评结果,不评路径。"); + let _ = writeln!(markdown, "- 先证据后结论;没有证据支撑的 PASS 不成立。"); + let _ = writeln!( + markdown, + "- 如仍存在 pending request,必须解释它是已处理、仍保留,还是不影响判定。" + ); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 最小通过条件"); + let _ = writeln!(markdown); + for criterion in success_criteria { + let _ = writeln!(markdown, "- {criterion}"); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 关键阻塞检查"); + let _ = writeln!(markdown); + for check in blocking_checks { + let _ = writeln!(markdown, "- {check}"); + } + let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 建议输出模板"); + let _ = writeln!(markdown); + let _ = writeln!(markdown, "```text"); + let _ = writeln!(markdown, "verdict: pass | fail | needs_review"); + let _ = writeln!(markdown, "reason:"); + let _ = writeln!(markdown, "- ..."); + let _ = writeln!(markdown, "evidence:"); + let _ = writeln!(markdown, "- ..."); + let _ = writeln!(markdown, "risks:"); + let _ = writeln!(markdown, "- ..."); + let _ = writeln!(markdown, "```"); + + markdown +} + +fn build_evidence_links_json( + handoff_bundle: &RuntimeHandoffBundleExportResult, + evidence_pack: &RuntimeEvidencePackExportResult, + recent_artifacts: &[String], + exported_at: &str, +) -> Result { + let payload = json!({ + "replayCaseVersion": "v1", + "exportedAt": exported_at, + "handoffBundle": { + "relativeRoot": handoff_bundle.bundle_relative_root.as_str(), + "absoluteRoot": handoff_bundle.bundle_absolute_root.as_str(), + "artifacts": &handoff_bundle.artifacts, + }, + "evidencePack": { + "relativeRoot": evidence_pack.pack_relative_root.as_str(), + "absoluteRoot": evidence_pack.pack_absolute_root.as_str(), + "knownGaps": &evidence_pack.known_gaps, + "artifacts": &evidence_pack.artifacts, + }, + "recentArtifacts": recent_artifacts, + }); + + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("序列化 evidence-links.json 失败: {error}")) +} + +fn build_success_criteria( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + goal_summary: Option<&str>, + recent_artifacts: &[String], + pending_requests: &[ReplayPendingRequestInput], +) -> Vec { + let mut criteria = Vec::new(); + + if let Some(summary) = goal_summary { + criteria.push(format!("结果应延续当前目标:{summary}")); + } + + let unfinished_todos = detail + .todo_items + .iter() + .filter(|item| !session_todo_completed(item)) + .map(|item| item.content.trim()) + .filter(|value| !value.is_empty()) + .collect::>(); + if !unfinished_todos.is_empty() { + criteria.push(format!( + "结果应至少推动这些未完成项:{}", + unfinished_todos.join(";") + )); + } + + if !pending_requests.is_empty() { + criteria.push( + "如果样本仍包含待处理请求,评分时必须确认这些请求是否已被正确处理或明确保留。" + .to_string(), + ); + } + + if !recent_artifacts.is_empty() { + criteria.push(format!( + "如果任务继续沿用已有产物,应优先验证这些文件没有偏离:{}", + recent_artifacts + .iter() + .take(3) + .map(|path| format!("`{path}`")) + .collect::>() + .join("、") + )); + } + + if !thread_read.queued_turns.is_empty() { + criteria.push("不要无意吞掉排队 turn;如要清空或改写,必须有明确理由。".to_string()); + } + + if criteria.is_empty() { + criteria.push("结果应与 input.json 描述的任务目标一致。".to_string()); + } + + criteria +} + +fn build_blocking_checks( + thread_read: &AgentRuntimeThreadReadModel, + pending_requests: &[ReplayPendingRequestInput], +) -> Vec { + let mut checks = Vec::new(); + + if let Some(summary) = thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.primary_blocking_summary.clone())) + { + checks.push(format!("当前主要阻塞:{summary}")); + } + + for request in pending_requests { + checks.push(format!( + "待处理请求 `{}`:{}", + request.request_id, + request + .title + .clone() + .or_else(|| request.prompt.clone()) + .unwrap_or_else(|| "需要确认该请求是否已解决".to_string()) + )); + } + + if !thread_read.queued_turns.is_empty() { + checks.push(format!( + "当前仍有 {} 条排队 turn,需确认 replay 评估是否把它们误判成已完成。", + thread_read.queued_turns.len() + )); + } + + if checks.is_empty() { + checks.push("当前没有额外阻塞检查项,按结果与证据判定即可。".to_string()); + } + + checks +} + +fn build_artifact_checks(recent_artifacts: &[String]) -> Vec { + if recent_artifacts.is_empty() { + return vec!["当前没有显式 artifact 快照,重点检查结果和证据链是否闭环。".to_string()]; + } + + recent_artifacts + .iter() + .take(4) + .map(|path| format!("确认 `{path}` 仍与当前目标一致,且没有被回放结果无意破坏。")) + .collect() +} + +fn infer_replay_suite_tags( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + handoff_bundle: &RuntimeHandoffBundleExportResult, + evidence_pack: &RuntimeEvidencePackExportResult, +) -> Vec { + let mut tags = Vec::new(); + + push_unique_text_tag(&mut tags, "conversation-runtime"); + push_unique_text_tag(&mut tags, "replay"); + push_unique_text_tag(&mut tags, "runtime-export"); + + if let Some(strategy) = normalize_optional_text(detail.execution_strategy.clone()) { + push_unique_owned_tag(&mut tags, format!("execution-strategy-{strategy}")); + } + + if !handoff_bundle.artifacts.is_empty() { + push_unique_text_tag(&mut tags, "handoff"); + } + + if !evidence_pack.artifacts.is_empty() { + push_unique_text_tag(&mut tags, "evidence"); + } + + if !thread_read.pending_requests.is_empty() { + push_unique_text_tag(&mut tags, "pending-request"); + } + + if !thread_read.queued_turns.is_empty() { + push_unique_text_tag(&mut tags, "queued-turn"); + } + + if !detail.child_subagent_sessions.is_empty() { + push_unique_text_tag(&mut tags, "subagent"); + } + + if !thread_read.incidents.is_empty() { + push_unique_text_tag(&mut tags, "incident"); + } + + tags +} + +fn infer_replay_failure_modes( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + let mut failure_modes = Vec::new(); + + if let Some(primary_blocking_kind) = thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.primary_blocking_kind.clone())) + { + push_unique_owned_tag(&mut failure_modes, primary_blocking_kind); + } + + if !thread_read.pending_requests.is_empty() { + push_unique_text_tag(&mut failure_modes, "pending_request"); + } + + if !thread_read.queued_turns.is_empty() { + push_unique_text_tag(&mut failure_modes, "queued_turn_backlog"); + } + + if !thread_read.incidents.is_empty() { + push_unique_text_tag(&mut failure_modes, "incident_present"); + } + + if detail + .todo_items + .iter() + .any(|item| !session_todo_completed(item)) + { + push_unique_text_tag(&mut failure_modes, "unfinished_todo"); + } + + if !detail.child_subagent_sessions.is_empty() { + push_unique_text_tag(&mut failure_modes, "subagent_in_progress"); + } + + if let Some(latest_turn_status) = thread_read + .diagnostics + .as_ref() + .and_then(|value| normalize_optional_text(value.latest_turn_status.clone())) + .or_else(|| { + detail + .turns + .last() + .map(|turn| turn.status.as_str().trim().to_string()) + .and_then(|value| normalize_optional_text(Some(value))) + }) + { + if latest_turn_status.contains("fail") { + push_unique_text_tag(&mut failure_modes, "turn_failed"); + } + if latest_turn_status.contains("interrupt") { + push_unique_text_tag(&mut failure_modes, "turn_interrupted"); + } + } + + failure_modes +} + +fn push_unique_text_tag(values: &mut Vec, value: &str) { + if !values.iter().any(|item| item == value) { + values.push(value.to_string()); + } +} + +fn push_unique_owned_tag(values: &mut Vec, value: String) { + if !values.iter().any(|item| item == &value) { + values.push(value); + } +} + +fn collect_latest_plan(detail: &SessionDetail) -> Option { + detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + AgentThreadItemPayload::Plan { text } => normalize_optional_text(Some(text.clone())), + _ => None, + }) +} + +fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { + detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + AgentThreadItemPayload::TurnSummary { text } => { + normalize_optional_text(Some(text.clone())) + } + _ => None, + }) +} + +fn collect_recent_artifact_paths(detail: &SessionDetail) -> Vec { + detail + .items + .iter() + .rev() + .filter_map(|item| match &item.payload { + AgentThreadItemPayload::FileArtifact { path, .. } => { + normalize_optional_text(Some(path.clone())) + } + _ => None, + }) + .take(MAX_RECENT_ARTIFACTS) + .collect() +} + +fn collect_recent_timeline_items(detail: &SessionDetail) -> Vec { + let mut items = detail + .items + .iter() + .rev() + .take(MAX_RECENT_TIMELINE_ITEMS) + .map(|item| ReplayTimelineItem { + item_id: item.id.clone(), + turn_id: item.turn_id.clone(), + payload_kind: item.payload.kind().to_string(), + status: item.status.as_str().to_string(), + summary: summarize_item_payload(&item.payload), + updated_at: item.updated_at.clone(), + }) + .collect::>(); + items.reverse(); + items +} + +fn collect_pending_request_inputs( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + thread_read + .pending_requests + .iter() + .take(MAX_PENDING_REQUESTS) + .map(|request| { + detail + .items + .iter() + .rev() + .find_map(|item| match &item.payload { + AgentThreadItemPayload::ApprovalRequest { + request_id, + action_type, + prompt, + tool_name, + arguments, + .. + } if request_id == &request.id => Some(ReplayPendingRequestInput { + request_id: request_id.clone(), + request_type: request.request_type.clone(), + title: normalize_optional_text(request.title.clone()), + action_type: normalize_optional_text(Some(action_type.clone())), + prompt: normalize_optional_text(prompt.clone()), + tool_name: normalize_optional_text(tool_name.clone()), + arguments: arguments.clone(), + questions: None, + }), + AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + .. + } if request_id == &request.id => Some(ReplayPendingRequestInput { + request_id: request_id.clone(), + request_type: request.request_type.clone(), + title: normalize_optional_text(request.title.clone()), + action_type: normalize_optional_text(Some(action_type.clone())), + prompt: normalize_optional_text(prompt.clone()), + tool_name: None, + arguments: None, + questions: questions + .as_ref() + .and_then(|value| serde_json::to_value(value).ok()), + }), + _ => None, + }) + .unwrap_or_else(|| ReplayPendingRequestInput { + request_id: request.id.clone(), + request_type: request.request_type.clone(), + title: normalize_optional_text(request.title.clone()), + action_type: None, + prompt: None, + tool_name: None, + arguments: None, + questions: None, + }) + }) + .collect() +} + +fn summarize_item_payload(payload: &AgentThreadItemPayload) -> Option { + match payload { + AgentThreadItemPayload::Plan { text } + | AgentThreadItemPayload::TurnSummary { text } + | AgentThreadItemPayload::AgentMessage { text, .. } + | AgentThreadItemPayload::Reasoning { text, .. } => { + normalize_optional_text(Some(truncate_text(text, 160))) + } + AgentThreadItemPayload::FileArtifact { path, .. } => { + normalize_optional_text(Some(path.clone())) + } + AgentThreadItemPayload::ApprovalRequest { + prompt, tool_name, .. + } => normalize_optional_text(prompt.clone().or_else(|| tool_name.clone())), + AgentThreadItemPayload::RequestUserInput { prompt, .. } => { + normalize_optional_text(prompt.clone()) + } + AgentThreadItemPayload::ToolCall { + tool_name, success, .. + } => Some(format!( + "{tool_name} ({})", + if success.unwrap_or(false) { + "success" + } else { + "unknown" + } + )), + AgentThreadItemPayload::CommandExecution { + command, exit_code, .. + } => Some(format!( + "{command} ({})", + exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "running".to_string()) + )), + AgentThreadItemPayload::WebSearch { query, action, .. } => { + normalize_optional_text(query.clone().or_else(|| action.clone())) + } + AgentThreadItemPayload::SubagentActivity { title, summary, .. } => { + normalize_optional_text(title.clone().or_else(|| summary.clone())) + } + AgentThreadItemPayload::Warning { message, .. } + | AgentThreadItemPayload::Error { message } => { + normalize_optional_text(Some(truncate_text(message, 160))) + } + AgentThreadItemPayload::ContextCompaction { + detail, trigger, .. + } => normalize_optional_text(detail.clone().or_else(|| trigger.clone())), + AgentThreadItemPayload::UserMessage { content } => { + normalize_optional_text(Some(truncate_text(content, 160))) + } + } +} + +fn truncate_text(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let truncated = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +fn pending_request_like_state(thread_read: &AgentRuntimeThreadReadModel) -> bool { + !thread_read.pending_requests.is_empty() + || thread_read + .diagnostics + .as_ref() + .and_then(|value| value.primary_blocking_kind.as_ref()) + .is_some_and(|value| value == "pending_request") +} + +fn child_subagent_runtime_status_label( + status: &crate::agent::ChildSubagentRuntimeStatus, +) -> &'static str { + match status { + crate::agent::ChildSubagentRuntimeStatus::Idle => "idle", + crate::agent::ChildSubagentRuntimeStatus::Queued => "queued", + crate::agent::ChildSubagentRuntimeStatus::Running => "running", + crate::agent::ChildSubagentRuntimeStatus::Completed => "completed", + crate::agent::ChildSubagentRuntimeStatus::Failed => "failed", + crate::agent::ChildSubagentRuntimeStatus::Aborted => "aborted", + crate::agent::ChildSubagentRuntimeStatus::Closed => "closed", + } +} + +fn session_todo_completed(item: &lime_agent::SessionTodoItem) -> bool { + session_todo_status_label(item) == "completed" +} + +fn session_todo_status_label(item: &lime_agent::SessionTodoItem) -> String { + serde_json::to_value(&item.status) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| "pending".to_string()) +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::QueuedTurnSnapshot; + use lime_core::database::dao::agent_timeline::{ + AgentRequestOption, AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload, + AgentThreadItemStatus, AgentThreadTurn, AgentThreadTurnStatus, + }; + use tempfile::TempDir; + + fn build_detail() -> SessionDetail { + SessionDetail { + id: "session-1".to_string(), + name: "P3 replay".to_string(), + created_at: 1, + updated_at: 2, + thread_id: "thread-1".to_string(), + model: Some("gpt-5.4".to_string()), + working_dir: Some("/tmp/workspace".to_string()), + workspace_id: Some("workspace-1".to_string()), + messages: Vec::new(), + execution_strategy: Some("react".to_string()), + execution_runtime: None, + turns: vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续把真实失败样本沉淀成 replay case".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-27T10:00:00Z".to_string(), + completed_at: Some("2026-03-27T10:02:00Z".to_string()), + error_message: None, + created_at: "2026-03-27T10:00:00Z".to_string(), + updated_at: "2026-03-27T10:02:00Z".to_string(), + }], + items: vec![ + AgentThreadItem { + id: "plan-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:05Z".to_string(), + completed_at: Some("2026-03-27T10:00:05Z".to_string()), + updated_at: "2026-03-27T10:00:05Z".to_string(), + payload: AgentThreadItemPayload::Plan { + text: "先复用 handoff 与 evidence,再导出 replay case".to_string(), + }, + }, + AgentThreadItem { + id: "request-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + status: AgentThreadItemStatus::InProgress, + started_at: "2026-03-27T10:00:20Z".to_string(), + completed_at: None, + updated_at: "2026-03-27T10:00:20Z".to_string(), + payload: AgentThreadItemPayload::RequestUserInput { + request_id: "req-1".to_string(), + action_type: "ask_user".to_string(), + prompt: Some("请选择这条 replay case 的优先级".to_string()), + questions: Some(vec![AgentRequestQuestion { + question: "优先级是 P1 还是 P2?".to_string(), + header: Some("优先级".to_string()), + options: Some(vec![ + AgentRequestOption { + label: "P1".to_string(), + description: Some("进入主线".to_string()), + }, + AgentRequestOption { + label: "P2".to_string(), + description: Some("后续补".to_string()), + }, + ]), + multi_select: Some(false), + }]), + response: None, + }, + }, + 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:01:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + updated_at: "2026-03-27T10:01:00Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: ".lime/artifacts/thread-1/report.md".to_string(), + source: "artifact_snapshot".to_string(), + content: None, + metadata: None, + }, + }, + AgentThreadItem { + id: "summary-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:01:30Z".to_string(), + completed_at: Some("2026-03-27T10:01:30Z".to_string()), + updated_at: "2026-03-27T10:01:30Z".to_string(), + payload: AgentThreadItemPayload::TurnSummary { + text: "已完成 handoff 与 evidence,下一步把真实案例沉淀成 replay case。" + .to_string(), + }, + }, + ], + todo_items: vec![lime_agent::SessionTodoItem { + content: "补 replay UI 入口".to_string(), + status: serde_json::from_value(json!("in_progress")).expect("status"), + active_form: None, + }], + child_subagent_sessions: vec![crate::agent::ChildSubagentSession { + id: "sub-1".to_string(), + name: "Eval Reviewer".to_string(), + created_at: 1, + updated_at: 2, + session_type: "subagent".to_string(), + model: None, + provider_name: None, + working_dir: None, + workspace_id: None, + task_summary: Some("复查 replay case 是否可评分".to_string()), + role_hint: Some("reviewer".to_string()), + origin_tool: None, + created_from_turn_id: None, + blueprint_role_id: None, + blueprint_role_label: None, + profile_id: None, + profile_name: None, + role_key: None, + team_preset_id: None, + theme: None, + output_contract: None, + skill_ids: Vec::new(), + skills: Vec::new(), + runtime_status: Some(crate::agent::ChildSubagentRuntimeStatus::Running), + latest_turn_status: None, + queued_turn_count: 0, + team_phase: None, + team_parallel_budget: None, + team_active_count: None, + team_queued_count: None, + provider_concurrency_group: None, + provider_parallel_budget: None, + queue_reason: None, + retryable_overload: false, + }], + subagent_parent_context: None, + } + } + + fn build_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "waiting_request".to_string(), + active_turn_id: Some("turn-1".to_string()), + pending_requests: vec![crate::commands::aster_agent_cmd::AgentRuntimeRequestView { + id: "req-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: Some("request-1".to_string()), + request_type: "ask_user".to_string(), + status: "pending".to_string(), + title: Some("确认 replay 样本优先级".to_string()), + payload: None, + decision: None, + scope: None, + created_at: None, + resolved_at: None, + }], + last_outcome: None, + incidents: Vec::new(), + queued_turns: vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "继续补 replay UI".to_string(), + message_text: "继续补 replay UI".to_string(), + created_at: 3, + image_count: 0, + position: 1, + }], + interrupt_state: None, + updated_at: Some("2026-03-27T10:02:00Z".to_string()), + diagnostics: Some(crate::commands::aster_agent_cmd::AgentRuntimeThreadDiagnostics { + latest_turn_status: Some("completed".to_string()), + latest_turn_started_at: None, + latest_turn_completed_at: None, + latest_turn_updated_at: None, + latest_turn_elapsed_seconds: None, + latest_turn_stalled_seconds: None, + latest_turn_error_message: None, + interrupt_reason: None, + runtime_interrupt_source: None, + runtime_interrupt_requested_at: None, + runtime_interrupt_wait_seconds: None, + warning_count: 0, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + oldest_pending_request_wait_seconds: None, + primary_blocking_kind: Some("pending_request".to_string()), + primary_blocking_summary: Some("等待用户确认 replay 样本优先级".to_string()), + latest_warning: None, + latest_context_compaction: None, + latest_failed_tool: None, + latest_failed_command: None, + latest_pending_request: Some( + crate::commands::aster_agent_cmd::AgentRuntimeDiagnosticPendingRequestSample { + request_id: "req-1".to_string(), + turn_id: Some("turn-1".to_string()), + request_type: "ask_user".to_string(), + title: Some("确认 replay 样本优先级".to_string()), + waited_seconds: Some(15), + created_at: None, + }, + ), + }), + } + } + + #[test] + fn should_export_runtime_replay_case_to_workspace() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + + let result = + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert_eq!( + result.replay_relative_root, + ".lime/harness/sessions/session-1/replay" + ); + assert_eq!(result.artifacts.len(), 4); + assert_eq!(result.linked_handoff_artifact_count, 4); + assert_eq!(result.linked_evidence_artifact_count, 4); + assert_eq!(result.pending_request_count, 1); + assert_eq!(result.queued_turn_count, 1); + assert_eq!(result.recent_artifact_count, 1); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let grader_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/grader.md"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + assert!(input_path.exists()); + assert!(expected_path.exists()); + assert!(grader_path.exists()); + assert!(links_path.exists()); + assert!(temp_dir + .path() + .join(".lime/harness/sessions/session-1/handoff.md") + .exists()); + assert!(temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/summary.md") + .exists()); + + let input = fs::read_to_string(input_path).expect("input"); + assert!(input.contains( + "\"goalSummary\": \"已完成 handoff 与 evidence,下一步把真实案例沉淀成 replay case。\"" + )); + assert!(input.contains("\"requestId\": \"req-1\"")); + assert!(input.contains("\"recentArtifacts\"")); + assert!(input.contains("\"classification\"")); + assert!(input.contains("\"suiteTags\"")); + assert!(input.contains("\"failureModes\"")); + assert!(input.contains("\"pending_request\"")); + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("不要要求与原始会话完全相同的工具调用顺序")); + assert!(expected.contains("等待用户确认 replay 样本优先级")); + + let grader = fs::read_to_string(grader_path).expect("grader"); + assert!(grader.contains("只评结果,不评路径")); + assert!(grader.contains("verdict: pass | fail | needs_review")); + + let links = fs::read_to_string(links_path).expect("links"); + assert!(links.contains("\"handoffBundle\"")); + assert!(links.contains("\"evidencePack\"")); + } +} diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs new file mode 100644 index 000000000..71856a75c --- /dev/null +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -0,0 +1,625 @@ +//! Runtime review decision 模板导出服务 +//! +//! 将外部 Claude Code / Codex 的分析结论回挂为 +//! Lime 工作区内可版本化的人工审核与决策记录模板。 +//! 这条链只导出 review-decision 模板,不在 Lime 内自动批准或自动应用修复。 + +use crate::agent::SessionDetail; +use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::services::runtime_analysis_handoff_service::{ + export_runtime_analysis_handoff, RuntimeAnalysisArtifact, RuntimeAnalysisHandoffExportResult, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +const SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions"; +const REVIEW_DIR_NAME: &str = "review"; +const REVIEW_DECISION_MARKDOWN_FILE_NAME: &str = "review-decision.md"; +const REVIEW_DECISION_JSON_FILE_NAME: &str = "review-decision.json"; +const DEFAULT_DECISION_STATUS: &str = "pending_review"; +const DEFAULT_RISK_LEVEL: &str = "unknown"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeReviewDecisionArtifactKind { + ReviewDecisionMarkdown, + ReviewDecisionJson, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeReviewDecisionArtifact { + pub kind: RuntimeReviewDecisionArtifactKind, + pub title: String, + pub relative_path: String, + pub absolute_path: String, + pub bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeReviewDecisionTemplateExportResult { + pub session_id: String, + pub thread_id: String, + pub workspace_id: Option, + pub workspace_root: String, + pub review_relative_root: String, + pub review_absolute_root: String, + pub analysis_relative_root: String, + pub analysis_absolute_root: String, + pub handoff_bundle_relative_root: String, + pub evidence_pack_relative_root: String, + pub replay_case_relative_root: String, + pub exported_at: String, + pub title: String, + pub thread_status: String, + pub latest_turn_status: Option, + pub pending_request_count: usize, + pub queued_turn_count: usize, + pub default_decision_status: String, + pub review_checklist: Vec, + pub analysis_artifacts: Vec, + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewDecisionDocument { + schema_version: String, + contract_shape: String, + exported_at: String, + source: ReviewDecisionSource, + review_context: ReviewDecisionContext, + decision: ReviewDecisionContent, + decision_status_options: Vec, + risk_level_options: Vec, + review_checklist: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewDecisionSource { + derived_from: Vec, + upstream_alignment: ReviewDecisionUpstreamAlignment, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewDecisionUpstreamAlignment { + execution_environment_reference: String, + runtime_fact_source: String, + product_surface: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewDecisionContext { + session_id: String, + thread_id: String, + workspace_id: Option, + title: String, + thread_status: String, + latest_turn_status: Option, + pending_request_count: usize, + queued_turn_count: usize, + analysis_relative_root: String, + handoff_bundle_relative_root: String, + evidence_pack_relative_root: String, + replay_case_relative_root: String, + analysis_artifacts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewDecisionArtifactReference { + kind: String, + title: String, + relative_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewDecisionContent { + decision_status: String, + decision_summary: String, + chosen_fix_strategy: String, + risk_level: String, + risk_tags: Vec, + human_reviewer: String, + reviewed_at: Option, + followup_actions: Vec, + regression_requirements: Vec, + notes: String, +} + +pub fn export_runtime_review_decision_template( + detail: &SessionDetail, + thread_read: &AgentRuntimeThreadReadModel, + workspace_root: &Path, +) -> Result { + let session_id = detail.id.trim(); + if session_id.is_empty() { + return Err("session_id 不能为空,无法导出 review decision 模板".to_string()); + } + + let thread_id = detail.thread_id.trim(); + if thread_id.is_empty() { + return Err("thread_id 不能为空,无法导出 review decision 模板".to_string()); + } + + let workspace_root = workspace_root + .canonicalize() + .unwrap_or_else(|_| workspace_root.to_path_buf()); + let exported_at = Utc::now().to_rfc3339(); + let review_relative_root = format!("{SESSION_RELATIVE_ROOT}/{session_id}/{REVIEW_DIR_NAME}"); + let review_absolute_root = + workspace_root.join(review_relative_root.replace('/', std::path::MAIN_SEPARATOR_STR)); + + let analysis = export_runtime_analysis_handoff(detail, thread_read, workspace_root.as_path())?; + + fs::create_dir_all(&review_absolute_root).map_err(|error| { + format!( + "创建 review decision 目录失败 {}: {error}", + review_absolute_root.display() + ) + })?; + + let review_checklist = build_review_checklist(); + let document = build_review_decision_document(&analysis, &exported_at, &review_checklist); + let markdown = build_review_decision_markdown(&document); + let json = serde_json::to_string_pretty(&document) + .map_err(|error| format!("序列化 review decision json 失败: {error}"))?; + + let artifacts = vec![ + write_review_decision_artifact( + RuntimeReviewDecisionArtifactKind::ReviewDecisionMarkdown, + "人工审核记录", + &review_absolute_root.join(REVIEW_DECISION_MARKDOWN_FILE_NAME), + &format!("{review_relative_root}/{REVIEW_DECISION_MARKDOWN_FILE_NAME}"), + markdown.as_bytes(), + )?, + write_review_decision_artifact( + RuntimeReviewDecisionArtifactKind::ReviewDecisionJson, + "人工审核记录 JSON", + &review_absolute_root.join(REVIEW_DECISION_JSON_FILE_NAME), + &format!("{review_relative_root}/{REVIEW_DECISION_JSON_FILE_NAME}"), + json.as_bytes(), + )?, + ]; + + Ok(RuntimeReviewDecisionTemplateExportResult { + session_id: analysis.session_id.clone(), + thread_id: analysis.thread_id.clone(), + workspace_id: analysis.workspace_id.clone(), + workspace_root: analysis.workspace_root.clone(), + review_relative_root, + review_absolute_root: to_portable_path(&review_absolute_root.to_string_lossy()), + analysis_relative_root: analysis.analysis_relative_root.clone(), + analysis_absolute_root: analysis.analysis_absolute_root.clone(), + handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(), + evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(), + replay_case_relative_root: analysis.replay_case_relative_root.clone(), + exported_at, + title: analysis.title.clone(), + thread_status: analysis.thread_status.clone(), + latest_turn_status: analysis.latest_turn_status.clone(), + pending_request_count: analysis.pending_request_count, + queued_turn_count: analysis.queued_turn_count, + default_decision_status: DEFAULT_DECISION_STATUS.to_string(), + review_checklist, + analysis_artifacts: analysis.artifacts.clone(), + artifacts, + }) +} + +fn build_review_decision_document( + analysis: &RuntimeAnalysisHandoffExportResult, + exported_at: &str, + review_checklist: &[String], +) -> ReviewDecisionDocument { + ReviewDecisionDocument { + schema_version: "v1".to_string(), + contract_shape: "lime_review_decision_template".to_string(), + exported_at: exported_at.to_string(), + source: ReviewDecisionSource { + derived_from: vec![ + "lime_external_analysis_handoff".to_string(), + "runtime_handoff_bundle".to_string(), + "runtime_evidence_pack".to_string(), + "runtime_replay_case".to_string(), + ], + upstream_alignment: ReviewDecisionUpstreamAlignment { + execution_environment_reference: "codex".to_string(), + runtime_fact_source: "aster-rust".to_string(), + product_surface: "lime".to_string(), + }, + }, + review_context: ReviewDecisionContext { + session_id: analysis.session_id.clone(), + thread_id: analysis.thread_id.clone(), + workspace_id: analysis.workspace_id.clone(), + title: analysis.title.clone(), + thread_status: analysis.thread_status.clone(), + latest_turn_status: analysis.latest_turn_status.clone(), + pending_request_count: analysis.pending_request_count, + queued_turn_count: analysis.queued_turn_count, + analysis_relative_root: analysis.analysis_relative_root.clone(), + handoff_bundle_relative_root: analysis.handoff_bundle_relative_root.clone(), + evidence_pack_relative_root: analysis.evidence_pack_relative_root.clone(), + replay_case_relative_root: analysis.replay_case_relative_root.clone(), + analysis_artifacts: analysis + .artifacts + .iter() + .map(|artifact| ReviewDecisionArtifactReference { + kind: review_analysis_artifact_kind_key(&artifact.kind).to_string(), + title: artifact.title.clone(), + relative_path: artifact.relative_path.clone(), + }) + .collect(), + }, + decision: ReviewDecisionContent { + decision_status: DEFAULT_DECISION_STATUS.to_string(), + decision_summary: String::new(), + chosen_fix_strategy: String::new(), + risk_level: DEFAULT_RISK_LEVEL.to_string(), + risk_tags: Vec::new(), + human_reviewer: String::new(), + reviewed_at: None, + followup_actions: Vec::new(), + regression_requirements: Vec::new(), + notes: String::new(), + }, + decision_status_options: vec![ + "accepted".to_string(), + "deferred".to_string(), + "rejected".to_string(), + "needs_more_evidence".to_string(), + DEFAULT_DECISION_STATUS.to_string(), + ], + risk_level_options: vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string(), + DEFAULT_RISK_LEVEL.to_string(), + ], + review_checklist: review_checklist.to_vec(), + } +} + +fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { + let checklist = document + .review_checklist + .iter() + .map(|item| format!("- [ ] {item}")) + .collect::>() + .join("\n"); + let analysis_files = document + .review_context + .analysis_artifacts + .iter() + .map(|artifact| format!("- `{}`:`{}`", artifact.title, artifact.relative_path)) + .collect::>() + .join("\n"); + + format!( + "# Lime 人工审核与决策记录\n\n\ +> 状态:`{decision_status}`\n\ +> 导出时间:`{exported_at}`\n\ +> 说明:这份模板用于把外部 Claude Code / Codex 的分析结论,回挂为 Lime 工作区内可版本化的人工审核记录;最终是否接受修复仍由开发者决定。\n\n\ +## 1. 审核上下文\n\ +- 标题:{title}\n\ +- session_id:`{session_id}`\n\ +- thread_id:`{thread_id}`\n\ +- 线程状态:`{thread_status}`\n\ +- 最新 Turn:`{latest_turn_status}`\n\ +- 待处理请求:`{pending_request_count}`\n\ +- 排队任务:`{queued_turn_count}`\n\ +- analysis 目录:`{analysis_relative_root}`\n\ +- handoff 目录:`{handoff_bundle_relative_root}`\n\ +- evidence 目录:`{evidence_pack_relative_root}`\n\ +- replay 目录:`{replay_case_relative_root}`\n\n\ +### 关联分析文件\n\ +{analysis_files}\n\n\ +## 2. 上游对齐\n\ +- 执行环境参照:`codex`\n\ +- 运行时事实源:`aster-rust`\n\ +- 产品承接面:`lime`\n\n\ +## 3. 审核清单\n\ +{checklist}\n\n\ +## 4. 决策状态\n\ +- 当前值:`{decision_status}`\n\ +- 可选值:`accepted` / `deferred` / `rejected` / `needs_more_evidence`\n\n\ +## 5. 决策摘要\n\ +待填写。\n\n\ +## 6. 采用的修复策略\n\ +待填写。\n\n\ +## 7. 风险等级与标签\n\ +- 风险等级:`{risk_level}`\n\ +- 风险标签:待填写\n\n\ +## 8. 回归要求\n\ +- 待填写\n\n\ +## 9. 后续动作\n\ +- 待填写\n\n\ +## 10. 审核备注\n\ +- 审核人:待填写\n\ +- 审核时间:待填写\n\ +- 备注:待填写\n", + decision_status = document.decision.decision_status, + exported_at = document.exported_at, + title = empty_fallback(&document.review_context.title, "未命名"), + session_id = document.review_context.session_id, + thread_id = document.review_context.thread_id, + thread_status = empty_fallback(&document.review_context.thread_status, "unknown"), + latest_turn_status = document + .review_context + .latest_turn_status + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("unknown"), + pending_request_count = document.review_context.pending_request_count, + queued_turn_count = document.review_context.queued_turn_count, + analysis_relative_root = document.review_context.analysis_relative_root, + handoff_bundle_relative_root = document.review_context.handoff_bundle_relative_root, + evidence_pack_relative_root = document.review_context.evidence_pack_relative_root, + replay_case_relative_root = document.review_context.replay_case_relative_root, + analysis_files = if analysis_files.is_empty() { + "- 待补充".to_string() + } else { + analysis_files + }, + checklist = if checklist.is_empty() { + "- [ ] 待补充审核清单".to_string() + } else { + checklist + }, + risk_level = document.decision.risk_level, + ) +} + +fn build_review_checklist() -> Vec { + vec![ + "先阅读 analysis-brief.md 与 analysis-context.json,再决定是否进入修复。".to_string(), + "确认根因判断引用的是现有证据,而不是外部 AI 的猜测扩写。".to_string(), + "确认修复范围仍落在 current 主链,没有把 compat / deprecated 路径重新接回主线。" + .to_string(), + "明确最小回归集合,包括 contract、GUI smoke、Replay 或其它定向验证。".to_string(), + "把最终决定记录为 accepted / deferred / rejected / needs_more_evidence 之一。".to_string(), + ] +} + +fn review_analysis_artifact_kind_key( + kind: &crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind, +) -> &'static str { + match kind { + crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisBrief => { + "analysis_brief" + } + crate::services::runtime_analysis_handoff_service::RuntimeAnalysisArtifactKind::AnalysisContext => { + "analysis_context" + } + } +} + +fn write_review_decision_artifact( + kind: RuntimeReviewDecisionArtifactKind, + title: &str, + absolute_path: &Path, + relative_path: &str, + contents: &[u8], +) -> Result { + fs::write(absolute_path, contents).map_err(|error| { + format!( + "写入 review decision 文件失败 {}: {error}", + absolute_path.display() + ) + })?; + + Ok(RuntimeReviewDecisionArtifact { + kind, + title: title.to_string(), + relative_path: relative_path.to_string(), + absolute_path: to_portable_path(&absolute_path.to_string_lossy()), + bytes: contents.len(), + }) +} + +fn empty_fallback<'a>(value: &'a str, fallback: &'a str) -> &'a str { + if value.trim().is_empty() { + fallback + } else { + value + } +} + +fn to_portable_path(value: &str) -> String { + value.replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::QueuedTurnSnapshot; + use crate::commands::aster_agent_cmd::{ + AgentRuntimeDiagnosticPendingRequestSample, AgentRuntimeRequestView, + AgentRuntimeThreadDiagnostics, + }; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, + AgentThreadTurnStatus, + }; + use serde_json::json; + use tempfile::TempDir; + + fn build_detail() -> SessionDetail { + SessionDetail { + id: "session-1".to_string(), + thread_id: "thread-1".to_string(), + workspace_id: Some("workspace-1".to_string()), + name: "Harness Review Demo".to_string(), + model: Some("gpt-5.4".to_string()), + working_dir: Some("/tmp/workspace".to_string()), + created_at: 1, + updated_at: 2, + execution_strategy: Some("react".to_string()), + messages: Vec::new(), + execution_runtime: None, + turns: vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "请导出 review decision 模板。".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-27T10:00:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + error_message: None, + created_at: "2026-03-27T10:00:00Z".to_string(), + updated_at: "2026-03-27T10:01:00Z".to_string(), + }], + items: vec![ + AgentThreadItem { + id: "item-plan-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:10Z".to_string(), + completed_at: Some("2026-03-27T10:00:10Z".to_string()), + updated_at: "2026-03-27T10:00:10Z".to_string(), + payload: AgentThreadItemPayload::Plan { + text: "补 review decision 模板导出".to_string(), + }, + }, + AgentThreadItem { + id: "item-summary-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 2, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:01:00Z".to_string(), + completed_at: Some("2026-03-27T10:01:00Z".to_string()), + updated_at: "2026-03-27T10:01:00Z".to_string(), + payload: AgentThreadItemPayload::TurnSummary { + text: "外部分析已可导出,下一步需要固定人工审核记录。".to_string(), + }, + }, + ], + todo_items: vec![lime_agent::SessionTodoItem { + content: "导出人工审核记录".to_string(), + status: serde_json::from_value(json!("in_progress")).expect("status"), + active_form: None, + }], + child_subagent_sessions: vec![], + subagent_parent_context: None, + } + } + + fn build_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "waiting_request".to_string(), + active_turn_id: Some("turn-1".to_string()), + pending_requests: vec![AgentRuntimeRequestView { + id: "req-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: Some("request-1".to_string()), + request_type: "approval_request".to_string(), + status: "pending".to_string(), + title: Some("是否接受最小修复".to_string()), + payload: None, + decision: None, + scope: None, + created_at: None, + resolved_at: None, + }], + last_outcome: None, + incidents: Vec::new(), + queued_turns: vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "继续补 review decision".to_string(), + message_text: "继续补 review decision".to_string(), + created_at: 3, + image_count: 0, + position: 1, + }], + interrupt_state: None, + updated_at: Some("2026-03-27T10:01:20Z".to_string()), + diagnostics: Some(AgentRuntimeThreadDiagnostics { + latest_turn_status: Some("action_required".to_string()), + latest_turn_started_at: None, + latest_turn_completed_at: None, + latest_turn_updated_at: None, + latest_turn_elapsed_seconds: None, + latest_turn_stalled_seconds: None, + latest_turn_error_message: None, + interrupt_reason: None, + runtime_interrupt_source: None, + runtime_interrupt_requested_at: None, + runtime_interrupt_wait_seconds: None, + warning_count: 0, + context_compaction_count: 0, + failed_tool_call_count: 0, + failed_command_count: 0, + pending_request_count: 1, + oldest_pending_request_wait_seconds: None, + primary_blocking_kind: Some("pending_request".to_string()), + primary_blocking_summary: Some("等待人工审核修复方案".to_string()), + latest_warning: None, + latest_context_compaction: None, + latest_failed_tool: None, + latest_failed_command: None, + latest_pending_request: Some(AgentRuntimeDiagnosticPendingRequestSample { + request_id: "req-1".to_string(), + turn_id: Some("turn-1".to_string()), + request_type: "approval_request".to_string(), + title: Some("是否接受最小修复".to_string()), + waited_seconds: Some(10), + created_at: None, + }), + }), + } + } + + #[test] + fn should_export_runtime_review_decision_template_to_workspace() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let thread_read = build_thread_read(); + + let result = + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + assert_eq!( + result.review_relative_root, + ".lime/harness/sessions/session-1/review" + ); + assert_eq!(result.default_decision_status, "pending_review"); + assert_eq!(result.artifacts.len(), 2); + assert_eq!(result.analysis_artifacts.len(), 2); + assert!(!result.review_checklist.is_empty()); + + 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"); + + assert!(markdown_path.exists()); + assert!(json_path.exists()); + + let markdown = fs::read_to_string(markdown_path).expect("markdown"); + assert!(markdown.contains("人工审核与决策记录")); + assert!(markdown.contains("analysis-brief.md")); + assert!(markdown.contains("aster-rust")); + assert!(markdown.contains("pending_review")); + + 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\"")); + } +} diff --git a/src-tauri/src/services/site_adapter_registry.rs b/src-tauri/src/services/site_adapter_registry.rs index 08da049a3..34bdc5895 100644 --- a/src-tauri/src/services/site_adapter_registry.rs +++ b/src-tauri/src/services/site_adapter_registry.rs @@ -386,7 +386,7 @@ fn load_site_adapters_from_str( document .adapters .into_iter() - .map(|entry| manifest_entry_to_spec(entry, dir, source_kind)) + .map(|entry| manifest_entry_to_spec(normalize_manifest_entry(entry), dir, source_kind)) .collect() } @@ -429,6 +429,40 @@ fn manifest_entry_to_spec( }) } +fn normalize_manifest_entry(mut entry: SiteAdapterManifestEntry) -> SiteAdapterManifestEntry { + if should_upgrade_legacy_github_search_entry(&entry) { + entry.entry = SiteAdapterEntryManifest::UrlTemplate { + template: "https://github.com/search?q={{query|urlencode}}&type=repositories" + .to_string(), + }; + } + + entry +} + +fn should_upgrade_legacy_github_search_entry(entry: &SiteAdapterManifestEntry) -> bool { + if normalize_site_adapter_name(&entry.name) != "github/search" { + return false; + } + + let has_query_arg = entry.args.iter().any(|arg| { + arg.name == "query" && matches!(arg.arg_type, SiteAdapterArgTypeManifest::String) + }); + if !has_query_arg { + return false; + } + + matches!( + &entry.entry, + SiteAdapterEntryManifest::FixedUrl { url } + if normalize_fixed_url(url) == "https://github.com/search" + ) +} + +fn normalize_fixed_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_ascii_lowercase() +} + fn extract_site_adapter_catalog_from_bootstrap_payload<'a>( payload: &'a Value, ) -> Option<&'a Value> { @@ -502,7 +536,7 @@ fn write_server_synced_catalog_to_dir( format!("写入站点适配器脚本失败 {}: {error}", script_path.display()) })?; - adapters.push(SiteAdapterManifestEntry { + adapters.push(normalize_manifest_entry(SiteAdapterManifestEntry { name: normalize_required_text(&entry.name, "name")?, domain: normalize_required_text(&entry.domain, "domain")?, description: normalize_required_text(&entry.description, "description")?, @@ -514,7 +548,7 @@ fn write_server_synced_catalog_to_dir( entry: entry.entry, script_file, source_version: normalize_optional_text(entry.source_version), - }); + })); } let document = SiteAdapterRegistryDocument { @@ -842,6 +876,64 @@ mod tests { assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1")); } + #[test] + fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() { + let temp_dir = tempdir().expect("temp dir should exist"); + let dir = temp_dir.path(); + fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist"); + fs::write( + dir.join("index.json"), + r#" + { + "adapters": [ + { + "name": "github/search", + "domain": "github.com", + "description": "server synced", + "read_only": true, + "capabilities": ["search"], + "args": [ + { + "name": "query", + "description": "搜索关键词", + "required": true, + "arg_type": "string", + "example": "mcp" + } + ], + "example": "github/search {\"query\":\"mcp\"}", + "entry": { + "kind": "fixed_url", + "url": "https://github.com/search" + }, + "script_file": "scripts/github-search.js", + "source_version": "sync-legacy" + } + ] + } + "#, + ) + .expect("index should write"); + fs::write( + dir.join("scripts/github-search.js"), + "async () => ({ ok: true })", + ) + .expect("script should write"); + + let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::ServerSynced) + .expect("server synced adapters should load"); + let github = adapters + .iter() + .find(|adapter| adapter.name == "github/search") + .expect("github/search should exist"); + + let mut args = Map::new(); + args.insert("query".to_string(), Value::String("mcp".to_string())); + + let url = build_entry_url(github, &args).expect("entry url should build"); + assert_eq!(url, "https://github.com/search?q=mcp&type=repositories"); + } + #[test] fn should_extract_site_adapter_catalog_from_nested_bootstrap_payload() { let payload = serde_json::json!({ diff --git a/src-tauri/src/services/site_capability_service.rs b/src-tauri/src/services/site_capability_service.rs index a9a3f9a47..3779eaffd 100644 --- a/src-tauri/src/services/site_capability_service.rs +++ b/src-tauri/src/services/site_capability_service.rs @@ -3,17 +3,22 @@ use crate::commands::webview_cmd::{ open_cdp_session_global, shared_browser_runtime, BrowserSessionStateRequest, ListCdpTargetsRequest, OpenCdpSessionRequest, }; -use crate::content::{ContentCreateRequest, ContentManager, ContentType}; +use crate::content::{ContentCreateRequest, ContentManager, ContentType, ContentUpdateRequest}; use crate::database::{lock_db, DbConnection}; use crate::services::site_adapter_registry::{ build_entry_url, find_site_adapter_spec, load_site_adapter_specs, normalize_site_adapter_name, SiteAdapterArgType, SiteAdapterSpec, }; use lime_browser_runtime::{CdpSessionState, CdpTargetInfo}; -use lime_core::database::dao::browser_profile::{BrowserProfileDao, BrowserProfileTransportKind}; -use lime_server::chrome_bridge::{self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult}; +use lime_core::database::dao::browser_profile::{ + BrowserProfileDao, BrowserProfileRecord, BrowserProfileTransportKind, +}; +use lime_server::chrome_bridge::{ + self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeObserverSnapshot, +}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use std::collections::HashSet; use std::time::{Duration, Instant}; use url::Url; @@ -22,6 +27,7 @@ const DEFAULT_TIMEOUT_MS: u64 = 20_000; const MIN_ADAPTER_EVALUATE_TIMEOUT_MS: u64 = 30_000; const MAX_TIMEOUT_MS: u64 = 120_000; const EXPLICIT_PROJECT_SAVE_SOURCE: &str = "explicit_project"; +const EXPLICIT_CONTENT_SAVE_SOURCE: &str = "explicit_content"; #[derive(Debug, Clone, Serialize)] pub struct SiteAdapterArgumentDefinition { @@ -33,7 +39,7 @@ pub struct SiteAdapterArgumentDefinition { pub example: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SiteAdapterDefinition { pub name: String, pub domain: String, @@ -51,6 +57,18 @@ pub struct SiteAdapterDefinition { pub source_version: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SiteAdapterRecommendation { + pub adapter: SiteAdapterDefinition, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_id: Option, + pub entry_url: String, + pub score: u32, +} + #[derive(Debug, Clone, Deserialize)] pub struct RunSiteAdapterRequest { pub adapter_name: String, @@ -63,6 +81,8 @@ pub struct RunSiteAdapterRequest { #[serde(default)] pub timeout_ms: Option, #[serde(default)] + pub content_id: Option, + #[serde(default)] pub project_id: Option, #[serde(default)] pub save_title: Option, @@ -90,6 +110,8 @@ pub struct SiteAdapterRunResult { #[serde(skip_serializing_if = "Option::is_none")] pub auth_hint: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub report_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub saved_content: Option, #[serde(skip_serializing_if = "Option::is_none")] pub saved_project_id: Option, @@ -105,7 +127,10 @@ pub struct SiteAdapterRunResult { #[derive(Debug, Clone, Deserialize)] pub struct SaveSiteAdapterResultRequest { - pub project_id: String, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub content_id: Option, #[serde(default)] pub save_title: Option, pub run_request: RunSiteAdapterRequest, @@ -126,6 +151,29 @@ struct AdapterExecutionState { source_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExistingSessionTabRecord { + id: String, + index: i64, + url: Option, + active: bool, +} + +#[derive(Debug, Clone)] +struct ExistingSessionRecommendationContext { + profile_key: String, + current_url: Option, + tabs: Vec, +} + +#[derive(Debug, Clone)] +struct SiteAdapterRecommendationCandidate { + reason: String, + profile_key: Option, + target_id: Option, + score: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SiteAdapterTransportRoute { ManagedCdp, @@ -220,6 +268,22 @@ pub fn get_site_adapter(name: &str) -> Option { .map(|spec| build_adapter_definition(&spec)) } +pub async fn recommend_site_adapters( + db: &DbConnection, + limit: Option, +) -> Result, String> { + let specs = load_site_adapter_specs()?; + let profiles = load_active_browser_profiles(db)?; + let attached_contexts = load_existing_session_recommendation_contexts(&profiles).await; + + Ok(rank_site_adapter_recommendations( + &specs, + &profiles, + &attached_contexts, + limit, + )) +} + pub fn build_site_result_document_title(adapter_name: &str, custom_title: Option<&str>) -> String { let normalized_custom_title = custom_title .map(str::trim) @@ -324,16 +388,7 @@ pub fn save_site_result_to_project( let manager = ContentManager::new(db.clone()); let title = build_site_result_document_title(&adapter.name, save_title); let body = build_site_result_document_body(adapter, request, result); - let metadata = serde_json::json!({ - "resourceKind": "document", - "siteAdapterName": adapter.name, - "siteAdapterDomain": adapter.domain, - "siteAdapterProfileKey": result.profile_key, - "siteAdapterEntryUrl": result.entry_url, - "siteAdapterSourceUrl": result.source_url, - "siteAdapterSourceKind": adapter.source_kind, - "siteAdapterSourceVersion": adapter.source_version, - }); + let metadata = Value::Object(build_site_result_metadata_map(adapter, result, true)); let content = manager .create(ContentCreateRequest { project_id: normalized_project_id.to_string(), @@ -352,6 +407,46 @@ pub fn save_site_result_to_project( }) } +pub fn save_site_result_to_content( + db: &DbConnection, + content_id: &str, + adapter: &SiteAdapterDefinition, + request: &RunSiteAdapterRequest, + result: &SiteAdapterRunResult, +) -> Result { + let normalized_content_id = content_id.trim(); + if normalized_content_id.is_empty() { + return Err("content_id 不能为空".to_string()); + } + + let manager = ContentManager::new(db.clone()); + let Some(existing_content) = manager.get(&normalized_content_id.to_string())? else { + return Err(format!("未找到要写回的内容: {normalized_content_id}")); + }; + + let body = build_site_result_document_body(adapter, request, result); + let mut metadata = existing_content + .metadata + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + metadata.extend(build_site_result_metadata_map(adapter, result, false)); + + let updated = manager.update( + &normalized_content_id.to_string(), + ContentUpdateRequest { + body: Some(body), + metadata: Some(Value::Object(metadata)), + ..Default::default() + }, + )?; + + Ok(SavedSiteAdapterContent { + content_id: updated.id, + project_id: updated.project_id, + title: updated.title, + }) +} + pub fn save_existing_site_result_to_project( db: &DbConnection, request: SaveSiteAdapterResultRequest, @@ -363,9 +458,21 @@ pub fn save_existing_site_result_to_project( let adapter_name = normalize_site_adapter_name(&request.run_request.adapter_name); let adapter = get_site_adapter(&adapter_name).ok_or_else(|| "未找到对应的站点适配器".to_string())?; + if let Some(content_id) = normalize_optional_content_id(request.content_id.as_deref()) { + return save_site_result_to_content( + db, + &content_id, + &adapter, + &request.run_request, + &request.result, + ); + } + + let project_id = normalize_optional_project_id(request.project_id.as_deref()) + .ok_or_else(|| "project_id 或 content_id 至少提供一个".to_string())?; save_site_result_to_project( db, - &request.project_id, + &project_id, request.save_title.as_deref(), &adapter, &request.run_request, @@ -386,6 +493,7 @@ pub async fn run_site_adapter( request: RunSiteAdapterRequest, ) -> SiteAdapterRunResult { let normalized_name = normalize_site_adapter_name(&request.adapter_name); + let requested_profile_key = resolve_requested_profile_key(request.profile_key.as_deref()); let spec = match find_site_adapter_spec(&normalized_name) { Ok(Some(spec)) => spec, Ok(None) => { @@ -393,7 +501,7 @@ pub async fn run_site_adapter( ok: false, adapter: normalized_name, domain: String::new(), - profile_key: resolve_requested_profile_key(request.profile_key.as_deref()), + profile_key: requested_profile_key.clone(), session_id: None, target_id: None, entry_url: String::new(), @@ -402,6 +510,7 @@ pub async fn run_site_adapter( error_code: Some("adapter_not_found".to_string()), error_message: Some("未找到对应的站点适配器".to_string()), auth_hint: None, + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -415,7 +524,7 @@ pub async fn run_site_adapter( ok: false, adapter: normalized_name, domain: String::new(), - profile_key: resolve_requested_profile_key(request.profile_key.as_deref()), + profile_key: requested_profile_key.clone(), session_id: None, target_id: None, entry_url: String::new(), @@ -424,6 +533,7 @@ pub async fn run_site_adapter( error_code: Some("internal_error".to_string()), error_message: Some(error), auth_hint: None, + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -434,13 +544,12 @@ pub async fn run_site_adapter( } }; - let profile_key = resolve_requested_profile_key(request.profile_key.as_deref()); let args = match normalize_adapter_args(request.args) { Ok(value) => value, Err(error) => { return build_error_result( &spec, - profile_key, + requested_profile_key.clone(), None, None, String::new(), @@ -453,7 +562,7 @@ pub async fn run_site_adapter( if let Err(error) = validate_adapter_args(&spec, &args) { return build_error_result( &spec, - profile_key, + requested_profile_key.clone(), None, None, String::new(), @@ -467,7 +576,7 @@ pub async fn run_site_adapter( Err(error) => { return build_error_result( &spec, - profile_key, + requested_profile_key.clone(), None, None, String::new(), @@ -477,7 +586,24 @@ pub async fn run_site_adapter( } }; - let transport_route = match resolve_transport_route(db, &profile_key) { + let profile_key = + match resolve_effective_profile_key(db, request.profile_key.as_deref(), &spec.domain).await + { + Ok(value) => value, + Err(error) => { + return build_error_result( + &spec, + requested_profile_key, + None, + None, + entry_url, + "internal_error", + &error, + ); + } + }; + + let transport_route = match resolve_transport_route(db, &profile_key).await { Ok(value) => value, Err(error) => { return build_error_result( @@ -626,24 +752,236 @@ fn validate_adapter_args(spec: &SiteAdapterSpec, args: &Map) -> R Ok(()) } -fn resolve_requested_profile_key(profile_key: Option<&str>) -> String { +fn normalize_requested_profile_key(profile_key: Option<&str>) -> Option { profile_key .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_PROFILE_KEY) - .to_string() + .map(ToString::to_string) } -fn resolve_transport_route( +fn resolve_requested_profile_key(profile_key: Option<&str>) -> String { + normalize_requested_profile_key(profile_key).unwrap_or_else(|| DEFAULT_PROFILE_KEY.to_string()) +} + +fn load_active_browser_profiles(db: &DbConnection) -> Result, String> { + let conn = lock_db(db)?; + BrowserProfileDao::list(&conn, false).map_err(|error| format!("读取浏览器资料失败: {error}")) +} + +fn site_scope_matches_domain(site_scope: Option<&str>, adapter_domain: &str) -> bool { + let Some(scope) = site_scope + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) + else { + return false; + }; + let normalized_domain = adapter_domain.trim().to_ascii_lowercase(); + normalized_domain == scope + || normalized_domain.ends_with(&format!(".{scope}")) + || scope.ends_with(&format!(".{normalized_domain}")) +} + +fn select_preferred_site_profile_key( + profiles: &[BrowserProfileRecord], + adapter_domain: &str, + observer_matching_profile_keys: &HashSet, + attached_profile_keys: &HashSet, +) -> Option { + let attached_existing_session_matching_page = profiles.iter().find(|profile| { + profile.transport_kind == BrowserProfileTransportKind::ExistingSession + && observer_matching_profile_keys.contains(&profile.profile_key) + }); + if let Some(profile) = attached_existing_session_matching_page { + return Some(profile.profile_key.clone()); + } + + let attached_existing_session_matching_scope = profiles.iter().find(|profile| { + profile.transport_kind == BrowserProfileTransportKind::ExistingSession + && attached_profile_keys.contains(&profile.profile_key) + && site_scope_matches_domain(profile.site_scope.as_deref(), adapter_domain) + }); + if let Some(profile) = attached_existing_session_matching_scope { + return Some(profile.profile_key.clone()); + } + + let attached_existing_session_generic = profiles.iter().find(|profile| { + profile.transport_kind == BrowserProfileTransportKind::ExistingSession + && attached_profile_keys.contains(&profile.profile_key) + && profile + .site_scope + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_none() + }); + if let Some(profile) = attached_existing_session_generic { + return Some(profile.profile_key.clone()); + } + + let managed_matching_profile = profiles.iter().find(|profile| { + profile.transport_kind == BrowserProfileTransportKind::ManagedCdp + && site_scope_matches_domain(profile.site_scope.as_deref(), adapter_domain) + }); + if let Some(profile) = managed_matching_profile { + return Some(profile.profile_key.clone()); + } + + let any_matching_profile = profiles + .iter() + .find(|profile| site_scope_matches_domain(profile.site_scope.as_deref(), adapter_domain)); + if let Some(profile) = any_matching_profile { + return Some(profile.profile_key.clone()); + } + + let default_profile = profiles + .iter() + .find(|profile| profile.profile_key == DEFAULT_PROFILE_KEY); + if let Some(profile) = default_profile { + return Some(profile.profile_key.clone()); + } + + let managed_profile = profiles + .iter() + .find(|profile| profile.transport_kind == BrowserProfileTransportKind::ManagedCdp); + if let Some(profile) = managed_profile { + return Some(profile.profile_key.clone()); + } + + profiles.first().map(|profile| profile.profile_key.clone()) +} + +fn observer_matches_site_domain( + observer: &ChromeBridgeObserverSnapshot, + adapter_domain: &str, +) -> bool { + observer + .last_page_info + .as_ref() + .and_then(|page| page.url.as_deref()) + .and_then(parse_url_host) + .is_some_and(|host| site_scope_matches_domain(Some(host.as_str()), adapter_domain)) +} + +fn select_observer_only_profile_key( + profiles: &[BrowserProfileRecord], + observers: &[ChromeBridgeObserverSnapshot], + adapter_domain: &str, +) -> Option { + let registered_profile_keys = profiles + .iter() + .map(|profile| profile.profile_key.as_str()) + .collect::>(); + + observers + .iter() + .filter(|observer| !registered_profile_keys.contains(observer.profile_key.as_str())) + .find(|observer| observer_matches_site_domain(observer, adapter_domain)) + .or_else(|| { + observers + .iter() + .filter(|observer| !registered_profile_keys.contains(observer.profile_key.as_str())) + .next() + }) + .map(|observer| observer.profile_key.clone()) +} + +fn select_auto_profile_key( + profiles: &[BrowserProfileRecord], + observers: &[ChromeBridgeObserverSnapshot], + adapter_domain: &str, +) -> Option { + let observer_matching_profile_keys = observers + .iter() + .filter(|observer| observer_matches_site_domain(observer, adapter_domain)) + .map(|observer| observer.profile_key.clone()) + .collect::>(); + let attached_profile_keys = observers + .iter() + .map(|observer| observer.profile_key.clone()) + .collect::>(); + + let selected_saved_profile_key = select_preferred_site_profile_key( + profiles, + adapter_domain, + &observer_matching_profile_keys, + &attached_profile_keys, + ); + let observer_only_profile_key = + select_observer_only_profile_key(profiles, observers, adapter_domain); + + let selected_saved_transport = selected_saved_profile_key + .as_deref() + .and_then(|profile_key| { + profiles + .iter() + .find(|profile| profile.profile_key == profile_key) + .map(|profile| profile.transport_kind) + }); + + if observer_only_profile_key.is_some() + && selected_saved_transport != Some(BrowserProfileTransportKind::ExistingSession) + { + return observer_only_profile_key; + } + + selected_saved_profile_key +} + +async fn resolve_effective_profile_key( + db: &DbConnection, + profile_key: Option<&str>, + adapter_domain: &str, +) -> Result { + if let Some(profile_key) = normalize_requested_profile_key(profile_key) { + return Ok(profile_key); + } + + let profiles = load_active_browser_profiles(db)?; + let observers = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await + .observers + .into_iter() + .collect::>(); + + Ok( + select_auto_profile_key(&profiles, &observers, adapter_domain) + .unwrap_or_else(|| DEFAULT_PROFILE_KEY.to_string()), + ) +} + +fn resolve_transport_route_from_state( + profile_transport: Option, + has_attached_observer: bool, +) -> SiteAdapterTransportRoute { + match profile_transport { + Some(BrowserProfileTransportKind::ExistingSession) => { + SiteAdapterTransportRoute::ExistingSession + } + Some(_) => SiteAdapterTransportRoute::ManagedCdp, + None if has_attached_observer => SiteAdapterTransportRoute::ExistingSession, + None => SiteAdapterTransportRoute::ManagedCdp, + } +} + +async fn resolve_transport_route( db: &DbConnection, profile_key: &str, ) -> Result { - match load_profile_transport(db, profile_key)? { - Some(BrowserProfileTransportKind::ExistingSession) => { - Ok(SiteAdapterTransportRoute::ExistingSession) - } - _ => Ok(SiteAdapterTransportRoute::ManagedCdp), - } + let profile_transport = load_profile_transport(db, profile_key)?; + let has_attached_observer = profile_transport.is_none() + && chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await + .observers + .iter() + .any(|observer| observer.profile_key == profile_key); + + Ok(resolve_transport_route_from_state( + profile_transport, + has_attached_observer, + )) } fn load_profile_transport( @@ -658,6 +996,370 @@ fn load_profile_transport( .map(|record| record.transport_kind)) } +fn parse_existing_session_tabs(data: Option) -> Vec { + let Some(data) = data else { + return Vec::new(); + }; + let raw_tabs = data.get("tabs").and_then(Value::as_array).or_else(|| { + data.get("data") + .and_then(|value| value.get("tabs")) + .and_then(Value::as_array) + }); + let Some(raw_tabs) = raw_tabs else { + return Vec::new(); + }; + + raw_tabs + .iter() + .filter_map(|item| { + let object = item.as_object()?; + let id = value_to_string(object.get("id")?)?; + Some(ExistingSessionTabRecord { + id, + index: object.get("index").and_then(Value::as_i64).unwrap_or(0), + url: object + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + active: object + .get("active") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + }) + .collect() +} + +fn tab_matches_domain(tab: &ExistingSessionTabRecord, domain: &str) -> bool { + let Some(url) = tab.url.as_deref() else { + return false; + }; + let Some(host) = parse_url_host(url) else { + return false; + }; + site_scope_matches_domain(Some(host.as_str()), domain) +} + +fn select_existing_session_target( + tabs: &[ExistingSessionTabRecord], + domain: &str, +) -> Option { + tabs.iter() + .max_by_key(|tab| (tab_matches_domain(tab, domain), tab.active, -tab.index)) + .cloned() +} + +fn build_recommendation_entry_url(spec: &SiteAdapterSpec) -> String { + let example_args = match build_example_args(&spec.args) { + Value::Object(map) => map, + _ => Map::new(), + }; + + build_entry_url(spec, &example_args) + .unwrap_or_else(|_| format!("https://{}", spec.domain.trim_matches('/'))) +} + +fn is_priority_site_adapter(spec: &SiteAdapterSpec) -> bool { + const PRIORITY_DOMAINS: &[&str] = &[ + "github.com", + "www.zhihu.com", + "search.bilibili.com", + "www.36kr.com", + ]; + const PRIORITY_CAPABILITIES: &[&str] = &[ + "research", + "search", + "newsflash", + "hot", + "feed", + "issues", + "repository", + "video", + ]; + + PRIORITY_DOMAINS + .iter() + .any(|domain| site_scope_matches_domain(Some(domain), &spec.domain)) + || spec.capabilities.iter().any(|capability| { + PRIORITY_CAPABILITIES + .iter() + .any(|expected| capability.eq_ignore_ascii_case(expected)) + }) +} + +fn select_site_scope_recommendation_profile<'a>( + profiles: &'a [BrowserProfileRecord], + domain: &str, + attached_profile_keys: &HashSet, +) -> Option<&'a BrowserProfileRecord> { + profiles + .iter() + .find(|profile| { + profile.transport_kind == BrowserProfileTransportKind::ExistingSession + && attached_profile_keys.contains(&profile.profile_key) + && site_scope_matches_domain(profile.site_scope.as_deref(), domain) + }) + .or_else(|| { + profiles.iter().find(|profile| { + profile.transport_kind == BrowserProfileTransportKind::ManagedCdp + && site_scope_matches_domain(profile.site_scope.as_deref(), domain) + }) + }) + .or_else(|| { + profiles + .iter() + .find(|profile| site_scope_matches_domain(profile.site_scope.as_deref(), domain)) + }) +} + +fn build_site_adapter_recommendation_candidate( + spec: &SiteAdapterSpec, + profiles: &[BrowserProfileRecord], + attached_contexts: &[ExistingSessionRecommendationContext], + attached_profile_keys: &HashSet, +) -> SiteAdapterRecommendationCandidate { + for profile in profiles + .iter() + .filter(|profile| profile.transport_kind == BrowserProfileTransportKind::ExistingSession) + { + let Some(context) = attached_contexts + .iter() + .find(|context| context.profile_key == profile.profile_key) + else { + continue; + }; + let Some(current_host) = context.current_url.as_deref().and_then(parse_url_host) else { + continue; + }; + if !site_scope_matches_domain(Some(current_host.as_str()), &spec.domain) { + continue; + } + + return SiteAdapterRecommendationCandidate { + reason: format!( + "已检测到资料 {} 当前停留在 {},可直接复用已连接的 Chrome 上下文。", + profile.name, spec.domain + ), + profile_key: Some(profile.profile_key.clone()), + target_id: select_existing_session_target(&context.tabs, &spec.domain) + .map(|tab| tab.id), + score: 100, + }; + } + + for profile in profiles + .iter() + .filter(|profile| profile.transport_kind == BrowserProfileTransportKind::ExistingSession) + { + let Some(context) = attached_contexts + .iter() + .find(|context| context.profile_key == profile.profile_key) + else { + continue; + }; + let Some(target) = select_existing_session_target(&context.tabs, &spec.domain) else { + continue; + }; + + return SiteAdapterRecommendationCandidate { + reason: format!( + "已检测到资料 {} 的已连接标签页命中 {},优先复用现有登录态。", + profile.name, spec.domain + ), + profile_key: Some(profile.profile_key.clone()), + target_id: Some(target.id), + score: 90, + }; + } + + if let Some(profile) = + select_site_scope_recommendation_profile(profiles, &spec.domain, attached_profile_keys) + { + let score = if profile.transport_kind == BrowserProfileTransportKind::ManagedCdp { + 70 + } else { + 75 + }; + return SiteAdapterRecommendationCandidate { + reason: format!( + "资料 {} 已绑定站点范围 {},可优先作为该适配器的执行上下文。", + profile.name, + profile + .site_scope + .as_deref() + .unwrap_or(spec.domain.as_str()) + ), + profile_key: Some(profile.profile_key.clone()), + target_id: None, + score, + }; + } + + let observer_matching_profile_keys = HashSet::new(); + let fallback_profile_key = select_preferred_site_profile_key( + profiles, + &spec.domain, + &observer_matching_profile_keys, + attached_profile_keys, + ); + let fallback_profile = fallback_profile_key.as_ref().and_then(|profile_key| { + profiles + .iter() + .find(|profile| profile.profile_key == *profile_key) + }); + + if is_priority_site_adapter(spec) { + return SiteAdapterRecommendationCandidate { + reason: fallback_profile + .map(|profile| { + format!( + "当前没有直接命中的站点上下文,但 {} 适合研究采集,可先使用资料 {}。", + spec.name, profile.name + ) + }) + .unwrap_or_else(|| { + "当前没有直接命中的站点上下文,但该适配器仍适合作为研究候选。".to_string() + }), + profile_key: fallback_profile_key, + target_id: None, + score: 45, + }; + } + + SiteAdapterRecommendationCandidate { + reason: fallback_profile + .map(|profile| { + format!( + "当前未检测到更强上下文,保留为可用候选;默认推荐资料 {}。", + profile.name + ) + }) + .unwrap_or_else(|| "当前未检测到可复用的浏览器上下文,保留为可用候选。".to_string()), + profile_key: fallback_profile_key, + target_id: None, + score: 20, + } +} + +fn rank_site_adapter_recommendations( + specs: &[SiteAdapterSpec], + profiles: &[BrowserProfileRecord], + attached_contexts: &[ExistingSessionRecommendationContext], + limit: Option, +) -> Vec { + let attached_profile_keys = attached_contexts + .iter() + .map(|context| context.profile_key.clone()) + .collect::>(); + + let mut recommendations = specs + .iter() + .map(|spec| { + let candidate = build_site_adapter_recommendation_candidate( + spec, + profiles, + attached_contexts, + &attached_profile_keys, + ); + SiteAdapterRecommendation { + adapter: build_adapter_definition(spec), + reason: candidate.reason, + profile_key: candidate.profile_key, + target_id: candidate.target_id, + entry_url: build_recommendation_entry_url(spec), + score: candidate.score, + } + }) + .collect::>(); + + recommendations.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| left.adapter.name.cmp(&right.adapter.name)) + }); + + if let Some(limit) = limit { + recommendations.truncate(limit.min(recommendations.len())); + } + + recommendations +} + +async fn load_existing_session_tabs( + profile_key: &str, +) -> Result, String> { + let result = execute_bridge_adapter_command(ChromeBridgeCommandRequest { + profile_key: Some(profile_key.to_string()), + command: "list_tabs".to_string(), + target: None, + text: None, + url: None, + payload: None, + wait_for_page_info: false, + timeout_ms: Some(DEFAULT_TIMEOUT_MS), + }) + .await?; + + Ok(parse_existing_session_tabs(result.data)) +} + +async fn load_existing_session_recommendation_contexts( + profiles: &[BrowserProfileRecord], +) -> Vec { + let status_snapshot = chrome_bridge::chrome_bridge_hub() + .get_status_snapshot() + .await; + let mut contexts = Vec::new(); + + for profile in profiles + .iter() + .filter(|profile| profile.transport_kind == BrowserProfileTransportKind::ExistingSession) + { + let Some(observer) = status_snapshot + .observers + .iter() + .find(|observer| observer.profile_key == profile.profile_key) + else { + continue; + }; + + let tabs = match load_existing_session_tabs(&profile.profile_key).await { + Ok(result) => result, + Err(error) => { + tracing::debug!( + "[site_capability] 读取 existing_session 推荐上下文标签页失败: profile_key={}, error={}", + profile.profile_key, + error + ); + Vec::new() + } + }; + contexts.push(ExistingSessionRecommendationContext { + profile_key: profile.profile_key.clone(), + current_url: observer + .last_page_info + .as_ref() + .and_then(|page| page.url.as_ref().map(ToString::to_string)), + tabs, + }); + } + + contexts +} + +fn classify_existing_session_target_error(error: &str) -> &'static str { + let normalized = error.to_ascii_lowercase(); + if normalized.contains("标签页") || normalized.contains("tab") || normalized.contains("target") + { + "no_matching_context" + } else { + "site_unreachable" + } +} + async fn run_existing_session_adapter( spec: &SiteAdapterSpec, profile_key: String, @@ -666,42 +1368,93 @@ async fn run_existing_session_adapter( timeout_ms: u64, wrapped_script: String, ) -> SiteAdapterRunResult { - let navigation_result = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { - profile_key: Some(profile_key.clone()), - command: "open_url".to_string(), - target: target_id.clone(), - text: None, - url: Some(entry_url.clone()), - payload: None, - wait_for_page_info: true, - timeout_ms: Some(timeout_ms), - }) - .await + let selected_target = if let Some(explicit_target_id) = target_id + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) { - Ok(result) => result, - Err(error) => { + ExistingSessionTabRecord { + id: explicit_target_id, + index: 0, + url: None, + active: true, + } + } else { + let tabs = match load_existing_session_tabs(&profile_key).await { + Ok(result) => result, + Err(error) => { + return build_error_result( + spec, + profile_key, + None, + None, + entry_url, + "site_unreachable", + &format!("读取当前 Chrome 标签页失败: {error}"), + ); + } + }; + let Some(selected_target) = select_existing_session_target(&tabs, &spec.domain) else { return build_error_result( spec, profile_key, None, - target_id, + None, entry_url, - "site_unreachable", - &format!("当前 Chrome 导航失败: {error}"), + "no_matching_context", + "当前 Chrome 没有匹配的目标站点标签页,请先打开目标站点页面,或手动传入 target_id。", ); - } + }; + selected_target }; - let bridged_target_id = navigation_result - .data - .as_ref() - .and_then(|data| data.get("tab_id")) - .and_then(value_to_string) - .or(target_id.clone()); - let latest_source_url = navigation_result - .page_info - .as_ref() - .and_then(|page| page.url.clone()); + let should_skip_navigation = selected_target + .url + .as_deref() + .map(|current_url| url_matches_expected_entry(current_url, &entry_url)) + .unwrap_or(false); + let mut bridged_target_id = Some(selected_target.id.clone()); + let latest_source_url = if should_skip_navigation { + selected_target.url.clone() + } else { + let navigation_result = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { + profile_key: Some(profile_key.clone()), + command: "open_url".to_string(), + target: bridged_target_id.clone(), + text: None, + url: Some(entry_url.clone()), + payload: None, + wait_for_page_info: true, + timeout_ms: Some(timeout_ms), + }) + .await + { + Ok(result) => result, + Err(error) => { + return build_error_result( + spec, + profile_key, + None, + bridged_target_id, + entry_url, + classify_existing_session_target_error(&error), + &format!("当前 Chrome 导航失败: {error}"), + ); + } + }; + + bridged_target_id = navigation_result + .data + .as_ref() + .and_then(|data| data.get("tab_id")) + .and_then(value_to_string) + .or(bridged_target_id.clone()); + navigation_result + .page_info + .as_ref() + .and_then(|page| page.url.clone()) + .or(selected_target.url.clone()) + }; let adapter_output = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { profile_key: Some(profile_key.clone()), @@ -725,7 +1478,7 @@ async fn run_existing_session_adapter( None, bridged_target_id, entry_url, - "adapter_failed", + "adapter_runtime_error", &error, ); } @@ -802,7 +1555,8 @@ async fn run_managed_cdp_adapter( }; let runtime = shared_browser_runtime(); - if let Err(error) = runtime + let previous_url = session.last_page_info.as_ref().map(|page| page.url.clone()); + let navigated_session = match runtime .execute_action( &session.session_id, "navigate", @@ -814,26 +1568,61 @@ async fn run_managed_cdp_adapter( ) .await { - return build_error_result( - spec, - profile_key, - Some(session.session_id), - Some(session.target_id), - entry_url, - "site_unreachable", - &format!("导航站点失败: {error}"), - ); - } + Ok(_) => { + let refreshed_session = runtime + .refresh_page_info(&session.session_id) + .await + .unwrap_or(session.clone()); + wait_for_navigation_settle(&runtime, refreshed_session, &entry_url, timeout_ms).await + } + Err(error) => { + if !looks_like_navigation_timeout_error(&error) { + return build_error_result( + spec, + profile_key, + Some(session.session_id), + Some(session.target_id), + entry_url, + "site_unreachable", + &format!("导航站点失败: {error}"), + ); + } - let refreshed_session = runtime - .refresh_page_info(&session.session_id) - .await - .unwrap_or(session.clone()); - let refreshed_session = - wait_for_navigation_settle(&runtime, refreshed_session, &entry_url, timeout_ms).await; + let refreshed_session = runtime + .refresh_page_info(&session.session_id) + .await + .unwrap_or(session.clone()); + let settled_session = + wait_for_navigation_settle(&runtime, refreshed_session, &entry_url, timeout_ms) + .await; + let recovered_url = settled_session + .last_page_info + .as_ref() + .map(|page| page.url.as_str()); + if !navigation_reached_expected_page(recovered_url, previous_url.as_deref(), &entry_url) + { + return build_error_result( + spec, + profile_key, + Some(session.session_id), + Some(session.target_id), + entry_url, + "site_unreachable", + &format!("导航站点失败: {error}"), + ); + } + + tracing::warn!( + "[site_capability] 导航命令超时后继续复查页面状态并恢复执行: profile_key={}, entry_url={}", + profile_key, + entry_url + ); + settled_session + } + }; let adapter_output = match evaluate_session_script( - &refreshed_session.session_id, + &navigated_session.session_id, &wrapped_script, normalize_adapter_evaluate_timeout_ms(timeout_ms), ) @@ -844,19 +1633,19 @@ async fn run_managed_cdp_adapter( return build_error_result( spec, profile_key, - Some(refreshed_session.session_id), - Some(refreshed_session.target_id), + Some(navigated_session.session_id), + Some(navigated_session.target_id), entry_url, - "adapter_failed", + "adapter_runtime_error", &error, ); } }; let latest_session = runtime - .refresh_page_info(&refreshed_session.session_id) + .refresh_page_info(&navigated_session.session_id) .await - .unwrap_or(refreshed_session); + .unwrap_or(navigated_session); normalize_adapter_output( spec, @@ -967,45 +1756,149 @@ fn normalize_optional_project_id(value: Option<&str>) -> Option { .map(ToString::to_string) } +fn normalize_optional_content_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn build_site_result_metadata_map( + adapter: &SiteAdapterDefinition, + result: &SiteAdapterRunResult, + include_resource_kind: bool, +) -> Map { + let mut metadata = Map::new(); + if include_resource_kind { + metadata.insert( + "resourceKind".to_string(), + Value::String("document".to_string()), + ); + } + metadata.insert( + "siteAdapterName".to_string(), + Value::String(adapter.name.clone()), + ); + metadata.insert( + "siteAdapterDomain".to_string(), + Value::String(adapter.domain.clone()), + ); + metadata.insert( + "siteAdapterProfileKey".to_string(), + Value::String(result.profile_key.clone()), + ); + metadata.insert( + "siteAdapterEntryUrl".to_string(), + Value::String(result.entry_url.clone()), + ); + metadata.insert( + "siteAdapterSourceUrl".to_string(), + result + .source_url + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata.insert( + "siteAdapterSourceKind".to_string(), + adapter + .source_kind + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata.insert( + "siteAdapterSourceVersion".to_string(), + adapter + .source_version + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata.insert( + "siteAdapterReportHint".to_string(), + result + .report_hint + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata +} + +fn resolve_project_id_from_content(db: &DbConnection, content_id: &str) -> Option { + ContentManager::new(db.clone()) + .get(&content_id.to_string()) + .ok() + .flatten() + .map(|content| content.project_id) +} + fn attach_requested_site_result_save( db: &DbConnection, request: &RunSiteAdapterRequest, mut result: SiteAdapterRunResult, ) -> SiteAdapterRunResult { - let Some(project_id) = normalize_optional_project_id(request.project_id.as_deref()) else { + let content_id = normalize_optional_content_id(request.content_id.as_deref()); + let project_id = normalize_optional_project_id(request.project_id.as_deref()); + let save_source = if content_id.is_some() { + EXPLICIT_CONTENT_SAVE_SOURCE + } else if project_id.is_some() { + EXPLICIT_PROJECT_SAVE_SOURCE + } else { return result; }; if !result.ok { - result.save_skipped_project_id = Some(project_id); - result.save_skipped_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + result.save_skipped_project_id = project_id.clone().or_else(|| { + content_id + .as_deref() + .and_then(|value| resolve_project_id_from_content(db, value)) + }); + result.save_skipped_by = Some(save_source.to_string()); return result; } let adapter_name = normalize_site_adapter_name(&request.adapter_name); let Some(adapter) = get_site_adapter(&adapter_name) else { - result.save_skipped_project_id = Some(project_id); - result.save_skipped_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + result.save_skipped_project_id = project_id.clone().or_else(|| { + content_id + .as_deref() + .and_then(|value| resolve_project_id_from_content(db, value)) + }); + result.save_skipped_by = Some(save_source.to_string()); result.save_error_message = Some("未找到对应的站点适配器".to_string()); return result; }; - match save_site_result_to_project( - db, - &project_id, - request.save_title.as_deref(), - &adapter, - request, - &result, - ) { + let save_result = if let Some(content_id) = content_id.as_deref() { + save_site_result_to_content(db, content_id, &adapter, request, &result) + } else if let Some(project_id) = project_id.as_deref() { + save_site_result_to_project( + db, + project_id, + request.save_title.as_deref(), + &adapter, + request, + &result, + ) + } else { + Err("project_id 或 content_id 至少提供一个".to_string()) + }; + + match save_result { Ok(saved_content) => { + result.saved_project_id = Some(saved_content.project_id.clone()); result.saved_content = Some(saved_content); - result.saved_project_id = Some(project_id); - result.saved_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + result.saved_by = Some(save_source.to_string()); } Err(error) => { - result.save_skipped_project_id = Some(project_id); - result.save_skipped_by = Some(EXPLICIT_PROJECT_SAVE_SOURCE.to_string()); + result.save_skipped_project_id = project_id.clone().or_else(|| { + content_id + .as_deref() + .and_then(|value| resolve_project_id_from_content(db, value)) + }); + result.save_skipped_by = Some(save_source.to_string()); result.save_error_message = Some(error); } } @@ -1035,6 +1928,14 @@ fn build_wrapped_adapter_script( try {{ const result = await adapter(args, helpers); if (result && typeof result === "object" && Object.prototype.hasOwnProperty.call(result, "ok")) {{ + if (result.ok === false && !result.error_code) {{ + return {{ + ...result, + error_code: helpers.looksLikeLoginWall() + ? "auth_required" + : "adapter_runtime_error", + }}; + }} return result; }} return {{ @@ -1043,9 +1944,10 @@ fn build_wrapped_adapter_script( source_url: location.href, }}; }} catch (error) {{ + const loginWall = helpers.looksLikeLoginWall(); return {{ ok: false, - error_code: "adapter_failed", + error_code: loginWall ? "auth_required" : "adapter_runtime_error", error_message: error?.message || String(error), source_url: location.href, }}; @@ -1108,7 +2010,7 @@ fn normalize_adapter_output( None } }); - let error_code = adapter_output + let raw_error_code = adapter_output .get("error_code") .and_then(Value::as_str) .map(ToString::to_string); @@ -1116,11 +2018,20 @@ fn normalize_adapter_output( .get("error_message") .and_then(Value::as_str) .map(ToString::to_string); + let error_code = if ok { + None + } else { + normalize_site_adapter_error_code(raw_error_code.as_deref(), error_message.as_deref()) + .or_else(|| Some("adapter_runtime_error".to_string())) + }; let auth_hint = adapter_output .get("auth_hint") .and_then(Value::as_str) .map(ToString::to_string) .or_else(|| spec.auth_hint.clone()); + let report_hint = error_code + .as_deref() + .and_then(build_site_adapter_report_hint); SiteAdapterRunResult { ok, @@ -1135,6 +2046,7 @@ fn normalize_adapter_output( error_code, error_message, auth_hint, + report_hint, saved_content: None, saved_project_id: None, saved_by: None, @@ -1244,6 +2156,95 @@ fn parse_url_host(url: &str) -> Option { .map(|value| value.to_ascii_lowercase()) } +fn looks_like_auth_required_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("sign in") + || normalized.contains("log in") + || normalized.contains("登录") + || normalized.contains("登入") + || normalized.contains("扫码") + || normalized.contains("验证你是人类") +} + +fn looks_like_no_matching_context_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("标签页") + || normalized.contains("tab") + || normalized.contains("target") + || normalized.contains("上下文") +} + +fn normalize_site_adapter_error_code( + error_code: Option<&str>, + error_message: Option<&str>, +) -> Option { + let normalized_message = error_message + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default(); + let normalized_code = error_code + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()); + + if normalized_code.as_deref() == Some("auth_required") + || looks_like_auth_required_message(normalized_message) + { + return Some("auth_required".to_string()); + } + + if matches!( + normalized_code.as_deref(), + Some("target_not_found") | Some("no_matching_context") + ) || looks_like_no_matching_context_message(normalized_message) + { + return Some("no_matching_context".to_string()); + } + + if matches!( + normalized_code.as_deref(), + Some("adapter_failed") | Some("adapter_runtime_error") + ) { + return Some("adapter_runtime_error".to_string()); + } + + normalized_code.or_else(|| { + if normalized_message.is_empty() { + None + } else { + Some("adapter_runtime_error".to_string()) + } + }) +} + +fn looks_like_navigation_timeout_error(error: &str) -> bool { + let normalized = error.to_ascii_lowercase(); + normalized.contains("page.navigate") + && (normalized.contains("timeout") || error.contains("超时")) +} + +fn build_site_adapter_report_hint(error_code: &str) -> Option { + match error_code { + "auth_required" => Some( + "请先确认当前浏览器资料已经登录目标站点,再重试;如果仍失败,请附上当前页面 URL 和登录状态。" + .to_string(), + ), + "no_matching_context" => Some( + "请先在当前浏览器里打开目标站点页面,或手动传入 profile_key / target_id 后重试。" + .to_string(), + ), + "adapter_runtime_error" => Some( + "站点页面结构可能已经变化;请保留当前页面 URL、执行参数和错误信息后反馈给 Lime。" + .to_string(), + ), + "site_unreachable" => Some( + "目标站点可能加载较慢、发生重定向,或当前网络暂时不可达;请先确认入口 URL 能正常打开,必要时增大 timeout_ms 后重试。" + .to_string(), + ), + _ => None, + } +} + fn normalize_url_path(path: &str) -> &str { let trimmed = path.trim_end_matches('/'); if trimmed.is_empty() { @@ -1262,6 +2263,9 @@ fn build_error_result( error_code: &str, error_message: &str, ) -> SiteAdapterRunResult { + let normalized_error_code = + normalize_site_adapter_error_code(Some(error_code), Some(error_message)) + .unwrap_or_else(|| error_code.to_string()); SiteAdapterRunResult { ok: false, adapter: spec.name.clone(), @@ -1272,9 +2276,10 @@ fn build_error_result( entry_url, source_url: None, data: None, - error_code: Some(error_code.to_string()), + error_code: Some(normalized_error_code.clone()), error_message: Some(error_message.to_string()), auth_hint: spec.auth_hint.clone(), + report_hint: build_site_adapter_report_hint(&normalized_error_code), saved_content: None, saved_project_id: None, saved_by: None, @@ -1300,6 +2305,7 @@ mod tests { use crate::workspace::{WorkspaceManager, WorkspaceType}; use lime_core::database::dao::browser_profile::UpsertBrowserProfileInput; use rusqlite::Connection; + use std::collections::HashSet; use std::sync::{Arc, Mutex}; use tempfile::tempdir; @@ -1315,16 +2321,19 @@ mod tests { assert!(adapters .iter() .any(|adapter| adapter.name == "github/search")); + let expected = get_site_adapter("github/search").expect("github/search should resolve"); let github = adapters .into_iter() .find(|adapter| adapter.name == "github/search") .expect("github/search should exist"); - assert_eq!( - github.example_args["query"], - Value::String("AI Agent".to_string()) - ); - assert_eq!(github.example_args["limit"], Value::from(5)); - assert_eq!(github.source_kind.as_deref(), Some("bundled")); + assert_eq!(github.example_args, expected.example_args); + assert_eq!(github.source_kind, expected.source_kind); + assert_eq!(github.source_version, expected.source_version); + assert!(github + .example_args + .get("query") + .and_then(Value::as_str) + .is_some()); } #[test] @@ -1334,6 +2343,306 @@ mod tests { assert_eq!(adapters[0].name, "github/issues"); } + #[test] + fn should_prefer_attached_existing_session_profile_for_matching_site() { + let attached_profile_keys = HashSet::from(["research_attach".to_string()]); + let profiles = vec![ + BrowserProfileRecord { + id: "managed-1".to_string(), + profile_key: "general_browser_assist".to_string(), + name: "通用资料".to_string(), + description: None, + site_scope: Some("github.com".to_string()), + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/managed".to_string(), + managed_profile_dir: Some("/tmp/managed".to_string()), + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }, + BrowserProfileRecord { + id: "existing-1".to_string(), + profile_key: "research_attach".to_string(), + name: "研究附着".to_string(), + description: None, + site_scope: None, + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ExistingSession, + profile_dir: String::new(), + managed_profile_dir: None, + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }, + ]; + + let selected = select_preferred_site_profile_key( + &profiles, + "github.com", + &HashSet::new(), + &attached_profile_keys, + ); + + assert_eq!(selected.as_deref(), Some("research_attach")); + } + + #[test] + fn should_fall_back_to_matching_managed_profile_when_existing_session_is_not_attached() { + let profiles = vec![ + BrowserProfileRecord { + id: "existing-1".to_string(), + profile_key: "research_attach".to_string(), + name: "研究附着".to_string(), + description: None, + site_scope: Some("github.com".to_string()), + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ExistingSession, + profile_dir: String::new(), + managed_profile_dir: None, + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }, + BrowserProfileRecord { + id: "managed-1".to_string(), + profile_key: "general_browser_assist".to_string(), + name: "通用资料".to_string(), + description: None, + site_scope: Some("github.com".to_string()), + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/managed".to_string(), + managed_profile_dir: Some("/tmp/managed".to_string()), + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }, + ]; + + let selected = select_preferred_site_profile_key( + &profiles, + "github.com", + &HashSet::new(), + &HashSet::new(), + ); + + assert_eq!(selected.as_deref(), Some("general_browser_assist")); + } + + #[test] + fn should_prefer_observer_only_profile_before_managed_profile() { + let profiles = vec![BrowserProfileRecord { + id: "managed-1".to_string(), + profile_key: "general_browser_assist".to_string(), + name: "通用资料".to_string(), + description: None, + site_scope: Some("github.com".to_string()), + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/managed".to_string(), + managed_profile_dir: Some("/tmp/managed".to_string()), + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }]; + let observers = vec![ChromeBridgeObserverSnapshot { + client_id: "observer-1".to_string(), + profile_key: "live_browser".to_string(), + connected_at: "2026-03-26T00:00:00Z".to_string(), + user_agent: Some("Chrome".to_string()), + last_heartbeat_at: Some("2026-03-26T00:00:01Z".to_string()), + last_page_info: Some(chrome_bridge::ChromeBridgePageInfo { + title: Some("GitHub".to_string()), + url: Some("https://github.com/trending".to_string()), + markdown: "GitHub".to_string(), + updated_at: "2026-03-26T00:00:01Z".to_string(), + }), + }]; + + let selected = select_auto_profile_key(&profiles, &observers, "github.com"); + + assert_eq!(selected.as_deref(), Some("live_browser")); + } + + #[test] + fn should_route_observer_only_profile_as_existing_session() { + assert_eq!( + resolve_transport_route_from_state(None, true), + SiteAdapterTransportRoute::ExistingSession + ); + assert_eq!( + resolve_transport_route_from_state(Some(BrowserProfileTransportKind::ManagedCdp), true,), + SiteAdapterTransportRoute::ManagedCdp + ); + } + + #[test] + fn should_select_matching_existing_session_tab_before_active_non_matching_tab() { + let selected = select_existing_session_target( + &[ + ExistingSessionTabRecord { + id: "tab-1".to_string(), + index: 0, + url: Some("https://www.36kr.com/newsflashes".to_string()), + active: true, + }, + ExistingSessionTabRecord { + id: "tab-2".to_string(), + index: 1, + url: Some( + "https://github.com/search?q=model%20context%20protocol&type=repositories" + .to_string(), + ), + active: false, + }, + ], + "github.com", + ) + .expect("应该选中匹配域名的标签页"); + + assert_eq!(selected.id, "tab-2"); + } + + #[test] + fn should_rank_observer_context_before_scope_only_recommendations() { + let github = find_site_adapter_spec("github/search") + .expect("registry should load") + .expect("github spec should exist"); + let zhihu = find_site_adapter_spec("zhihu/search") + .expect("registry should load") + .expect("zhihu spec should exist"); + let profiles = vec![ + BrowserProfileRecord { + id: "existing-1".to_string(), + profile_key: "research_attach".to_string(), + name: "研究附着".to_string(), + description: None, + site_scope: None, + launch_url: Some("https://github.com".to_string()), + transport_kind: BrowserProfileTransportKind::ExistingSession, + profile_dir: String::new(), + managed_profile_dir: None, + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }, + BrowserProfileRecord { + id: "managed-1".to_string(), + profile_key: "zhihu_scope".to_string(), + name: "知乎资料".to_string(), + description: None, + site_scope: Some("www.zhihu.com".to_string()), + launch_url: Some("https://www.zhihu.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/managed".to_string(), + managed_profile_dir: Some("/tmp/managed".to_string()), + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }, + ]; + let attached_contexts = vec![ExistingSessionRecommendationContext { + profile_key: "research_attach".to_string(), + current_url: Some( + "https://github.com/search?q=model%20context%20protocol&type=repositories" + .to_string(), + ), + tabs: vec![ExistingSessionTabRecord { + id: "tab-github".to_string(), + index: 0, + url: Some( + "https://github.com/search?q=model%20context%20protocol&type=repositories" + .to_string(), + ), + active: true, + }], + }]; + + let recommendations = rank_site_adapter_recommendations( + &[github, zhihu], + &profiles, + &attached_contexts, + Some(2), + ); + + assert_eq!(recommendations.len(), 2); + assert_eq!(recommendations[0].adapter.name, "github/search"); + assert_eq!( + recommendations[0].profile_key.as_deref(), + Some("research_attach") + ); + assert_eq!(recommendations[0].target_id.as_deref(), Some("tab-github")); + assert!(recommendations[0].score > recommendations[1].score); + } + + #[test] + fn should_fall_back_to_site_scope_recommendation_without_observer_context() { + let spec = find_site_adapter_spec("zhihu/search") + .expect("registry should load") + .expect("zhihu spec should exist"); + let profiles = vec![BrowserProfileRecord { + id: "managed-1".to_string(), + profile_key: "zhihu_scope".to_string(), + name: "知乎资料".to_string(), + description: None, + site_scope: Some("www.zhihu.com".to_string()), + launch_url: Some("https://www.zhihu.com".to_string()), + transport_kind: BrowserProfileTransportKind::ManagedCdp, + profile_dir: "/tmp/managed".to_string(), + managed_profile_dir: Some("/tmp/managed".to_string()), + created_at: "2026-03-26T00:00:00Z".to_string(), + updated_at: "2026-03-26T00:00:00Z".to_string(), + last_used_at: None, + archived_at: None, + }]; + let attached_profile_keys = HashSet::new(); + + let candidate = build_site_adapter_recommendation_candidate( + &spec, + &profiles, + &[], + &attached_profile_keys, + ); + + assert_eq!(candidate.profile_key.as_deref(), Some("zhihu_scope")); + assert_eq!(candidate.target_id, None); + assert_eq!(candidate.score, 70); + assert!(candidate.reason.contains("已绑定站点范围")); + } + + #[test] + fn should_normalize_runtime_error_and_report_hint() { + let spec = find_site_adapter_spec("github/search") + .expect("registry should load") + .expect("github spec should exist"); + + let result = normalize_adapter_output( + &spec, + "general_browser_assist".to_string(), + "https://github.com/search?q=mcp&type=repositories".to_string(), + AdapterExecutionState { + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + }, + serde_json::json!({ + "ok": false, + "error_message": "页面脚本执行失败", + }), + ); + + assert_eq!(result.error_code.as_deref(), Some("adapter_runtime_error")); + assert!(result.report_hint.is_some()); + } + #[test] fn should_reject_missing_required_arg() { let spec = find_site_adapter_spec("github/search") @@ -1387,9 +2696,30 @@ mod tests { )); } + #[test] + fn should_detect_navigation_timeout_error_from_cdp_message() { + assert!(looks_like_navigation_timeout_error( + "导航站点失败: CDP 命令超时: Page.navigate" + )); + assert!(looks_like_navigation_timeout_error( + "CDP command timeout: Page.navigate" + )); + assert!(!looks_like_navigation_timeout_error( + "CDP 命令超时: Runtime.evaluate" + )); + } + + #[test] + fn should_build_site_unreachable_report_hint() { + let hint = build_site_adapter_report_hint("site_unreachable") + .expect("site_unreachable 应返回提示"); + assert!(hint.contains("timeout_ms")); + } + #[test] fn should_save_existing_site_result_to_project_as_document() { let db = setup_test_db(); + let active_adapter = get_site_adapter("github/search").expect("github/search should exist"); let workspace_root = tempdir().expect("创建临时目录失败"); let workspace = WorkspaceManager::new(db.clone()) .create_with_type( @@ -1399,7 +2729,8 @@ mod tests { ) .expect("创建测试项目失败"); let request = SaveSiteAdapterResultRequest { - project_id: workspace.id.clone(), + project_id: Some(workspace.id.clone()), + content_id: None, save_title: Some("GitHub MCP 搜索结果".to_string()), run_request: RunSiteAdapterRequest { adapter_name: "github/search".to_string(), @@ -1407,6 +2738,7 @@ mod tests { profile_key: Some("general_browser_assist".to_string()), target_id: Some("target-1".to_string()), timeout_ms: Some(20_000), + content_id: None, project_id: None, save_title: None, }, @@ -1427,6 +2759,7 @@ mod tests { error_code: None, error_message: None, auth_hint: Some("请先登录 GitHub。".to_string()), + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -1457,20 +2790,21 @@ mod tests { .and_then(|metadata| metadata.get("siteAdapterName")), Some(&serde_json::json!("github/search")) ); - assert_eq!( - content - .metadata - .as_ref() - .and_then(|metadata| metadata.get("siteAdapterSourceKind")), - Some(&serde_json::json!("bundled")) - ); + let actual_source_kind = content + .metadata + .as_ref() + .and_then(|metadata| metadata.get("siteAdapterSourceKind")) + .cloned(); + let expected_source_kind = active_adapter.source_kind.clone().map(Value::String); + assert_eq!(actual_source_kind, expected_source_kind); } #[test] fn should_reject_saving_failed_site_result() { let db = setup_test_db(); let request = SaveSiteAdapterResultRequest { - project_id: "project-1".to_string(), + project_id: Some("project-1".to_string()), + content_id: None, save_title: None, run_request: RunSiteAdapterRequest { adapter_name: "github/search".to_string(), @@ -1478,6 +2812,7 @@ mod tests { profile_key: Some("general_browser_assist".to_string()), target_id: None, timeout_ms: None, + content_id: None, project_id: None, save_title: None, }, @@ -1494,6 +2829,7 @@ mod tests { error_code: Some("adapter_failed".to_string()), error_message: Some("mock error".to_string()), auth_hint: None, + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -1527,6 +2863,7 @@ mod tests { profile_key: Some("general_browser_assist".to_string()), target_id: Some("target-1".to_string()), timeout_ms: Some(20_000), + content_id: None, project_id: Some(workspace.id.clone()), save_title: Some("自动保存的 GitHub MCP 搜索结果".to_string()), }; @@ -1547,6 +2884,7 @@ mod tests { error_code: None, error_message: None, auth_hint: Some("请先登录 GitHub。".to_string()), + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -1585,6 +2923,7 @@ mod tests { profile_key: Some("general_browser_assist".to_string()), target_id: None, timeout_ms: None, + content_id: None, project_id: Some("project-1".to_string()), save_title: None, }; @@ -1601,6 +2940,7 @@ mod tests { error_code: Some("adapter_failed".to_string()), error_message: Some("mock error".to_string()), auth_hint: None, + report_hint: None, saved_content: None, saved_project_id: None, saved_by: None, @@ -1652,6 +2992,7 @@ mod tests { profile_key: Some("weibo_attach".to_string()), target_id: None, timeout_ms: Some(5_000), + content_id: None, project_id: None, save_title: None, }, @@ -1663,7 +3004,7 @@ mod tests { result.error_code.as_deref(), Some("unsupported_profile_transport") ); - assert_eq!(result.error_code.as_deref(), Some("site_unreachable")); + assert_eq!(result.error_code.as_deref(), Some("no_matching_context")); assert!( result .error_message @@ -1672,4 +3013,196 @@ mod tests { || result.auth_hint.unwrap_or_default().contains("GitHub") ); } + + #[test] + fn should_save_existing_site_result_to_current_content() { + let db = setup_test_db(); + let workspace_root = tempdir().expect("创建临时目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "站点采集项目".to_string(), + workspace_root + .path() + .join("site-capability-current-content-project"), + WorkspaceType::Document, + ) + .expect("创建测试项目失败"); + let manager = ContentManager::new(db.clone()); + let existing = manager + .create(ContentCreateRequest { + project_id: workspace.id.clone(), + title: "当前主稿".to_string(), + content_type: Some(ContentType::Document), + order: None, + body: Some("旧内容".to_string()), + metadata: Some(serde_json::json!({ + "artifactKind": "roadmap", + "siteAdapterName": "legacy/adapter" + })), + }) + .expect("创建测试内容失败"); + + let request = SaveSiteAdapterResultRequest { + project_id: None, + content_id: Some(existing.id.clone()), + save_title: Some("这个标题不应覆盖当前主稿".to_string()), + run_request: RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp","limit":5}), + profile_key: Some("general_browser_assist".to_string()), + target_id: Some("target-1".to_string()), + timeout_ms: Some(20_000), + content_id: Some(existing.id.clone()), + project_id: None, + save_title: Some("不会用于当前主稿".to_string()), + }, + result: SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ + "items": [ + {"title": "modelcontextprotocol/servers"} + ] + })), + error_code: None, + error_message: None, + auth_hint: Some("请先登录 GitHub。".to_string()), + report_hint: Some("建议继续筛选 star > 1000 的仓库。".to_string()), + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }, + }; + + let saved_content = + save_existing_site_result_to_project(&db, request).expect("应写回当前主稿内容"); + let updated = manager + .get(&existing.id) + .expect("读取内容失败") + .expect("内容应存在"); + + assert_eq!(saved_content.content_id, existing.id); + assert_eq!(saved_content.project_id, workspace.id); + assert_eq!(saved_content.title, "当前主稿"); + assert_eq!(updated.title, "当前主稿"); + assert!(updated.body.contains("# 站点采集结果")); + assert_eq!( + updated + .metadata + .as_ref() + .and_then(|metadata| metadata.get("artifactKind")), + Some(&serde_json::json!("roadmap")) + ); + assert_eq!( + updated + .metadata + .as_ref() + .and_then(|metadata| metadata.get("siteAdapterName")), + Some(&serde_json::json!("github/search")) + ); + assert_eq!( + updated + .metadata + .as_ref() + .and_then(|metadata| metadata.get("siteAdapterReportHint")), + Some(&serde_json::json!("建议继续筛选 star > 1000 的仓库。")) + ); + } + + #[test] + fn should_attach_saved_content_when_run_request_includes_content_id() { + let db = setup_test_db(); + let workspace_root = tempdir().expect("创建临时目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "站点采集项目".to_string(), + workspace_root + .path() + .join("site-capability-current-content-auto-save"), + WorkspaceType::Document, + ) + .expect("创建测试项目失败"); + let manager = ContentManager::new(db.clone()); + let existing = manager + .create(ContentCreateRequest { + project_id: workspace.id.clone(), + title: "当前主稿".to_string(), + content_type: Some(ContentType::Document), + order: None, + body: Some("旧内容".to_string()), + metadata: Some(serde_json::json!({ + "artifactKind": "roadmap" + })), + }) + .expect("创建测试内容失败"); + + let request = RunSiteAdapterRequest { + adapter_name: "github/search".to_string(), + args: serde_json::json!({"query":"mcp","limit":5}), + profile_key: Some("general_browser_assist".to_string()), + target_id: Some("target-1".to_string()), + timeout_ms: Some(20_000), + content_id: Some(existing.id.clone()), + project_id: None, + save_title: Some("不应覆盖当前主稿标题".to_string()), + }; + let result = SiteAdapterRunResult { + ok: true, + adapter: "github/search".to_string(), + domain: "github.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://github.com/search?q=mcp&type=repositories".to_string(), + source_url: Some("https://github.com/search?q=mcp&type=repositories".to_string()), + data: Some(serde_json::json!({ + "items": [ + {"title": "modelcontextprotocol/servers"} + ] + })), + error_code: None, + error_message: None, + auth_hint: Some("请先登录 GitHub。".to_string()), + report_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }; + + let saved_result = attach_requested_site_result_save(&db, &request, result); + let updated = manager + .get(&existing.id) + .expect("读取内容失败") + .expect("内容应存在"); + + assert_eq!( + saved_result + .saved_content + .as_ref() + .map(|content| content.content_id.as_str()), + Some(existing.id.as_str()) + ); + assert_eq!( + saved_result.saved_project_id.as_deref(), + Some(workspace.id.as_str()) + ); + assert_eq!( + saved_result.saved_by.as_deref(), + Some(EXPLICIT_CONTENT_SAVE_SOURCE) + ); + assert_eq!(updated.title, "当前主稿"); + assert!(updated.body.contains("# 站点采集结果")); + } } diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 80790a674..f3ef9cb02 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": "0.96.0", + "version": "0.97.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 204ddd5fd..8c403ac6d 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": "0.96.0", + "version": "0.97.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 new file mode 100644 index 000000000..ae337d72b --- /dev/null +++ b/src-tauri/tests/deepseek_reasoner_output_schema_runtime.rs @@ -0,0 +1,382 @@ +use anyhow::{anyhow, Result}; +use aster::conversation::Conversation; +use aster::model::ModelConfig; +use aster::providers::api_client::{ApiClient, AuthMethod}; +use aster::providers::base::Provider; +use aster::providers::openai::OpenAiProvider; +use aster::recipe::Recipe; +use aster::session::{ + ChatHistoryMatch, CommitOptions, CommitReport, ExtensionData, MemoryCategory, MemoryHealth, + MemoryRecord, MemorySearchResult, MemoryStats, NoopSessionStore, Session, SessionInsights, + SessionStore, SessionType, TokenStatsUpdate, TurnOutputSchemaStrategy, +}; +use async_trait::async_trait; +use chrono::Utc; +use lime_agent::{build_session_execution_runtime, SessionConfigBuilder}; +use lime_lib::services::artifact_output_schema_service::merge_turn_context_with_artifact_output_schema; +use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::sync::RwLock; +use uuid::Uuid; + +fn build_openai_provider(model_config: ModelConfig) -> Result { + let api_client = ApiClient::new( + "https://api.deepseek.com".to_string(), + AuthMethod::BearerToken("test-key".to_string()), + )?; + Ok(OpenAiProvider::new(api_client, model_config)) +} + +struct TestSessionStore { + fallback: NoopSessionStore, + sessions: RwLock>, +} + +impl Default for TestSessionStore { + fn default() -> Self { + Self { + fallback: NoopSessionStore, + sessions: RwLock::new(HashMap::new()), + } + } +} + +impl TestSessionStore { + async fn create_user_session(&self, working_dir: PathBuf, name: &str) -> Result { + ::create_session( + self, + working_dir, + name.to_string(), + SessionType::User, + ) + .await + } +} + +#[async_trait] +impl SessionStore for TestSessionStore { + async fn create_session( + &self, + working_dir: PathBuf, + name: String, + session_type: SessionType, + ) -> Result { + let session = Session { + id: format!("test-session-{}", Uuid::new_v4()), + working_dir, + name, + user_set_name: false, + session_type, + created_at: Utc::now(), + updated_at: Utc::now(), + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: Some(Conversation::default()), + message_count: 0, + provider_name: None, + model_config: None, + }; + self.sessions + .write() + .await + .insert(session.id.clone(), session.clone()); + Ok(session) + } + + async fn get_session(&self, id: &str, _include_messages: bool) -> Result { + self.sessions + .read() + .await + .get(id) + .cloned() + .ok_or_else(|| anyhow!("session not found: {id}")) + } + + async fn add_message( + &self, + session_id: &str, + message: &aster::conversation::message::Message, + ) -> Result<()> { + self.fallback.add_message(session_id, message).await + } + + async fn replace_conversation( + &self, + session_id: &str, + conversation: &Conversation, + ) -> Result<()> { + self.fallback + .replace_conversation(session_id, conversation) + .await + } + + async fn list_sessions(&self) -> Result> { + Ok(self.sessions.read().await.values().cloned().collect()) + } + + async fn list_sessions_by_types(&self, types: &[SessionType]) -> Result> { + Ok(self + .sessions + .read() + .await + .values() + .filter(|session| types.contains(&session.session_type)) + .cloned() + .collect()) + } + + async fn delete_session(&self, id: &str) -> Result<()> { + self.sessions.write().await.remove(id); + Ok(()) + } + + async fn get_insights(&self) -> Result { + Ok(SessionInsights { + total_sessions: self.sessions.read().await.len(), + total_tokens: 0, + }) + } + + async fn export_session(&self, id: &str) -> Result { + self.fallback.export_session(id).await + } + + async fn import_session(&self, json: &str) -> Result { + self.fallback.import_session(json).await + } + + async fn copy_session(&self, session_id: &str, new_name: String) -> Result { + self.fallback.copy_session(session_id, new_name).await + } + + async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> { + self.fallback + .truncate_conversation(session_id, timestamp) + .await + } + + async fn update_session_name( + &self, + session_id: &str, + name: String, + user_set: bool, + ) -> Result<()> { + self.fallback + .update_session_name(session_id, name, user_set) + .await + } + + async fn update_extension_data( + &self, + session_id: &str, + extension_data: ExtensionData, + ) -> Result<()> { + self.fallback + .update_extension_data(session_id, extension_data) + .await + } + + async fn update_token_stats(&self, session_id: &str, stats: TokenStatsUpdate) -> Result<()> { + self.fallback.update_token_stats(session_id, stats).await + } + + async fn update_provider_config( + &self, + session_id: &str, + provider_name: Option, + model_config: Option, + ) -> Result<()> { + let mut sessions = self.sessions.write().await; + let session = sessions + .get_mut(session_id) + .ok_or_else(|| anyhow!("session not found: {session_id}"))?; + session.provider_name = provider_name; + session.model_config = model_config; + session.updated_at = Utc::now(); + Ok(()) + } + + async fn update_recipe( + &self, + session_id: &str, + recipe: Option, + user_recipe_values: Option>, + ) -> Result<()> { + self.fallback + .update_recipe(session_id, recipe, user_recipe_values) + .await + } + + async fn search_chat_history( + &self, + query: &str, + limit: Option, + after_date: Option>, + before_date: Option>, + exclude_session_id: Option, + ) -> Result> { + self.fallback + .search_chat_history(query, limit, after_date, before_date, exclude_session_id) + .await + } + + async fn commit_session(&self, id: &str, options: CommitOptions) -> Result { + self.fallback.commit_session(id, options).await + } + + async fn search_memories( + &self, + query: &str, + limit: Option, + session_scope: Option<&str>, + categories: Option>, + ) -> Result> { + self.fallback + .search_memories(query, limit, session_scope, categories) + .await + } + + async fn retrieve_context_memories( + &self, + session_id: &str, + query: &str, + limit: usize, + ) -> Result> { + self.fallback + .retrieve_context_memories(session_id, query, limit) + .await + } + + async fn memory_stats(&self) -> Result { + self.fallback.memory_stats().await + } + + async fn memory_health(&self) -> Result { + self.fallback.memory_health().await + } +} + +#[test] +fn deepseek_reasoner_should_not_use_openai_native_output_schema() -> Result<()> { + let deepseek_model = ModelConfig::new("deepseek-reasoner")?; + let deepseek_provider = build_openai_provider(deepseek_model.clone())?; + assert!( + !deepseek_provider.supports_native_output_schema_with_model(&deepseek_model), + "deepseek-reasoner 不应被判定为 OpenAI native output schema 模型" + ); + + let codex_model = ModelConfig::new("gpt-5.3-codex")?; + let codex_provider = build_openai_provider(codex_model.clone())?; + assert!( + codex_provider.supports_native_output_schema_with_model(&codex_model), + "gpt-5.3-codex 应保持 native output schema 能力" + ); + + Ok(()) +} + +#[tokio::test] +async fn artifact_runtime_should_mark_deepseek_reasoner_as_final_output_tool() -> Result<()> { + let working_dir = tempdir()?; + let store = Arc::new(TestSessionStore::default()); + let session = store + .create_user_session( + working_dir.path().to_path_buf(), + "deepseek artifact runtime", + ) + .await?; + let agent = aster::agents::Agent::new().with_session_store(store.clone()); + + let model_config = ModelConfig::new("deepseek-reasoner")?; + let provider = Arc::new(build_openai_provider(model_config.clone())?); + agent.update_provider(provider, &session.id).await?; + + let request_metadata = json!({ + "artifact": { + "artifact_mode": "draft", + "artifact_stage": "stage2", + "artifact_kind": "report", + "source_policy": "required" + } + }); + let turn_context = merge_turn_context_with_artifact_output_schema( + Some(aster::session::TurnContextOverride { + model: Some("deepseek-reasoner".to_string()), + ..aster::session::TurnContextOverride::default() + }), + Some(&request_metadata), + ) + .expect("turn context"); + assert!(turn_context.output_schema.is_some()); + + let session_config = SessionConfigBuilder::new(&session.id) + .thread_id("thread-deepseek") + .turn_id("turn-deepseek") + .turn_context(turn_context) + .build(); + + agent + .ensure_runtime_turn_initialized(&session_config, Some("生成 Artifact 文档".to_string())) + .await?; + + let snapshot = agent.runtime_snapshot(&session.id).await?; + let turn = snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .find(|turn| turn.id == "turn-deepseek") + .expect("runtime turn"); + + assert_eq!(turn.status, aster::session::TurnStatus::Running); + let output_schema_runtime = turn + .output_schema_runtime + .as_ref() + .expect("output schema runtime"); + assert_eq!( + output_schema_runtime.strategy, + TurnOutputSchemaStrategy::FinalOutputTool + ); + assert_eq!( + output_schema_runtime.model_name.as_deref(), + Some("deepseek-reasoner") + ); + + let updated_session = store.get_session(&session.id, false).await?; + let execution_runtime = build_session_execution_runtime( + &session.id, + Some(&updated_session), + None, + Some(&snapshot), + Some("deepseek".to_string()), + ) + .expect("execution runtime"); + + assert_eq!( + execution_runtime + .output_schema_runtime + .as_ref() + .map(|runtime| runtime.strategy), + Some(TurnOutputSchemaStrategy::FinalOutputTool) + ); + assert_eq!( + execution_runtime.model_name.as_deref(), + Some("deepseek-reasoner") + ); + assert_eq!( + execution_runtime.latest_turn_status.as_deref(), + Some("running") + ); + + Ok(()) +} diff --git a/src/App.tsx b/src/App.tsx index cee7b3365..e659cd960 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { ComponentDebugOverlay } from "./components/dev"; import { AgentPageParams, AutomationPageParams, + BrowserRuntimePageParams, getThemeByWorkspacePage, getThemeWorkspacePage, isThemeWorkspacePage, @@ -705,9 +706,25 @@ function AppContent() { } if (currentPage === "browser-runtime") { + const browserRuntimeParams = pageParams as BrowserRuntimePageParams; return ( - + ); } diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index aa2706828..43b9dc47c 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -14,6 +14,7 @@ const { mockHomeShellExecutionStrategy, mockHomeShellModel, mockHomeShellProviderType, + mockHomeShellRecentExecutionRuntime, mockListProjects, mockSetExecutionStrategy, mockSetModel, @@ -148,9 +149,8 @@ const { isRecent: false, runnerLabel: "本地计划任务", runnerTone: "sky", - runnerDescription: - "当前先进入工作区生成首版任务方案,后续再接本地自动化。", - actionLabel: "先做方案", + runnerDescription: "可直接创建本地定时任务,并回流到任务中心与工作区。", + actionLabel: "创建任务", automationStatus: { jobId: "automation-job-daily-trend", jobName: "每日趋势摘要", @@ -159,6 +159,56 @@ const { detail: "下次 03/24 09:00", }, }, + { + id: "github-repo-radar", + title: "GitHub 仓库线索检索", + summary: + "复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。", + category: "情报研究", + outputHint: "仓库列表 + 关键线索", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + defaultArtifactKind: "analysis", + themeTarget: "knowledge", + version: "seed-v1", + readinessRequirements: { + requiresBrowser: true, + requiresProject: true, + }, + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + requireAttachedSession: true, + saveMode: "current_content", + slotArgMap: { + repository_query: "query", + }, + fixedArgs: { + limit: 10, + }, + suggestedTitleTemplate: "GitHub 仓库线索 · {{repository_query}}", + }, + slotSchema: [ + { + key: "repository_query", + label: "检索主题", + type: "text", + required: true, + placeholder: "例如 MCP agent browser automation", + }, + ], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald", + runnerDescription: + "直接进入浏览器工作台,复用真实登录态执行站点脚本并沉淀结果。", + actionLabel: "启动采集", + automationStatus: null, + }, ]; const mockRecordClawSolutionUsage = vi.fn(); @@ -202,6 +252,7 @@ const { mockHomeShellProviderType: { current: "mock-provider" }, mockHomeShellModel: { current: "mock-model" }, mockHomeShellExecutionStrategy: { current: "react" }, + mockHomeShellRecentExecutionRuntime: { current: null as unknown }, mockListProjects: vi.fn(async () => [ { id: "project-1", @@ -276,6 +327,8 @@ vi.mock("./components/EmptyState", () => ({ onSend, onRecommendationClick, supportingSlotOverride, + serviceSkills, + onSelectServiceSkill, }: { onSend: ( value: string, @@ -284,6 +337,8 @@ vi.mock("./components/EmptyState", () => ({ ) => void; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; supportingSlotOverride?: React.ReactNode; + serviceSkills?: Array<{ id: string; title: string }>; + onSelectServiceSkill?: (skill: { id: string; title: string }) => void; }) => ( <> + {serviceSkills?.[0] && onSelectServiceSkill ? ( + + ) : null} {supportingSlotOverride} ), @@ -353,6 +417,7 @@ vi.mock("./hooks/useHomeShellAgentPreferences", () => ({ setModel: mockSetModel, executionStrategy: mockHomeShellExecutionStrategy.current, setExecutionStrategy: mockSetExecutionStrategy, + recentExecutionRuntime: mockHomeShellRecentExecutionRuntime.current, })), })); @@ -534,9 +599,13 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ industry_keywords: "AI Agent,创作者工具", schedule_time: "每天 09:00", } - : { - reference_video: "https://example.com/video", - }, + : skill.id === "github-repo-radar" + ? { + repository_query: "browser assist mcp", + } + : { + reference_video: "https://example.com/video", + }, ) } > @@ -677,6 +746,7 @@ beforeEach(() => { mockHomeShellProviderType.current = "mock-provider"; mockHomeShellModel.current = "mock-model"; mockHomeShellExecutionStrategy.current = "react"; + mockHomeShellRecentExecutionRuntime.current = null; mockUseClawSolutions.mockImplementation(() => ({ solutions: mockClawSolutions, isLoading: false, @@ -879,6 +949,53 @@ describe("AgentChatHomeShell", () => { ); }); + it("最近 session runtime 的工具偏好应先回灌首页壳,再参与 team 推荐", async () => { + mockHomeShellRecentExecutionRuntime.current = { + recent_preferences: { + webSearch: true, + thinking: true, + task: false, + subagent: false, + }, + recent_team_selection: null, + }; + const onEnterWorkspace = vi.fn(); + const { container } = renderShell({ + onNavigate: undefined, + onEnterWorkspace, + }); + + await flushEffects(); + + const teamRecommendationButton = container.querySelector( + '[data-testid="home-shell-team-recommendation"]', + ) as HTMLButtonElement | null; + + expect(teamRecommendationButton).toBeTruthy(); + + act(() => { + teamRecommendationButton?.click(); + }); + + await flushEffects(); + + expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( + expect.objectContaining({ + webSearch: true, + thinking: true, + task: false, + subagent: true, + }), + "general", + ); + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + theme: "general", + }), + ); + }); + it("点击社媒方案时应切换到 social-media 工作区", async () => { const onNavigate = vi.fn(); mockLoadConfiguredProviders.mockResolvedValueOnce([ @@ -1182,6 +1299,7 @@ describe("AgentChatHomeShell", () => { contentId: "content-service-skill-1", theme: "video", initialCreationMode: "guided", + autoRunInitialPromptOnMount: true, initialRequestMetadata: { artifact: { artifact_mode: "draft", @@ -1206,8 +1324,99 @@ describe("AgentChatHomeShell", () => { }); }); - it("cloud_required 服务型技能应提交云端运行且不进入本地工作区", async () => { + it("通过首页输入区 @ 选择服务型技能时应打开补参弹窗", async () => { + const { container } = renderShell(); + + await flushEffects(); + + const mentionServiceSkillButton = container.querySelector( + '[data-testid="home-shell-empty-state-service-skill"]', + ) as HTMLButtonElement | null; + + expect(mentionServiceSkillButton).toBeTruthy(); + + act(() => { + mentionServiceSkillButton?.click(); + }); + + await flushEffects(); + + const launchButton = container.querySelector( + '[data-testid="home-shell-service-skill-launch"]', + ) as HTMLButtonElement | null; + + expect(launchButton).toBeTruthy(); + }); + + it("站点型服务技能应直接导航到浏览器工作台并预填自动执行参数", async () => { + const onNavigate = vi.fn(); const onEnterWorkspace = vi.fn(); + const { container } = renderShell({ + onNavigate, + onEnterWorkspace, + }); + + await flushEffects(); + + const serviceSkillButton = container.querySelector( + '[data-testid="home-shell-service-skill-github-repo-radar"]', + ) as HTMLButtonElement | null; + + expect(serviceSkillButton).toBeTruthy(); + + act(() => { + serviceSkillButton?.click(); + }); + + await flushEffects(); + + const launchButton = container.querySelector( + '[data-testid="home-shell-service-skill-launch"]', + ) as HTMLButtonElement | null; + + expect(launchButton).toBeTruthy(); + + act(() => { + launchButton?.click(); + }); + + await flushEffects(); + + expect(mockCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "project-1", + title: "GitHub 仓库线索检索", + content_type: "document", + }), + ); + expect(onNavigate).toHaveBeenCalledWith("browser-runtime", { + projectId: "project-1", + contentId: "content-service-skill-1", + initialAdapterName: "github/search", + initialArgs: { + query: "browser assist mcp", + limit: 10, + }, + initialAutoRun: true, + initialRequireAttachedSession: true, + initialSaveTitle: undefined, + }); + expect(onEnterWorkspace).not.toHaveBeenCalled(); + expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "github-repo-radar", + runnerType: "instant", + }); + }); + + it("cloud_required 服务型技能成功后应回流本地工作区", async () => { + const onEnterWorkspace = vi.fn(); + mockCreateServiceSkillRun.mockResolvedValue({ + id: "service-skill-run-cloud-1", + status: "success", + outputSummary: "云端结果已生成", + outputText: "# 云端视频配音\n\n第一版成稿", + finishedAt: "2026-03-26T01:02:03.000Z", + }); mockUseServiceSkills.mockImplementation(() => ({ skills: [ { @@ -1268,7 +1477,43 @@ describe("AgentChatHomeShell", () => { "cloud-video-dubbing", expect.stringContaining("- 参考视频链接/素材: https://example.com/video"), ); - expect(onEnterWorkspace).not.toHaveBeenCalled(); + expect(mockCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "project-1", + title: "云端视频配音", + content_type: "episode", + body: "# 云端视频配音\n\n第一版成稿", + metadata: expect.objectContaining({ + source: "service_skill", + serviceSkill: expect.objectContaining({ + id: "cloud-video-dubbing", + executionLocation: "cloud_required", + themeTarget: "video", + }), + cloudRun: expect.objectContaining({ + id: "service-skill-run-cloud-1", + status: "success", + outputSummary: "云端结果已生成", + finishedAt: "2026-03-26T01:02:03.000Z", + }), + }), + }), + ); + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + contentId: "content-service-skill-1", + theme: "video", + initialCreationMode: "guided", + initialRequestMetadata: { + artifact: { + artifact_mode: "draft", + artifact_kind: "brief", + workbench_surface: "right_panel", + }, + }, + }), + ); expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ skillId: "cloud-video-dubbing", runnerType: "instant", @@ -1277,7 +1522,7 @@ describe("AgentChatHomeShell", () => { "正在提交 云端视频配音 到云端...", ); expect(mockToastSuccess).toHaveBeenCalledWith( - "云端视频配音 云端运行完成:云端结果已生成", + "云端视频配音 云端运行完成:云端结果已生成,正在回流本地工作区。", { id: "toast-loading", }, @@ -1354,6 +1599,34 @@ describe("AgentChatHomeShell", () => { artifact_mode: "draft", artifact_kind: "analysis", }), + service_skill: expect.objectContaining({ + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + slot_values: [ + { + key: "platform", + label: "监测平台", + value: "X / Twitter", + }, + { + key: "industry_keywords", + label: "行业关键词", + value: "AI Agent,创作者工具", + }, + { + key: "schedule_time", + label: "推送时间", + value: "每天 09:00", + }, + ], + slot_summary: [ + "监测平台: X / Twitter", + "行业关键词: AI Agent,创作者工具", + "推送时间: 每天 09:00", + ], + user_input: null, + }), harness: expect.objectContaining({ theme: "social-media", session_mode: "theme_workbench", @@ -1381,6 +1654,7 @@ describe("AgentChatHomeShell", () => { contentId: "content-service-skill-1", theme: "social-media", initialCreationMode: "guided", + autoRunInitialPromptOnMount: true, initialRequestMetadata: { artifact: { artifact_mode: "draft", diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index 2ffe6ff0e..498c671e0 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import styled from "styled-components"; import { toast } from "sonner"; import { createAutomationJob } from "@/lib/api/automation"; @@ -15,13 +15,14 @@ import { type AutomationJobDialogInitialValues, type AutomationJobDialogSubmit, } from "@/components/settings-v2/system/automation/AutomationJobDialog"; -import type { Page, PageParams } from "@/types/page"; +import type { BrowserRuntimePageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { EmptyState } from "./components/EmptyState"; import type { CreationMode } from "./components/types"; import { saveChatToolPreferences } from "./utils/chatToolPreferences"; import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations"; import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection"; +import { createChatToolPreferencesFromExecutionRuntime } from "./utils/sessionExecutionRuntime"; import { normalizeProjectId } from "./utils/topicProjectResolution"; import { LAST_PROJECT_ID_KEY, @@ -56,11 +57,17 @@ import { supportsServiceSkillLocalAutomation, } from "./service-skills/automationDraft"; import { recordServiceSkillAutomationLink } from "./service-skills/automationLinkStorage"; +import { recordServiceSkillCloudRun } from "./service-skills/cloudRunStorage"; import type { ServiceSkillHomeItem, ServiceSkillSlotValues, } from "./service-skills/types"; import { buildServiceSkillWorkspaceSeed } from "./service-skills/workspaceLaunch"; +import { + buildServiceSkillSiteCapabilityArgs, + buildServiceSkillSiteCapabilitySaveTitle, + isServiceSkillSiteCapabilityBound, +} from "./service-skills/siteCapabilityBinding"; const PageContainer = styled.div<{ $compact?: boolean }>` display: flex; @@ -168,6 +175,44 @@ function getErrorMessage(error: unknown): string { return "请稍后重试"; } +function normalizeOptionalText(value?: string | null): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim(); + return normalized ? normalized : undefined; +} + +function buildServiceSkillCloudResultBody( + skill: ServiceSkillHomeItem, + run: ServiceSkillRun, +): string { + return ( + normalizeOptionalText(run.outputText) || + normalizeOptionalText(run.outputSummary) || + `# ${skill.title}\n\n云端结果已生成。` + ); +} + +function buildServiceSkillCloudResultMetadata( + run: ServiceSkillRun, +): Record { + return { + cloudRun: { + id: run.id, + status: run.status, + executorKind: run.executorKind ?? null, + outputSummary: normalizeOptionalText(run.outputSummary) ?? null, + errorCode: run.errorCode ?? null, + errorMessage: run.errorMessage ?? null, + startedAt: run.startedAt ?? null, + finishedAt: run.finishedAt ?? null, + updatedAt: run.updatedAt ?? null, + }, + }; +} + function resolveFallbackProjectType(theme?: string): Project["workspaceType"] { switch (theme) { case "social-media": @@ -250,15 +295,17 @@ function buildServiceSkillRunSuccessMessage( ): string { const summary = run.outputSummary || run.outputText || run.inputSummary; if (summary) { - return `${skill.title} 云端运行完成:${summary}`; + return `${skill.title} 云端运行完成:${summary},正在回流本地工作区。`; } - return `${skill.title} 云端运行完成。`; + return `${skill.title} 云端运行完成,正在回流本地工作区。`; } interface PendingServiceSkillAutomationLaunch { skill: ServiceSkillHomeItem; prompt: string; + slotValues: ServiceSkillSlotValues; + userInput?: string; usage: { skillId: string; runnerType: ServiceSkillHomeItem["runnerType"]; @@ -290,13 +337,18 @@ export function AgentChatHomeShell({ const [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); - const { chatToolPreferences, setChatToolPreferences } = - useThemeScopedChatToolPreferences(activeTheme); const { projectId: currentProjectId, setProjectId: setCurrentProjectId, rememberProjectId, } = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY); + const { + chatToolPreferences, + setChatToolPreferences, + syncChatToolPreferencesSource, + } = useThemeScopedChatToolPreferences(activeTheme, { + scopeId: currentProjectId, + }); const { providerType, setProviderType, @@ -304,6 +356,7 @@ export function AgentChatHomeShell({ setModel, executionStrategy, setExecutionStrategy, + recentExecutionRuntime, } = useHomeShellAgentPreferences(currentProjectId); const projectMemory = useHomeShellProjectMemory(currentProjectId); const { skills, skillsLoading, refreshSkills } = useHomeShellSkills(); @@ -312,7 +365,13 @@ export function AgentChatHomeShell({ selectedTeam, setSelectedTeam: handleSelectTeam, enableSuggestedTeam: handleEnableSuggestedTeam, - } = useSelectedTeamPreference(activeTheme); + } = useSelectedTeamPreference(activeTheme, { + runtimeSelection: recentExecutionRuntime?.recent_team_selection ?? null, + }); + const runtimeChatToolPreferences = useMemo( + () => createChatToolPreferencesFromExecutionRuntime(recentExecutionRuntime), + [recentExecutionRuntime], + ); const { solutions: clawSolutions, isLoading: clawSolutionsLoading, @@ -366,6 +425,10 @@ export function AgentChatHomeShell({ toast.error(`加载服务型技能失败:${serviceSkillsError}`); }, [activeTheme, serviceSkillsError]); + useEffect(() => { + syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences); + }, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]); + const handleRefreshSkills = useCallback(async () => { await refreshSkills(true); }, [refreshSkills]); @@ -542,7 +605,14 @@ export function AgentChatHomeShell({ ); const createServiceSkillSeededContent = useCallback( - async (skill: ServiceSkillHomeItem, projectId?: string | null) => { + async ( + skill: ServiceSkillHomeItem, + projectId?: string | null, + options?: { + body?: string; + metadata?: Record; + }, + ) => { const normalizedProjectId = normalizeProjectId( projectId ?? currentProjectId, ); @@ -555,17 +625,60 @@ export function AgentChatHomeShell({ return null; } + const mergedMetadata = { + ...(seed.metadata ?? {}), + ...(options?.metadata ?? {}), + }; + return createContent({ project_id: normalizedProjectId, title: seed.title, content_type: seed.contentType, - body: "", - metadata: seed.metadata, + body: options?.body ?? "", + metadata: + Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined, }); }, [activeTheme, currentProjectId], ); + const prepareServiceSkillCloudResultWorkspacePayload = useCallback( + async ( + skill: ServiceSkillHomeItem, + run: ServiceSkillRun, + ): Promise => { + const normalizedProjectId = normalizeProjectId(currentProjectId); + const seed = buildServiceSkillWorkspaceSeed( + skill, + skill.themeTarget ?? activeTheme, + ); + + if (!normalizedProjectId || !seed) { + return null; + } + + const created = await createServiceSkillSeededContent( + skill, + normalizedProjectId, + { + body: buildServiceSkillCloudResultBody(skill, run), + metadata: buildServiceSkillCloudResultMetadata(run), + }, + ); + + if (!created) { + return null; + } + + return { + contentId: created.id, + themeOverride: skill.themeTarget, + initialRequestMetadata: seed.requestMetadata, + }; + }, + [activeTheme, createServiceSkillSeededContent, currentProjectId], + ); + const prepareServiceSkillWorkspacePayload = useCallback( async ( skill: ServiceSkillHomeItem, @@ -590,6 +703,7 @@ export function AgentChatHomeShell({ contentId: existingContentId, themeOverride: skill.themeTarget, initialRequestMetadata: seed?.requestMetadata, + autoRunInitialPromptOnMount: true, }; } @@ -598,6 +712,7 @@ export function AgentChatHomeShell({ prompt, themeOverride: skill.themeTarget, initialRequestMetadata: seed?.requestMetadata, + autoRunInitialPromptOnMount: true, }; } @@ -611,6 +726,7 @@ export function AgentChatHomeShell({ prompt, themeOverride: skill.themeTarget, initialRequestMetadata: seed.requestMetadata, + autoRunInitialPromptOnMount: true, }; } @@ -619,13 +735,93 @@ export function AgentChatHomeShell({ contentId: created.id, themeOverride: skill.themeTarget, initialRequestMetadata: seed.requestMetadata, + autoRunInitialPromptOnMount: true, }; }, [activeTheme, createServiceSkillSeededContent, currentProjectId], ); + const handleServiceSkillBrowserRuntimeLaunch = useCallback( + async ( + skill: ServiceSkillHomeItem, + slotValues: ServiceSkillSlotValues, + ): Promise => { + if (!isServiceSkillSiteCapabilityBound(skill)) { + return; + } + + if (!onNavigate) { + toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。"); + return; + } + + const normalizedProjectId = normalizeProjectId(currentProjectId); + if ( + skill.readinessRequirements?.requiresProject && + !normalizedProjectId + ) { + toast.error("缺少项目工作区,请先选择项目后再启动浏览器采集。"); + return; + } + + const binding = skill.siteCapabilityBinding; + const saveMode = binding.saveMode ?? "project_resource"; + const initialArgs = buildServiceSkillSiteCapabilityArgs( + skill, + slotValues, + ); + const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle( + skill, + slotValues, + ); + let contentId: string | undefined; + + if (saveMode === "current_content" && normalizedProjectId) { + try { + const created = await createServiceSkillSeededContent( + skill, + normalizedProjectId, + ); + contentId = created?.id ?? undefined; + } catch (error) { + toast.error(`准备浏览器采集主稿失败:${getErrorMessage(error)}`); + return; + } + } + + const navigationParams: BrowserRuntimePageParams = { + projectId: normalizedProjectId ?? undefined, + contentId, + initialAdapterName: binding.adapterName, + initialArgs, + initialAutoRun: binding.autoRun ?? false, + initialRequireAttachedSession: binding.requireAttachedSession ?? false, + initialSaveTitle: contentId ? undefined : initialSaveTitle, + }; + + onNavigate("browser-runtime", navigationParams); + recordServiceSkillUsage({ + skillId: skill.id, + runnerType: skill.runnerType, + }); + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + }, + [ + createServiceSkillSeededContent, + currentProjectId, + onNavigate, + recordServiceSkillUsage, + ], + ); + const handleServiceSkillLaunch = useCallback( async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => { + if (isServiceSkillSiteCapabilityBound(skill)) { + await handleServiceSkillBrowserRuntimeLaunch(skill, slotValues); + return; + } + const prompt = composeServiceSkillPrompt({ skill, slotValues, @@ -640,6 +836,7 @@ export function AgentChatHomeShell({ setSelectedServiceSkill(null); let run = await createServiceSkillRun(skill.id, prompt); + recordServiceSkillCloudRun(skill.id, run); recordServiceSkillUsage({ skillId: skill.id, runnerType: skill.runnerType, @@ -656,6 +853,7 @@ export function AgentChatHomeShell({ for (let attempt = 0; attempt < 12; attempt += 1) { await sleep(2_000); run = await getServiceSkillRun(run.id); + recordServiceSkillCloudRun(skill.id, run); if (isTerminalServiceSkillRunStatus(run.status)) { break; } @@ -663,9 +861,35 @@ export function AgentChatHomeShell({ } if (run.status === "success") { + let workspacePayload: HomeShellEnterWorkspacePayload | null = null; + let workspaceErrorMessage: string | null = null; + + try { + workspacePayload = + await prepareServiceSkillCloudResultWorkspacePayload( + skill, + run, + ); + } catch (error) { + workspaceErrorMessage = getErrorMessage(error); + } + toast.success(buildServiceSkillRunSuccessMessage(skill, run), { id: toastId, }); + + if (workspacePayload) { + const entered = handleEnterWorkspace(workspacePayload); + if (!entered) { + toast.error( + "云端结果已生成,但进入工作区失败,请稍后手动打开。", + ); + } + } else if (workspaceErrorMessage) { + toast.error( + `云端结果已生成,但回流本地工作区失败:${workspaceErrorMessage}`, + ); + } return; } @@ -691,9 +915,7 @@ export function AgentChatHomeShell({ } if (skill.runnerType !== "instant") { - toast.info( - "当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。", - ); + toast.info("当前先进入工作区生成首版结果;如需持续运行,可继续创建本地任务。"); } let workspacePayload: HomeShellEnterWorkspacePayload; @@ -721,8 +943,10 @@ export function AgentChatHomeShell({ setSelectedServiceSkill(null); }, [ + handleServiceSkillBrowserRuntimeLaunch, handleEnterWorkspace, input, + prepareServiceSkillCloudResultWorkspacePayload, prepareServiceSkillWorkspacePayload, recordServiceSkillUsage, ], @@ -746,6 +970,7 @@ export function AgentChatHomeShell({ slotValues, userInput: input.trim() || undefined, }); + const userInput = input.trim() || undefined; try { let workspaces: Project[]; @@ -769,13 +994,15 @@ export function AgentChatHomeShell({ buildServiceSkillAutomationInitialValues({ skill, slotValues, - userInput: input.trim() || undefined, + userInput, workspaceId: normalizedProjectId, }), ); setPendingServiceSkillAutomation({ skill, prompt, + slotValues, + userInput, usage: { skillId: skill.id, runnerType: skill.runnerType, @@ -823,6 +1050,8 @@ export function AgentChatHomeShell({ ...request.payload, ...buildServiceSkillAutomationAgentTurnPayloadContext({ skill: pendingLaunch.skill, + slotValues: pendingLaunch.slotValues, + userInput: pendingLaunch.userInput, contentId: automationContentId, }), }, @@ -1009,7 +1238,9 @@ export function AgentChatHomeShell({ } characters={projectMemory?.characters || []} skills={skills} + serviceSkills={activeTheme === "general" ? serviceSkills : []} isSkillsLoading={skillsLoading} + onSelectServiceSkill={handleServiceSkillSelect} onNavigateToSettings={() => { onNavigate?.("settings", { tab: SettingsTabs.Skills, diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 76528db15..3ebcdabfd 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -58,6 +58,7 @@ import { ensureWorkspaceReady, type Project, } from "@/lib/api/project"; +import { updateAgentRuntimeSession } from "@/lib/api/agentRuntime"; import { getProjectMemory, type ProjectMemory, @@ -79,11 +80,16 @@ import type { ThemeType, LayoutMode } from "@/components/content-creator/types"; import { normalizeProjectId } from "./utils/topicProjectResolution"; import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata"; import { deriveHarnessSessionState } from "./utils/harnessState"; +import { loadChatToolPreferences } from "./utils/chatToolPreferences"; import { mergeArtifacts, resolveDefaultArtifactViewMode, } from "./utils/messageArtifacts"; -import { createChatToolPreferencesFromExecutionRuntime } from "./utils/sessionExecutionRuntime"; +import { + createChatToolPreferencesFromExecutionRuntime, + createSessionRecentPreferencesFromChatToolPreferences, + createSessionRecentTeamSelectionFromTeamDefinition, +} from "./utils/sessionExecutionRuntime"; import { buildRealSubagentTimelineItems, buildSyntheticSubagentTimelineItems, @@ -93,9 +99,11 @@ import { resolveAgentChatMode, } from "./utils/generalAgentPrompt"; import { loadPersistedProjectId } from "./hooks/agentProjectStorage"; +import { loadPersistedSessionWorkspaceId } from "./hooks/agentProjectStorage"; import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences"; import { useLimeSkills } from "./hooks/useLimeSkills"; +import { useServiceSkills } from "./service-skills/useServiceSkills"; import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection"; import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview"; import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation"; @@ -134,6 +142,7 @@ import { useWorkspaceWorkflowProgressSync } from "./workspace/useWorkspaceWorkfl import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasLayoutRuntime"; import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync"; import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync"; +import { useWorkspaceArtifactWorkbenchActions } from "./workspace/useWorkspaceArtifactWorkbenchActions"; import { useWorkspaceImageWorkbenchActionRuntime } from "./workspace/useWorkspaceImageWorkbenchActionRuntime"; import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspaceImageWorkbenchEventRuntime"; import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime"; @@ -153,6 +162,7 @@ import { useWorkspaceThemeWorkbenchShellRuntime } from "./workspace/useWorkspace import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions"; import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime"; import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime"; +import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions"; import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation"; @@ -174,10 +184,11 @@ import { projectTypeToTheme, } from "./agentChatWorkspaceShared"; import type { AgentChatWorkspaceProps } from "./agentChatWorkspaceContract"; +import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDialog"; +import { AutomationJobDialog } from "@/components/settings-v2/system/automation/AutomationJobDialog"; const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; -const TOPIC_PROJECT_KEY_PREFIX = "agent_session_workspace_"; export type { AgentChatWorkspaceProps, WorkflowProgressSnapshot, @@ -188,6 +199,7 @@ export function AgentChatWorkspace({ projectId: externalProjectId, contentId, initialRequestMetadata, + autoRunInitialPromptOnMount = false, agentEntry = "claw", theme: initialTheme, initialCreationMode, @@ -233,11 +245,50 @@ export function AgentChatWorkspace({ const [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); + const activeSessionIdRef = useRef(null); + const sessionRecentPreferencesBackfillKeyRef = useRef(null); + const syncSessionRecentPreferences = useCallback( + async ( + sessionId: string, + preferences: Parameters< + typeof createSessionRecentPreferencesFromChatToolPreferences + >[0], + ) => { + await updateAgentRuntimeSession({ + session_id: sessionId, + recent_preferences: + createSessionRecentPreferencesFromChatToolPreferences(preferences), + }); + }, + [], + ); + const syncSessionRecentTeamSelection = useCallback( + async ( + sessionId: string, + team: Parameters< + typeof createSessionRecentTeamSelectionFromTeamDefinition + >[0], + theme?: string | null, + ) => { + await updateAgentRuntimeSession({ + session_id: sessionId, + recent_team_selection: + createSessionRecentTeamSelectionFromTeamDefinition(team, theme), + }); + }, + [], + ); const { chatToolPreferences, setChatToolPreferences, syncChatToolPreferencesSource, - } = useThemeScopedChatToolPreferences(activeTheme); + getSyncedSessionRecentPreferences, + } = useThemeScopedChatToolPreferences(activeTheme, { + sessionSync: { + getSessionId: () => activeSessionIdRef.current, + setSessionRecentPreferences: syncSessionRecentPreferences, + }, + }); const { projectId, shouldDisableSessionRestore, @@ -338,14 +389,6 @@ export function AgentChatWorkspace({ } | null>(null); const [novelChapterListCollapsed, setNovelChapterListCollapsed] = useState(false); - const { - selectedTeam, - setSelectedTeam: handleSelectTeam, - enableSuggestedTeam: handleEnableSuggestedTeam, - preferredTeamPresetId, - selectedTeamLabel, - selectedTeamSummary, - } = useSelectedTeamPreference(activeTheme); useEffect(() => { setActiveContentTarget(projectId, contentId, canvasState?.type ?? null); @@ -506,6 +549,21 @@ export function AgentChatWorkspace({ console.warn("[AgentChatPage] 加载 skills 失败:", error); }, }); + const { + skills: serviceSkills, + isLoading: serviceSkillsLoading, + error: serviceSkillsError, + recordUsage: recordServiceSkillUsage, + } = useServiceSkills(activeTheme === "general"); + + useEffect(() => { + if (activeTheme !== "general" || !serviceSkillsError) { + return; + } + + toast.error(`加载服务型技能失败:${serviceSkillsError}`); + }, [activeTheme, serviceSkillsError]); + const combinedSkillsLoading = skillsLoading || serviceSkillsLoading; // Workbench Store(用于主题工作台右侧面板状态同步) const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey); @@ -1080,6 +1138,22 @@ export function AgentChatWorkspace({ }, workspaceId: projectId ?? "", disableSessionRestore: shouldDisableSessionRestore, + getSyncedSessionRecentPreferences, + }); + activeSessionIdRef.current = sessionId; + const { + selectedTeam, + setSelectedTeam: handleSelectTeam, + enableSuggestedTeam: handleEnableSuggestedTeam, + preferredTeamPresetId, + selectedTeamLabel, + selectedTeamSummary, + } = useSelectedTeamPreference(activeTheme, { + runtimeSelection: executionRuntime?.recent_team_selection ?? null, + sessionSync: { + getSessionId: () => activeSessionIdRef.current, + setSessionRecentTeamSelection: syncSessionRecentTeamSelection, + }, }); const handleOpenSubagentSession = useCallback( (subagentSessionId: string) => { @@ -1103,6 +1177,37 @@ export function AgentChatWorkspace({ syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences); }, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]); + useEffect(() => { + const trimmedSessionId = sessionId?.trim(); + if (!trimmedSessionId || runtimeChatToolPreferences) { + return; + } + + const fallbackPreferences = loadChatToolPreferences(activeTheme); + const backfillKey = `${trimmedSessionId}:${JSON.stringify([ + fallbackPreferences.webSearch, + fallbackPreferences.thinking, + fallbackPreferences.task, + fallbackPreferences.subagent, + ])}`; + if (sessionRecentPreferencesBackfillKeyRef.current === backfillKey) { + return; + } + sessionRecentPreferencesBackfillKeyRef.current = backfillKey; + + void syncSessionRecentPreferences( + trimmedSessionId, + fallbackPreferences, + ).catch((error) => { + console.warn("[AgentChatPage] 回填会话 recent_preferences 失败:", error); + }); + }, [ + activeTheme, + runtimeChatToolPreferences, + sessionId, + syncSessionRecentPreferences, + ]); + const hasRealTeamGraph = childSubagentSessions.length > 0 || Boolean(subagentParentContext); const { @@ -1204,7 +1309,7 @@ export function AgentChatWorkspace({ projectId: projectId ?? null, sessionId: sessionId ?? null, skillsCount: skills.length, - skillsLoading, + skillsLoading: combinedSkillsLoading, topicsCount: topics.length, workspaceHealthError, }, @@ -1220,7 +1325,7 @@ export function AgentChatWorkspace({ projectId: projectId ?? null, sessionId: sessionId ?? null, skillsCount: skills.length, - skillsLoading, + skillsLoading: combinedSkillsLoading, topicsCount: topics.length, workspaceHealthError, }), @@ -1238,7 +1343,7 @@ export function AgentChatWorkspace({ projectId, sessionId, skills.length, - skillsLoading, + combinedSkillsLoading, topics.length, workspaceHealthError, ]); @@ -1506,6 +1611,7 @@ export function AgentChatWorkspace({ selectedTeamId: selectedTeam?.id, selectedTeamSource: selectedTeam?.source, selectedTeamLabel, + selectedTeamDescription: selectedTeam?.description, selectedTeamSummary, selectedTeamRoles: selectedTeam?.roles, }), @@ -1520,6 +1626,7 @@ export function AgentChatWorkspace({ mappedTheme, preferredTeamPresetId, selectedTeam?.id, + selectedTeam?.description, selectedTeam?.roles, selectedTeam?.source, selectedTeamLabel, @@ -1621,6 +1728,7 @@ export function AgentChatWorkspace({ contentId: contentId ?? undefined, sessionId: sessionId ?? undefined, isThemeWorkbench, + autoRunInitialPromptOnMount, shouldUseCompactThemeWorkbench, messagesCount: messages.length, initialDispatchKey, @@ -1735,7 +1843,9 @@ export function AgentChatWorkspace({ rememberProjectId, getRememberedProjectId, loadTopicBoundProjectId: (topicId) => - loadPersistedProjectId(`${TOPIC_PROJECT_KEY_PREFIX}${topicId}`), + topics.find((topic) => topic.id === topicId)?.workspaceId || + loadPersistedSessionWorkspaceId(topicId) || + loadPersistedProjectId(`agent_session_workspace_${topicId}`), resetTopicLocalState, }); @@ -2096,6 +2206,13 @@ export function AgentChatWorkspace({ }, [handleWriteFile], ); + const { renderToolbarActions: renderArtifactWorkbenchToolbarActions } = + useWorkspaceArtifactWorkbenchActions({ + activeTheme, + projectId, + syncGeneralArtifactToResource, + onSaveArtifactDocument: handleSaveArtifactDocument, + }); const { handleHarnessLoadFilePreview, @@ -2171,6 +2288,7 @@ export function AgentChatWorkspace({ sessionId, initialUserPrompt, initialUserImages, + autoRunInitialPromptOnMount, initialDispatchKey, messagesCount: messages.length, projectReady: Boolean(project), @@ -2269,6 +2387,17 @@ export function AgentChatWorkspace({ setWorkspaceHealthError, workspacePathMissing, }); + const workspaceServiceSkillEntryActions = + useWorkspaceServiceSkillEntryActions({ + activeTheme, + creationMode, + projectId, + contentId, + input, + chatToolPreferences, + onNavigate: _onNavigate, + recordServiceSkillUsage, + }); const inputbarScene = useWorkspaceInputbarSceneRuntime({ setMentionedCharacters, @@ -2318,7 +2447,10 @@ export function AgentChatWorkspace({ handleTaskFileClick, characters: projectMemory?.characters || [], skills, - skillsLoading, + serviceSkills: activeTheme === "general" ? serviceSkills : [], + skillsLoading: combinedSkillsLoading, + onSelectServiceSkill: + workspaceServiceSkillEntryActions.handleServiceSkillSelect, setChatToolPreferences, handleNavigateToSkillSettings, handleRefreshSkills, @@ -2375,6 +2507,7 @@ export function AgentChatWorkspace({ artifactPreviewSize, setArtifactPreviewSize, onSaveArtifactDocument: handleSaveArtifactDocument, + renderArtifactWorkbenchToolbarActions, threadItems: effectiveThreadItems, focusedBlockId: focusedArtifactBlockId, blockFocusRequestKey: artifactBlockFocusRequestKey, @@ -2464,7 +2597,7 @@ export function AgentChatWorkspace({ selectedText, handleRecommendationClick, skills, - skillsLoading, + skillsLoading: combinedSkillsLoading, handleNavigateToSkillSettings, handleRefreshSkills, handleOpenBrowserAssistInCanvas, @@ -2540,5 +2673,33 @@ export function AgentChatWorkspace({ timelineFocusRequestKey, }); - return workspaceShellSceneRuntime.shellSceneNode; + return ( + <> + {workspaceShellSceneRuntime.shellSceneNode} + + + + ); } diff --git a/src/components/agent/chat/README.md b/src/components/agent/chat/README.md index 34bc2c5f4..be03b6771 100644 --- a/src/components/agent/chat/README.md +++ b/src/components/agent/chat/README.md @@ -21,7 +21,6 @@ AI Agent 聊天页面,支持通用对话和内容创作两种模式。集成 | --------------------------- | ---------------------------------------------------------------------------- | | `ChatNavbar.tsx` | 顶部导航栏(模型选择、设置等) | | `ChatSidebar.tsx` | 侧边栏(任务列表) | -| `ChatSettings.tsx` | 设置面板 | | `MessageList.tsx` | 消息列表组件 | | `Inputbar.tsx` | 输入栏组件 | | `EmptyState.tsx` | 空状态引导(主题选择、模式选择) | diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts index 1b82951db..1ee1f72ec 100644 --- a/src/components/agent/chat/agentChatWorkspaceContract.ts +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -17,6 +17,7 @@ export interface AgentChatWorkspaceProps { projectId?: string; contentId?: string; initialRequestMetadata?: Record; + autoRunInitialPromptOnMount?: boolean; agentEntry?: "new-task" | "claw"; immersiveHome?: boolean; theme?: string; diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 42dd8357e..8600ac84c 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -708,9 +708,11 @@ describe("AgentThreadReliabilityPanel", () => { expect.stringContaining("浏览器工具执行失败"), ); expect(mockToast.success).toHaveBeenCalledWith("AI 诊断内容已复制"); - expect(container.textContent).toContain("复制给 AI"); - expect(container.textContent).toContain("复制原始 JSON"); - expect(container.textContent).toContain("会附带诊断任务说明"); + expect(container.textContent).toContain("compat 快速诊断"); + expect(container.textContent).toContain("快速复制给 AI"); + expect(container.textContent).toContain("复制原始 JSON(debug)"); + expect(container.textContent).toContain("外部分析交接"); + expect(container.textContent).toContain("analysis-brief.md / analysis-context.json"); }); it("应支持复制原始 JSON 诊断数据", async () => { diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 13898dc6d..9d507ac3e 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -755,6 +755,12 @@ export const AgentThreadReliabilityPanel: React.FC< 线程可靠性
+ + compat 快速诊断 + - 复制给 AI + 快速复制给 AI
-
- “复制给 AI” 会附带诊断任务说明、运行环境、过程信号与最近消息;“复制原始 JSON” 适合程序化分析、存档或二次处理。 +
+ 当前入口属于 `compat` 线程级快速诊断,只覆盖当前 thread 的运行信号。 + 正式交给外部 Claude Code / Codex 分析时,请优先使用工作台“交接制品 + → 外部分析交接”的 `analysis-brief.md / analysis-context.json` + 主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、存档或二次处理。
diff --git a/src/components/agent/chat/components/ChatSettings.tsx b/src/components/agent/chat/components/ChatSettings.tsx deleted file mode 100644 index 020798397..000000000 --- a/src/components/agent/chat/components/ChatSettings.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import React, { useState } from "react"; -import styled from "styled-components"; -import { - Settings2, - ChevronDown, - ChevronRight, - HelpCircle, - X, -} from "lucide-react"; -import { Switch } from "@/components/ui/switch"; -import { Slider } from "@/components/ui/slider"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; - -// --- Styled Components --- - -const SettingsContainer = styled.div` - width: 300px; - background-color: hsl(var(--background)); - border-left: 1px solid hsl(var(--border)); - display: flex; - flex-direction: column; - height: 100%; - flex-shrink: 0; -`; - -const Header = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid hsl(var(--border)); - - .title { - font-size: 14px; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; - } -`; - -const SectionContainer = styled.div` - /* padding: 16px; removed to move padding into content */ -`; - -const SectionTitle = styled.div` - font-size: 12px; - font-weight: 500; - color: hsl(var(--muted-foreground)); - padding: 12px 16px; - width: 100%; - display: flex; - align-items: center; - gap: 4px; - cursor: pointer; - transition: color 0.2s; - - &:hover { - color: hsl(var(--foreground)); - } -`; - -const SectionContent = styled(CollapsibleContent)` - padding: 0 16px 16px 16px; -`; - -const SettingRow = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; - - &:last-child { - margin-bottom: 0; - } - - .label { - font-size: 13px; - color: hsl(var(--foreground)); - display: flex; - align-items: center; - gap: 4px; - } - - .desc { - font-size: 11px; - color: hsl(var(--muted-foreground)); - margin-top: 2px; - } -`; - -const HelpIcon = () => ( - -); - -interface CollapsibleSectionProps { - title: string; - children: React.ReactNode; - defaultOpen?: boolean; -} - -const CollapsibleSection: React.FC = ({ - title, - children, - defaultOpen = true, -}) => { - const [isOpen, setIsOpen] = useState(defaultOpen); - - return ( - - - - - {isOpen ? : } - {title} - - - {children} - - - ); -}; - -interface ChatSettingsProps { - onClose: () => void; -} - -export const ChatSettings: React.FC = ({ onClose }) => { - // Local state for UI toggles (Mocking functional settings) - const [fontSize, setFontSize] = useState([14]); - - return ( - -
-
- - 设置 -
- -
- - - {/* Message Settings */} - - -
显示提示词
- -
- - -
使用衬线字体
- -
- - -
- 思考内容自动折叠 - -
- -
- - -
显示消息大纲
- -
- - -
消息样式
- -
- - -
多模型回答样式
- -
- - -
对话导航按钮
- -
- -
-
- 消息字体大小 - {fontSize[0]}px -
- -
- A - 默认 - A -
-
-
- - - - {/* Math Settings */} - - -
数学公式引擎
- -
- - -
- 启用 $...$ - -
- -
-
- - - - {/* Code Settings */} - - -
代码风格
- -
- - -
- 花式代码块 - -
- -
- - -
- 代码执行 - -
- -
- - -
代码编辑器
- -
- - -
代码显示行号
- -
- - -
代码块可折叠
- -
- - -
代码块可换行
- -
- - -
- 启用预览工具 - -
- -
-
- - - - {/* Input Settings */} - - -
显示预估 Token 数
- -
- - -
长文本粘贴为文件
- -
- - -
Markdown 渲染输入消息
- -
- - -
3 个空格快速翻译
- -
-
-
-
- ); -}; diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 8d387484b..c8e78d7ac 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EmptyState } from "./EmptyState"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import type { ServiceSkillHomeItem } from "../service-skills/types"; import { composeEntryPrompt } from "../utils/entryPromptComposer"; const { mockGetConfig } = vi.hoisted(() => ({ @@ -16,7 +17,9 @@ const mockCharacterMention = (props: { characters?: Character[]; skills?: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; onSelectSkill?: (skill: Skill) => void; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; value: string; onChange: (value: string) => void; }) => React.ReactNode @@ -52,7 +55,9 @@ vi.mock("./Inputbar/components/CharacterMention", () => ({ CharacterMention: (props: { characters?: Character[]; skills?: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; onSelectSkill?: (skill: Skill) => void; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; value: string; onChange: (value: string) => void; }) => { @@ -254,6 +259,60 @@ describe("EmptyState", () => { expect(setInput).toHaveBeenCalledWith("@技能A"); }); + it("应把服务型技能与选择回调透传给 CharacterMention", async () => { + const serviceSkills: ServiceSkillHomeItem[] = [ + { + id: "daily-trend-briefing", + title: "每日趋势摘要", + summary: "围绕指定平台与关键词输出趋势摘要。", + entryHint: "把平台和关键词给我,我先整理一份趋势报告。", + aliases: ["趋势报告"], + category: "社媒运营", + outputHint: "趋势摘要 + 调度建议", + source: "cloud_catalog", + runnerType: "scheduled", + defaultExecutorBinding: "automation_job", + executionLocation: "client_default", + slotSchema: [], + surfaceScopes: ["home", "mention", "workspace"], + promptTemplateKey: "trend_briefing", + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "本地计划任务", + runnerTone: "sky", + runnerDescription: + "当前先进入工作区生成首版任务方案,后续再接本地自动化。", + actionLabel: "先做方案", + automationStatus: null, + }, + ]; + const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + + renderEmptyState({ + input: "@", + serviceSkills, + onSelectServiceSkill, + }); + await act(async () => { + await Promise.resolve(); + }); + + const latestCall = + mockCharacterMention.mock.calls[ + mockCharacterMention.mock.calls.length - 1 + ][0]; + expect(latestCall.serviceSkills).toEqual(serviceSkills); + expect(typeof latestCall.onSelectServiceSkill).toBe("function"); + + act(() => { + latestCall.onSelectServiceSkill?.(serviceSkills[0]!); + }); + + expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkills[0]!); + }); + it("选择技能后发送应自动附加 skill 前缀,且发送后清除激活技能", async () => { const onSend = vi.fn< diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 93e387f04..9f5491765 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -57,6 +57,7 @@ import { getClipboardImageCandidates, readImageAttachment, } from "../utils/imageAttachments"; +import type { ServiceSkillHomeItem } from "../service-skills/types"; // Import Assets import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-placeholder.svg"; @@ -212,8 +213,12 @@ interface EmptyStateProps { characters?: Character[]; /** 技能列表(用于 @ 引用) */ skills?: Skill[]; + /** 服务型技能列表(用于 @ 引用) */ + serviceSkills?: ServiceSkillHomeItem[]; /** 技能列表加载状态 */ isSkillsLoading?: boolean; + /** 选择服务型技能回调 */ + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; /** 跳转到设置页安装技能 */ onNavigateToSettings?: () => void; /** 导入本地技能 */ @@ -402,7 +407,9 @@ export const EmptyState: React.FC = ({ selectedText = "", characters = [], skills = [], + serviceSkills = [], isSkillsLoading = false, + onSelectServiceSkill, onNavigateToSettings, onImportSkill, onRefreshSkills, @@ -1127,8 +1134,10 @@ export const EmptyState: React.FC = ({ onEntrySlotChange={handleEntrySlotChange} characters={characters} skills={skills} + serviceSkills={serviceSkills} activeSkill={activeSkill} setActiveSkill={setActiveSkill} + onSelectServiceSkill={onSelectServiceSkill} clearActiveSkill={clearActiveSkill} isSkillsLoading={isSkillsLoading} onNavigateToSettings={onNavigateToSettings} diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 3012dacce..a7c783b66 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -29,6 +29,20 @@ vi.mock("./Inputbar/components/TeamSelector", () => ({ ), })); +const mockSelectedTeam = { + id: "frontend-triage-team", + source: "builtin" as const, + label: "前端联调团队", + description: "分析、实现、验证三段式推进。", + roles: [ + { + id: "analysis", + label: "分析", + summary: "负责拆解问题。", + }, + ], +}; + const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; beforeEach(() => { @@ -136,13 +150,21 @@ function renderPanel( function renderStatefulPanel( props?: Partial>, + initialSubagentEnabled = false, ) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + const { + subagentEnabled: _ignoredSubagentEnabled, + onSubagentEnabledChange: _ignoredOnSubagentEnabledChange, + ...restProps + } = props || {}; const StatefulPanel = () => { - const [subagentEnabled, setSubagentEnabled] = React.useState(false); + const [subagentEnabled, setSubagentEnabled] = React.useState( + initialSubagentEnabled, + ); return ( ); }; @@ -324,26 +346,45 @@ describe("EmptyStateComposerPanel", () => { ).toBeTruthy(); }); - it("未开启 Team mode 时应显示显式开启按钮,并可直接启用", () => { - const onSubagentEnabledChange = vi.fn(); + it("未开启 Team mode 时应只保留图标开关,不再显示重复的文字入口", () => { const container = renderPanel({ isGeneralTheme: true, subagentEnabled: false, - onSubagentEnabledChange, }); + expect( + container.querySelector('[data-testid="empty-state-team-selector"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'), + ).toBeNull(); + + const toggleButton = container.querySelector( + 'button[title="开启多代理偏好"]', + ) as HTMLButtonElement | null; + + expect(toggleButton).toBeTruthy(); + }); + + it("即使已经保留 Team 方案,关闭 Team mode 后也不应显示 TeamSelector", () => { + const container = renderPanel({ + isGeneralTheme: true, + subagentEnabled: false, + selectedTeam: mockSelectedTeam, + }); + + expect( + container.querySelector('[data-testid="empty-state-team-selector"]'), + ).toBeNull(); + const enableButton = container.querySelector( '[data-testid="empty-state-team-mode-enable-button"]', ) as HTMLButtonElement | null; - expect(enableButton).toBeTruthy(); - expect(enableButton?.textContent).toContain("开启 Team"); - - act(() => { - enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); + expect(enableButton).toBeNull(); + expect( + container.querySelector('button[title="开启多代理偏好"]'), + ).toBeTruthy(); }); it("命中稳妥模式模型时应在首页输入区前置提示", () => { @@ -361,11 +402,11 @@ describe("EmptyStateComposerPanel", () => { expect(container.textContent).toContain("依次开始同类请求"); }); - it("点击开启 Team 后应自动透传 Team 配置面板打开令牌", async () => { + it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => { const container = renderStatefulPanel(); const enableButton = container.querySelector( - '[data-testid="empty-state-team-mode-enable-button"]', + 'button[title="开启多代理偏好"]', ) as HTMLButtonElement | null; expect(enableButton).toBeTruthy(); @@ -387,19 +428,61 @@ describe("EmptyStateComposerPanel", () => { expect(teamSelector?.getAttribute("data-auto-open-token")).toBe("1"); }); - it("复杂任务但未开启 Team 时,首页开启按钮应显示推荐态", () => { + it("关闭多代理偏好后应立即隐藏 TeamSelector 并回到显式开启入口", async () => { + const container = renderStatefulPanel( + { + selectedTeam: mockSelectedTeam, + }, + true, + ); + + expect( + container.querySelector('[data-testid="empty-state-team-selector"]'), + ).toBeTruthy(); + + const toggleButton = container.querySelector( + 'button[title="关闭多代理偏好"]', + ) as HTMLButtonElement | null; + + expect(toggleButton).toBeTruthy(); + + act(() => { + toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="empty-state-team-selector"]'), + ).toBeNull(); + expect( + container.querySelector('button[title="开启多代理偏好"]'), + ).toBeTruthy(); + }); + + it("复杂任务但未开启 Team 时,首页保留推荐提示但不再渲染重复入口", () => { const container = renderPanel({ isGeneralTheme: true, subagentEnabled: false, input: "请拆成多个子任务分别分析、实现、验证,并最终统一回归验收", }); - const enableButton = container.querySelector( - '[data-testid="empty-state-team-mode-enable-button"]', - ) as HTMLButtonElement | null; + const enableButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("启用 Team")) as + | HTMLButtonElement + | undefined; + expect( + container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'), + ).toBeNull(); + expect( + container.querySelector('button[title="开启多代理偏好"]'), + ).toBeTruthy(); expect(enableButton).toBeTruthy(); - expect(enableButton?.textContent).toContain("开启 Team"); - expect(enableButton?.textContent).toContain("推荐"); + expect(enableButton?.textContent).toContain("启用 Team"); + expect(container.textContent).toContain("当前任务更适合 Team 协作"); }); }); diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 33d63524c..e17439817 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -35,8 +35,8 @@ import { CharacterMention } from "./Inputbar/components/CharacterMention"; import { SkillBadge } from "./Inputbar/components/SkillBadge"; import { SkillSelector } from "./Inputbar/components/SkillSelector"; import { TeamSelector } from "./Inputbar/components/TeamSelector"; -import { TeamModeEntryButton } from "./Inputbar/components/TeamModeEntryButton"; import { StableProcessingNotice } from "./StableProcessingNotice"; +import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { WorkspaceSettings } from "@/types/workspace"; import { CREATION_MODE_CONFIG } from "./constants"; import type { @@ -378,8 +378,7 @@ const GridItem = styled.div<{ $active?: boolean }>` padding: 10px; border-radius: 8px; border: 1px solid - ${(props) => - props.$active ? "rgba(148, 163, 184, 0.82)" : "transparent"}; + ${(props) => (props.$active ? "rgba(148, 163, 184, 0.82)" : "transparent")}; background-color: ${(props) => props.$active ? "rgba(241, 245, 249, 0.96)" : "rgba(248, 250, 252, 0.92)"}; cursor: pointer; @@ -416,11 +415,8 @@ const EntryTaskTab = styled.button<{ $active?: boolean }>` font-size: 12px; border: 1px solid ${(props) => - props.$active - ? "rgba(203, 213, 225, 0.92)" - : "rgba(226, 232, 240, 0.9)"}; - color: ${(props) => - props.$active ? "#0f172a" : "#64748b"}; + props.$active ? "rgba(203, 213, 225, 0.92)" : "rgba(226, 232, 240, 0.9)"}; + color: ${(props) => (props.$active ? "#0f172a" : "#64748b")}; background: ${(props) => props.$active ? "rgba(255, 255, 255, 0.96)" : "rgba(255, 255, 255, 0.78)"}; box-shadow: ${(props) => @@ -501,8 +497,10 @@ interface EmptyStateComposerPanelProps { onEntrySlotChange: (key: string, value: string) => void; characters: Character[]; skills: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; activeSkill?: Skill | null; setActiveSkill: (skill: Skill) => void; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; clearActiveSkill: () => void; isSkillsLoading: boolean; onNavigateToSettings?: () => void; @@ -570,8 +568,10 @@ export function EmptyStateComposerPanel({ onEntrySlotChange, characters, skills, + serviceSkills = [], activeSkill, setActiveSkill, + onSelectServiceSkill, clearActiveSkill, isSkillsLoading, onNavigateToSettings, @@ -644,6 +644,7 @@ export function EmptyStateComposerPanel({ Boolean(onSubagentEnabledChange) && teamSuggestion.shouldSuggest && dismissedSuggestionKey !== suggestionKey; + const shouldShowTeamSelector = isGeneralTheme && subagentEnabled; const handleEnableTeamSuggestion = () => { onSubagentEnabledChange?.(true); @@ -655,13 +656,6 @@ export function EmptyStateComposerPanel({ setDismissedSuggestionKey(suggestionKey); }; - const handleEnableTeamMode = () => { - if (!subagentEnabled && !selectedTeam) { - setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1); - } - onSubagentEnabledChange?.(true); - }; - const handleToggleSubagentMode = () => { if (!subagentEnabled && !selectedTeam) { setTeamSelectorAutoOpenToken((current) => (current ?? 0) + 1); @@ -740,10 +734,12 @@ export function EmptyStateComposerPanel({ @@ -814,7 +810,7 @@ export function EmptyStateComposerPanel({ onRefreshSkills={onRefreshSkills} /> ) : null} - {subagentEnabled ? ( + {shouldShowTeamSelector ? ( onSelectTeam?.(team)} /> - ) : isGeneralTheme && onSubagentEnabledChange ? ( - ) : null} @@ -1177,7 +1166,9 @@ export function EmptyStateComposerPanel({ 开始生成 diff --git a/src/components/agent/chat/components/EmptyStateQuickActions.tsx b/src/components/agent/chat/components/EmptyStateQuickActions.tsx index f89063949..33589e6f5 100644 --- a/src/components/agent/chat/components/EmptyStateQuickActions.tsx +++ b/src/components/agent/chat/components/EmptyStateQuickActions.tsx @@ -112,6 +112,7 @@ export interface EmptyStateQuickActionItem { secondaryStatusLabel?: string; secondaryStatusTone?: "slate" | "sky" | "emerald" | "amber"; secondaryStatusDescription?: string; + secondaryStatusActionable?: boolean; testId?: string; solutionId?: string; } @@ -257,7 +258,8 @@ export function EmptyStateQuickActions({ ) : null} {item.secondaryStatusLabel ? (
- {onSecondaryStatusAction ? ( + {onSecondaryStatusAction && + item.secondaryStatusActionable !== false ? ( + {handoffBundle ? ( + + ) : null} +
+
+
+ + {handoffExportError ? ( +
+ {handoffExportError} +
+ ) : null} + + {handoffBundle ? ( + <> +
+ + + + +
+ +
+
+ + 导出目录 +
+
+
+ 相对路径: + + {handoffBundle.bundle_relative_root} + +
+
+ 绝对路径: + +
+
+
+ +
+ {handoffBundle.artifacts.map((artifact) => { + const sizeLabel = formatSize(artifact.bytes); + return ( +
+
+
+
+ + + {artifact.title} + + + {formatHandoffArtifactKindLabel( + artifact.kind, + )} + + {sizeLabel ? ( + + {sizeLabel} + + ) : null} +
+
+
+ 相对路径: + + {artifact.relative_path} + +
+
+ 绝对路径: + +
+
+
+
+ + +
+
+
+ ); + })} +
+ + ) : ( +
+ 尚未导出交接制品。建议在需要跨会话接手、准备审查或切换执行人前先导出一次。 +
+ )} + +
+
+
+
+ + 问题证据包 +
+
+ 把当前 + runtime、timeline、最近产物和已知缺口导出为最小证据包,为后续 + replay、eval 和故障复盘提供输入。 +
+
+
+ + {evidencePack ? ( + + ) : null} +
+
+ + {evidenceExportError ? ( +
+ {evidenceExportError} +
+ ) : null} + + {evidencePack ? ( +
+
+ + + + +
+ +
+
+ + 证据目录 +
+
+
+ 相对路径: + + {evidencePack.pack_relative_root} + +
+
+ 绝对路径: + +
+
+
+ + {evidencePack.known_gaps.length > 0 ? ( +
+
+ 当前已知缺口 +
+
+ {evidencePack.known_gaps.map((gap, index) => ( +
{gap}
+ ))} +
+
+ ) : null} + +
+ {evidencePack.artifacts.map((artifact) => { + const sizeLabel = formatSize(artifact.bytes); + return ( +
+
+
+
+ + + {artifact.title} + + + {formatEvidenceArtifactKindLabel( + artifact.kind, + )} + + {sizeLabel ? ( + + {sizeLabel} + + ) : null} +
+
+
+ 相对路径: + + {artifact.relative_path} + +
+
+ 绝对路径: + +
+
+
+
+ + +
+
+
+ ); + })} +
+
+ ) : ( +
+ 尚未导出问题证据包。建议在出现阻塞、需要复盘失败链路,或准备把真实案例沉淀成 + replay / eval 样本前导出一次。 +
+ )} +
+ +
+
+
+
+ + Replay 样本 +
+
+ 基于当前 session 复用 handoff bundle 与 evidence + pack,导出 `input / expected / grader / + evidence-links` + 四件套,把真实失败转成可回放、可评分、可回归的最小样本。 +
+
+
+ + {replayCase ? ( + + ) : null} +
+
+ + {replayExportError ? ( +
+ {replayExportError} +
+ ) : null} + + {replayCase ? ( +
+
+ + + + +
+ +
+
+ + Replay 目录 +
+
+
+ 相对路径: + + {replayCase.replay_relative_root} + +
+
+ 绝对路径: + +
+
+
+ +
+
+ 关联证据主链 +
+
+
+ handoff: + + {replayCase.handoff_bundle_relative_root} + +
+
+ evidence: + + {replayCase.evidence_pack_relative_root} + +
+
+
+ +
+ {replayCase.artifacts.map((artifact) => { + const sizeLabel = formatSize(artifact.bytes); + return ( +
+
+
+
+ + + {artifact.title} + + + {formatReplayArtifactKindLabel( + artifact.kind, + )} + + {sizeLabel ? ( + + {sizeLabel} + + ) : null} +
+
+
+ 相对路径: + + {artifact.relative_path} + +
+
+ 绝对路径: + +
+
+
+
+ + +
+
+
+ ); + })} +
+
+ ) : ( +
+ 尚未导出 Replay 样本。建议在 handoff 和 evidence + 都稳定后,再把真实失败沉淀成 `input / expected / + grader / evidence-links` 四件套。 +
+ )} +
+ +
+
+
+
+ + 外部分析交接 +
+
+ 把 handoff / evidence / replay 主链重新包装成外部 + Claude Code / Codex + 可直接消费的分析交接;复制后可直接粘贴给 AI, + 不需要你再手写补充 prompt。 +
+
+
+ + + {analysisHandoff ? ( + + ) : null} +
+
+ + {analysisExportError ? ( +
+ {analysisExportError} +
+ ) : null} + + {analysisHandoff ? ( +
+
+ + + + +
+ +
+
+ + 复制说明 +
+
+ 复制内容来自后端导出的 + `copy_prompt`,已经包含分析入口文件、关联目录和输出要求; + 外部 AI + 可直接开始诊断,证据足够明确时也可直接实施最小修复。 +
+
+ +
+
+ + 分析目录 +
+
+
+ 相对路径: + + {analysisHandoff.analysis_relative_root} + +
+
+ 绝对路径: + +
+
+
+ +
+
+ 关联主链目录 +
+
+
+ handoff: + + {analysisHandoff.handoff_bundle_relative_root} + +
+
+ evidence: + + {analysisHandoff.evidence_pack_relative_root} + +
+
+ replay: + + {analysisHandoff.replay_case_relative_root} + +
+
+ 路径占位根: + + {analysisHandoff.sanitized_workspace_root} + +
+
+
+ +
+ {analysisHandoff.artifacts.map((artifact) => { + const sizeLabel = formatSize(artifact.bytes); + return ( +
+
+
+
+ + + {artifact.title} + + + {formatAnalysisArtifactKindLabel( + artifact.kind, + )} + + {sizeLabel ? ( + + {sizeLabel} + + ) : null} +
+
+
+ 相对路径: + + {artifact.relative_path} + +
+
+ 绝对路径: + +
+
+
+
+ + +
+
+
+ ); + })} +
+
+ ) : ( +
+ 尚未导出外部分析交接。点击“一键复制给 + AI”时会自动先导出再复制, 用于把当前 Lime + 证据链直接交给外部 Claude Code / Codex + 做诊断与最小修复。 +
+ )} +
+ +
+
+
+
+ + 人工审核记录 +
+
+ 把外部 Claude Code / Codex 的分析结论回挂为 + `review-decision.md/json` + 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 + Lime 自动闭环。 +
+
+
+ + {reviewDecisionTemplate ? ( + + ) : null} +
+
+ + {reviewDecisionExportError ? ( +
+ {reviewDecisionExportError} +
+ ) : null} + + {reviewDecisionTemplate ? ( +
+
+ + + + +
+ +
+
+ + 职责边界 +
+
+ 运行时事实继续以 aster-rust 的 session / thread / + turn 为准,外部分析形状对齐 Codex + 的交接习惯,但最终是否接受修复、补哪些回归,必须由开发者写入 + review decision。 +
+
+ +
+
+ + 审核目录 +
+
+
+ 相对路径: + + {reviewDecisionTemplate.review_relative_root} + +
+
+ 绝对路径: + +
+
+ 关联 analysis: + + { + reviewDecisionTemplate.analysis_relative_root + } + +
+
+
+ +
+
+ 人工审核清单 +
+
+ {reviewDecisionTemplate.review_checklist.map( + (item) => ( +
+ {item} +
+ ), + )} +
+
+ +
+
+ 关联分析文件 +
+ {reviewDecisionTemplate.analysis_artifacts.map( + (artifact) => { + const sizeLabel = formatSize(artifact.bytes); + return ( +
+
+
+
+ + + {artifact.title} + + + {formatAnalysisArtifactKindLabel( + artifact.kind, + )} + + {sizeLabel ? ( + + {sizeLabel} + + ) : null} +
+
+
+ 相对路径: + + {artifact.relative_path} + +
+
+ 绝对路径: + +
+
+
+
+ + +
+
+
+ ); + }, + )} +
+ +
+
+ 审核记录模板文件 +
+ {reviewDecisionTemplate.artifacts.map( + (artifact) => { + const sizeLabel = formatSize(artifact.bytes); + return ( +
+
+
+
+ + + {artifact.title} + + + {formatReviewDecisionArtifactKindLabel( + artifact.kind, + )} + + {sizeLabel ? ( + + {sizeLabel} + + ) : null} +
+
+
+ 相对路径: + + {artifact.relative_path} + +
+
+ 绝对路径: + +
+
+
+
+ + +
+
+
+ ); + }, + )} +
+
+ ) : ( +
+ 尚未导出人工审核记录。建议在外部 AI 完成诊断后立刻导出 + `review-decision.md/json`,把接受、延后、拒绝和回归要求回挂到工作区, + 而不是散落在聊天窗口或临时笔记里。 +
+ )} +
+ + + ) : null} + {threadReliabilityView.shouldRender ? (
+ ] as Array<[AgentToolExecutionPolicySource, string]> ).map(([source, label]) => ( {[ { value: "all" as const, label: "全部" }, - { value: "runtime" as const, label: "运行时覆盖" }, + { + value: "runtime" as const, + label: "运行时覆盖", + }, { value: "persisted" as const, label: "持久化覆盖", }, { value: "default" as const, label: "纯默认" }, ].map((option) => { - const active = option.value === toolInventoryFilter; + const active = + option.value === toolInventoryFilter; const count = countCatalogToolsByInventoryFilter( toolInventoryCatalogTools, option.value, @@ -2763,7 +4393,9 @@ export function HarnessStatusPanel({ )} - {formatToolSourceKindLabel(entry.source)} + {formatToolSourceKindLabel( + entry.source, + )} {formatToolPermissionPlaneLabel( @@ -2942,7 +4574,8 @@ export function HarnessStatusPanel({ - {collectRegistryExecutionSources(entry).length > 0 ? ( + {collectRegistryExecutionSources(entry).length > + 0 ? (
{entry.catalog_execution_warning_policy && entry.catalog_execution_warning_policy_source ? ( @@ -3032,9 +4665,7 @@ export function HarnessStatusPanel({
常驻工具:{entry.always_expose_tools.length}
-
- 已加载:{entry.loaded_tools.length} -
+
已加载:{entry.loaded_tools.length}
可搜索:{entry.searchable_tools.length}
@@ -3058,7 +4689,9 @@ export function HarnessStatusPanel({ {entry.name} - {entry.status} + + {entry.status} + {formatExtensionSourceKindLabel( entry.source_kind, @@ -3411,8 +5044,9 @@ export function HarnessStatusPanel({ {formatTime(event.timestamp)} · - {resolveFriendlyToolLabel(event.sourceToolName) || - event.sourceToolName} + {resolveFriendlyToolLabel( + event.sourceToolName, + ) || event.sourceToolName}
{event.preview ? ( @@ -3503,8 +5137,8 @@ export function HarnessStatusPanel({ : realTeamSummary.total > 0 ? `${realTeamSummary.total} 个协作` : harnessState.delegatedTasks.length > 0 - ? `${harnessState.delegatedTasks.length} 条` - : undefined + ? `${harnessState.delegatedTasks.length} 条` + : undefined } registerRef={registerSectionRef} > @@ -3633,8 +5267,8 @@ export function HarnessStatusPanel({ {session.provider_parallel_budget === 1 && session.provider_concurrency_group ? ( - {resolveTeamWorkspaceStableProcessingLabel()}: - 当前服务按顺序处理 + {resolveTeamWorkspaceStableProcessingLabel()} + : 当前服务按顺序处理 ) : null} {session.origin_tool ? ( @@ -3645,7 +5279,10 @@ export function HarnessStatusPanel({ ) || session.origin_tool} ) : null} - 更新:{formatUnixTimestamp(session.updated_at)} + + 更新: + {formatUnixTimestamp(session.updated_at)} + {session.task_summary ? ( ({ @@ -35,29 +37,34 @@ vi.mock("@/components/ui/popover", () => { sideOffset?: number; onOpenAutoFocus?: (event: Event) => void; } - >(({ - children, - className, - style, - side, - align, - avoidCollisions, - sideOffset: _sideOffset, - onOpenAutoFocus: _onOpenAutoFocus, - ...props - }, ref) => ( -
- {children} -
- )); + >( + ( + { + children, + className, + style, + side, + align, + avoidCollisions, + sideOffset: _sideOffset, + onOpenAutoFocus: _onOpenAutoFocus, + ...props + }, + ref, + ) => ( +
+ {children} +
+ ), + ); return { Popover, PopoverTrigger, PopoverContent }; }); @@ -157,19 +164,23 @@ afterEach(() => { interface HarnessProps { characters?: Character[]; skills?: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; syncValue?: boolean; onNavigateToSettings?: () => void; onChangeSpy?: (value: string) => void; onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; } const Harness: React.FC = ({ characters = [], skills = [], + serviceSkills = [], syncValue = true, onNavigateToSettings, onChangeSpy, onSelectBuiltinCommand, + onSelectServiceSkill, }) => { const [value, setValue] = useState(""); const inputRef = useRef(null); @@ -189,6 +200,7 @@ const Harness: React.FC = ({ { @@ -198,6 +210,7 @@ const Harness: React.FC = ({ } }} onSelectBuiltinCommand={onSelectBuiltinCommand} + onSelectServiceSkill={onSelectServiceSkill} onNavigateToSettings={onNavigateToSettings} /> @@ -256,10 +269,7 @@ async function typeAtAndWait(textarea: HTMLTextAreaElement) { }); } -async function typeSlashAndWait( - textarea: HTMLTextAreaElement, - value = "/", -) { +async function typeSlashAndWait(textarea: HTMLTextAreaElement, value = "/") { await act(async () => { await import("./CharacterMentionPanel"); }); @@ -302,6 +312,37 @@ function createCharacter(name: string): Character { }; } +function createServiceSkill( + overrides: Partial = {}, +): ServiceSkillHomeItem { + return { + id: "daily-trend-briefing", + title: "每日趋势摘要", + summary: "围绕指定平台与关键词输出趋势摘要。", + entryHint: "把平台和关键词给我,我先整理一份趋势报告。", + aliases: ["趋势报告", "热点摘要"], + category: "社媒运营", + outputHint: "趋势摘要 + 调度建议", + source: "cloud_catalog", + runnerType: "scheduled", + defaultExecutorBinding: "automation_job", + executionLocation: "client_default", + slotSchema: [], + surfaceScopes: ["home", "mention", "workspace"], + promptTemplateKey: "trend_briefing", + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "本地计划任务", + runnerTone: "sky", + runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。", + actionLabel: "先做方案", + automationStatus: null, + ...overrides, + }; +} + describe("CharacterMention", () => { it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => { const container = renderHarness({ @@ -329,7 +370,8 @@ describe("CharacterMention", () => { }); it("提供 onSelectBuiltinCommand 时,选择配图命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = vi.fn<(command: BuiltinInputCommand) => void>(); + const onSelectBuiltinCommand = + vi.fn<(command: BuiltinInputCommand) => void>(); const container = renderHarness({ onSelectBuiltinCommand, }); @@ -337,9 +379,9 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - const builtinButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("@配图"), - ); + const builtinButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("@配图")); expect(builtinButton).toBeTruthy(); act(() => { @@ -354,6 +396,84 @@ describe("CharacterMention", () => { ); }); + it("服务技能应出现在 @ 面板里", async () => { + const container = renderHarness({ + serviceSkills: [ + createServiceSkill(), + createServiceSkill({ + id: "carousel-post-replication", + title: "复制轮播帖", + entryHint: "拆结构并输出一版可继续改的轮播帖。", + aliases: ["轮播帖", "小红书轮播"], + runnerType: "instant", + defaultExecutorBinding: "agent_turn", + runnerLabel: "本地即时执行", + runnerTone: "emerald", + runnerDescription: "客户端起步版可直接进入工作区执行。", + actionLabel: "填写参数", + promptTemplateKey: "replication", + }), + ], + }); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + expect(document.body.textContent).toContain("服务技能"); + expect(document.body.textContent).toContain("每日趋势摘要"); + expect(document.body.textContent).toContain("复制轮播帖"); + }); + + it("服务技能过滤应支持命中别名", () => { + const filtered = filterMentionableServiceSkills( + [ + createServiceSkill(), + createServiceSkill({ + id: "carousel-post-replication", + title: "复制轮播帖", + aliases: ["轮播帖", "小红书轮播"], + runnerType: "instant", + defaultExecutorBinding: "agent_turn", + runnerLabel: "本地即时执行", + runnerTone: "emerald", + runnerDescription: "客户端起步版可直接进入工作区执行。", + actionLabel: "填写参数", + promptTemplateKey: "replication", + }), + ], + "轮播", + ); + + expect(filtered).toHaveLength(1); + expect(filtered[0]?.id).toBe("carousel-post-replication"); + }); + + it("提供 onSelectServiceSkill 时,选择服务技能应交给父组件接管", async () => { + const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + const onChangeSpy = vi.fn<(value: string) => void>(); + const serviceSkill = createServiceSkill(); + const container = renderHarness({ + serviceSkills: [serviceSkill], + onSelectServiceSkill, + onChangeSpy, + }); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + const serviceSkillButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("每日趋势摘要")); + expect(serviceSkillButton).toBeTruthy(); + + act(() => { + serviceSkillButton?.click(); + }); + + expect(onChangeSpy).toHaveBeenCalledWith(""); + expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkill); + }); + it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const container = renderHarness({ @@ -364,9 +484,9 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - const skillButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("技能A"), - ); + const skillButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("技能A")); expect(skillButton).toBeTruthy(); act(() => { @@ -418,9 +538,9 @@ describe("CharacterMention", () => { await typeSlashAndWait(textarea, "/ski"); - const skillButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("技能A"), - ); + const skillButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("技能A")); expect(skillButton).toBeTruthy(); act(() => { diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx index 0f397b4c9..b90a46c7f 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx @@ -16,8 +16,13 @@ import React, { import { createPortal } from "react-dom"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter"; +import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import { toast } from "sonner"; -import { filterCodexSlashCommands, type CodexSlashCommandDefinition } from "../../../commands"; +import { + filterCodexSlashCommands, + type CodexSlashCommandDefinition, +} from "../../../commands"; import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; import { filterBuiltinCommands, @@ -36,6 +41,8 @@ interface CharacterMentionProps { characters: Character[]; /** 技能列表 */ skills?: Skill[]; + /** 服务型技能列表 */ + serviceSkills?: ServiceSkillHomeItem[]; /** 输入框 ref */ inputRef: React.RefObject; /** 当前输入值 */ @@ -46,6 +53,8 @@ interface CharacterMentionProps { onSelectCharacter?: (character: Character) => void; /** 选择已安装技能回调 */ onSelectSkill?: (skill: Skill) => void; + /** 选择服务型技能回调 */ + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; /** 选择内建命令回调 */ onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void; /** 跳转到设置页安装技能 */ @@ -116,11 +125,13 @@ function resolveActiveTrigger( export function CharacterMention({ characters, skills = [], + serviceSkills = [], inputRef, value, onChange, onSelectCharacter, onSelectSkill, + onSelectServiceSkill, onSelectBuiltinCommand, onNavigateToSettings, }: CharacterMentionProps) { @@ -147,6 +158,10 @@ export function CharacterMention({ () => filterBuiltinCommands(mentionQuery), [mentionQuery], ); + const filteredServiceSkills = useMemo( + () => filterMentionableServiceSkills(serviceSkills, mentionQuery), + [mentionQuery, serviceSkills], + ); const filteredSlashCommands = useMemo( () => filterCodexSlashCommands(mentionQuery), [mentionQuery], @@ -276,7 +291,10 @@ export function CharacterMention({ if (!(target instanceof Node)) { return; } - if (panelRef.current?.contains(target) || inputRef.current?.contains(target)) { + if ( + panelRef.current?.contains(target) || + inputRef.current?.contains(target) + ) { return; } setShowMentions(false); @@ -311,7 +329,8 @@ export function CharacterMention({ setTimeout(() => { textarea.focus(); - const newCursorPos = activeTrigger.triggerIndex + character.name.length + 2; + const newCursorPos = + activeTrigger.triggerIndex + character.name.length + 2; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); }; @@ -428,6 +447,47 @@ export function CharacterMention({ }, 0); }; + const handleSelectServiceSkill = (skill: ServiceSkillHomeItem) => { + const textarea = inputRef.current; + if (!textarea) return; + + const currentValue = textarea.value || value; + const cursorPos = textarea.selectionStart ?? currentValue.length; + const textAfterCursor = currentValue.slice(cursorPos); + const activeTrigger = resolveActiveTrigger(currentValue, cursorPos); + if (!activeTrigger || activeTrigger.mode !== "mention") { + return; + } + + if (onSelectServiceSkill) { + const newValue = + currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor; + onChange(newValue.trimEnd() === "" ? "" : newValue); + setShowMentions(false); + onSelectServiceSkill(skill); + + setTimeout(() => { + textarea.focus(); + const newCursorPos = Math.max(0, activeTrigger.triggerIndex); + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + return; + } + + const newValue = + currentValue.slice(0, activeTrigger.triggerIndex) + + `@${skill.title} ` + + textAfterCursor; + onChange(newValue); + setShowMentions(false); + + setTimeout(() => { + textarea.focus(); + const newCursorPos = activeTrigger.triggerIndex + skill.title.length + 2; + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + }; + const handleSelectSlashCommand = (command: CodexSlashCommandDefinition) => { const textarea = inputRef.current; if (!textarea) return; @@ -541,12 +601,14 @@ export function CharacterMention({ mentionQuery={mentionQuery} builtinCommands={filteredBuiltinCommands} slashCommands={filteredSlashCommands} + mentionServiceSkills={filteredServiceSkills} filteredCharacters={filteredCharacters} installedSkills={installedSkills} availableSkills={availableSkills} commandRef={commandRef} onQueryChange={setMentionQuery} onSelectBuiltinCommand={handleSelectBuiltinCommand} + onSelectServiceSkill={handleSelectServiceSkill} onSelectSlashCommand={handleSelectSlashCommand} onSelectCharacter={handleSelectCharacter} onSelectInstalledSkill={handleSelectInstalledSkill} diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx index a0b1f8ebf..c8da02f68 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx @@ -1,5 +1,11 @@ import React from "react"; -import { Command as CommandIcon, ImagePlus, User, Zap } from "lucide-react"; +import { + Command as CommandIcon, + ImagePlus, + Sparkles, + User, + Zap, +} from "lucide-react"; import { Command, CommandGroup, @@ -9,6 +15,8 @@ import { } from "@/components/ui/command"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import { resolveServiceSkillEntryDescription } from "@/components/agent/chat/service-skills/entryAdapter"; +import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import type { CodexSlashCommandDefinition } from "../../../commands"; import type { BuiltinInputCommand } from "./builtinCommands"; @@ -17,12 +25,14 @@ interface CharacterMentionPanelProps { mentionQuery: string; builtinCommands: BuiltinInputCommand[]; slashCommands: CodexSlashCommandDefinition[]; + mentionServiceSkills: ServiceSkillHomeItem[]; filteredCharacters: Character[]; installedSkills: Skill[]; availableSkills: Skill[]; commandRef: React.RefObject; onQueryChange: (query: string) => void; onSelectBuiltinCommand: (command: BuiltinInputCommand) => void; + onSelectServiceSkill: (skill: ServiceSkillHomeItem) => void; onSelectSlashCommand: (command: CodexSlashCommandDefinition) => void; onSelectCharacter: (character: Character) => void; onSelectInstalledSkill: (skill: Skill) => void; @@ -35,12 +45,14 @@ export const CharacterMentionPanel: React.FC = ({ mentionQuery, builtinCommands, slashCommands, + mentionServiceSkills, filteredCharacters, installedSkills, availableSkills, commandRef, onQueryChange, onSelectBuiltinCommand, + onSelectServiceSkill, onSelectSlashCommand, onSelectCharacter, onSelectInstalledSkill, @@ -48,11 +60,13 @@ export const CharacterMentionPanel: React.FC = ({ onNavigateToSettings, }) => { const visibleBuiltinCommands = mode === "mention" ? builtinCommands : []; + const visibleServiceSkills = mode === "mention" ? mentionServiceSkills : []; const visibleCharacters = mode === "mention" ? filteredCharacters : []; const visibleSlashCommands = mode === "slash" ? slashCommands : []; const hasFilteredResults = visibleSlashCommands.length > 0 || visibleBuiltinCommands.length > 0 || + visibleServiceSkills.length > 0 || visibleCharacters.length > 0 || installedSkills.length > 0 || availableSkills.length > 0; @@ -60,14 +74,18 @@ export const CharacterMentionPanel: React.FC = ({ return ( {!hasFilteredResults ? (
-
{mode === "slash" ? "暂无可用命令或技能" : "暂无可用角色或技能"}
+
+ {mode === "slash" ? "暂无可用命令或技能" : "暂无可用角色或技能"} +
{onNavigateToSettings ? ( - ); -}; - -export default TeamModeEntryButton; diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index b23b3c3a0..5c026e48e 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -5,10 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Inputbar } from "./index"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; const mockCharacterMention = vi.fn< - (props: { characters?: Character[]; skills?: Skill[] }) => React.ReactNode + (props: { + characters?: Character[]; + skills?: Skill[]; + serviceSkills?: ServiceSkillHomeItem[]; + onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; + }) => React.ReactNode >(); const mockInputbarCore = vi.fn( (props: { @@ -33,6 +39,16 @@ const mockInputbarCore = vi.fn( {props.activeTools?.web_search ? "on" : "off"} + + + {props.activeTools?.subagent_mode ? "on" : "off"} +
); } diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx index 4a44a2301..6bae716be 100644 --- a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.test.tsx @@ -272,6 +272,41 @@ describe("ServiceSkillLaunchDialog", () => { ); }); + it("站点型服务技能应展示浏览器工作台入口文案", async () => { + renderDialog({ + skill: { + ...MOCK_SKILL, + id: "github-repo-radar", + title: "GitHub 仓库线索检索", + defaultExecutorBinding: "browser_assist", + summary: + "复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。", + runnerLabel: "浏览器站点执行", + runnerDescription: + "直接进入浏览器工作台,复用真实登录态执行站点脚本并沉淀结果。", + actionLabel: "启动采集", + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + saveMode: "current_content", + slotArgMap: { + reference_video: "query", + }, + }, + }, + }); + + await flushEffects(); + + expect(document.body.textContent).toContain( + "该任务会直接进入浏览器工作台,预填站点脚本参数,并优先复用当前浏览器登录态执行。", + ); + const launchButton = document.body.querySelector( + '[data-testid="service-skill-launch"]', + ) as HTMLButtonElement | null; + expect(launchButton?.textContent).toBe("进入浏览器工作台"); + }); + it("云端托管技能应显示云端运行文案且不暴露本地自动化入口", async () => { const onLaunch = vi.fn(); const onCreateAutomation = vi.fn(); @@ -296,7 +331,7 @@ describe("ServiceSkillLaunchDialog", () => { expect(document.body.textContent).toContain("提交云端运行"); expect(document.body.textContent).toContain( - "不会进入本地工作区,也不会创建本地自动化草稿", + "成功后会把结果回流到本地工作区,但不会创建本地自动化草稿", ); expect( document.body.querySelector( diff --git a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx index bfd28c1c9..ecbd76d1a 100644 --- a/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx +++ b/src/components/agent/chat/service-skills/ServiceSkillLaunchDialog.tsx @@ -17,6 +17,7 @@ import { formatServiceSkillPromptPreview, validateServiceSkillSlotValues, } from "./promptComposer"; +import { isServiceSkillSiteCapabilityBound } from "./siteCapabilityBinding"; import { supportsServiceSkillLocalAutomation } from "./automationDraft"; import type { ServiceSkillHomeItem, @@ -132,11 +133,16 @@ export function ServiceSkillLaunchDialog({ const canCreateAutomation = supportsAutomation && typeof onCreateAutomation === "function"; const isCloudRequired = skill?.executionLocation === "cloud_required"; + const isSiteCapabilityBound = !!( + skill && isServiceSkillSiteCapabilityBound(skill) + ); const primaryActionLabel = canCreateAutomation ? "创建任务并进入工作区" : isCloudRequired ? "提交云端运行" - : "进入工作区"; + : isSiteCapabilityBound + ? "进入浏览器工作台" + : "进入工作区"; if (!skill) { return null; @@ -165,7 +171,11 @@ export function ServiceSkillLaunchDialog({ ) : skill.executionLocation === "cloud_required" ? (
该任务会直接提交到 OEM - 云端执行,不会进入本地工作区,也不会创建本地自动化草稿。 + 云端执行;成功后会把结果回流到本地工作区,但不会创建本地自动化草稿。 +
+ ) : isSiteCapabilityBound ? ( +
+ 该任务会直接进入浏览器工作台,预填站点脚本参数,并优先复用当前浏览器登录态执行。
) : null} diff --git a/src/components/agent/chat/service-skills/automationDraft.test.ts b/src/components/agent/chat/service-skills/automationDraft.test.ts index e0894f763..b86f9bda0 100644 --- a/src/components/agent/chat/service-skills/automationDraft.test.ts +++ b/src/components/agent/chat/service-skills/automationDraft.test.ts @@ -84,6 +84,34 @@ describe("service skill automation draft", () => { artifact_mode: "draft", artifact_kind: "analysis", }), + service_skill: expect.objectContaining({ + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + slot_values: [ + { + key: "platform", + label: "监测平台", + value: "X / Twitter", + }, + { + key: "industry_keywords", + label: "行业关键词", + value: "AI Agent,创作者工具", + }, + { + key: "schedule_time", + label: "推送时间", + value: "每天 09:00", + }, + ], + slot_summary: [ + "监测平台: X / Twitter", + "行业关键词: AI Agent,创作者工具", + "推送时间: 每天 09:00", + ], + user_input: "重点关注新增热点与异常波动。", + }), harness: expect.objectContaining({ theme: "social-media", session_mode: "theme_workbench", @@ -107,6 +135,14 @@ describe("service skill automation draft", () => { artifact_mode: "draft", artifact_kind: "analysis", }), + service_skill: expect.objectContaining({ + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + slot_values: [], + slot_summary: [], + user_input: null, + }), harness: expect.objectContaining({ theme: "social-media", session_mode: "theme_workbench", diff --git a/src/components/agent/chat/service-skills/automationDraft.ts b/src/components/agent/chat/service-skills/automationDraft.ts index dd00b8a51..734835c76 100644 --- a/src/components/agent/chat/service-skills/automationDraft.ts +++ b/src/components/agent/chat/service-skills/automationDraft.ts @@ -29,6 +29,8 @@ interface BuildServiceSkillAutomationInitialValuesInput { interface BuildServiceSkillAutomationAgentTurnPayloadContextInput { skill: ServiceSkillItem; + slotValues?: ServiceSkillSlotValues; + userInput?: string; contentId?: string | null; } @@ -43,6 +45,57 @@ function resolveSlotValue( return slot.defaultValue?.trim() || ""; } +function normalizeOptionalText(value?: string | null): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim(); + return normalized ? normalized : undefined; +} + +function resolveSlotDisplayValue( + slot: ServiceSkillSlotDefinition, + slotValues: ServiceSkillSlotValues, +): string { + const resolved = resolveSlotValue(slot, slotValues); + if (!resolved) { + return ""; + } + + const matchedOption = slot.options?.find((option) => option.value === resolved); + return matchedOption?.label?.trim() || resolved; +} + +function summarizeMetadataValue(value: string, maxLength = 120): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength).trim()}...`; +} + +function buildServiceSkillAutomationSlotSummary( + skill: ServiceSkillItem, + slotValues: ServiceSkillSlotValues, +): Array<{ key: string; label: string; value: string }> { + return skill.slotSchema + .map((slot) => { + const displayValue = resolveSlotDisplayValue(slot, slotValues); + if (!displayValue) { + return null; + } + return { + key: slot.key, + label: slot.label, + value: summarizeMetadataValue(displayValue), + }; + }) + .filter((item): item is { key: string; label: string; value: string } => + Boolean(item), + ); +} + function resolveLocalTimeZone(): string { if ( typeof Intl !== "undefined" && @@ -171,19 +224,48 @@ function buildServiceSkillAutomationDescription( return lines.join("\n"); } +function buildServiceSkillAutomationMetadata(input: { + skill: ServiceSkillItem; + slotValues?: ServiceSkillSlotValues; + userInput?: string; +}): Record { + const { skill, slotValues, userInput } = input; + const slotSummary = slotValues + ? buildServiceSkillAutomationSlotSummary(skill, slotValues) + : []; + const normalizedUserInput = normalizeOptionalText(userInput); + + return { + id: skill.id, + title: skill.title, + runner_type: skill.runnerType, + execution_location: skill.executionLocation, + source: skill.source, + slot_values: slotSummary, + slot_summary: slotSummary.map((item) => `${item.label}: ${item.value}`), + user_input: normalizedUserInput ?? null, + }; +} + function buildServiceSkillAutomationRequestMetadata( - skill: ServiceSkillItem, - contentId?: string | null, + input: { + skill: ServiceSkillItem; + slotValues?: ServiceSkillSlotValues; + userInput?: string; + contentId?: string | null; + }, ): Record | undefined { + const { skill, slotValues, userInput, contentId } = input; const targetTheme = skill.themeTarget?.trim(); const workspaceSeed = buildServiceSkillWorkspaceSeed(skill, targetTheme); - if (!targetTheme && !workspaceSeed?.requestMetadata) { - return undefined; - } - return { ...(workspaceSeed?.requestMetadata ?? {}), + service_skill: buildServiceSkillAutomationMetadata({ + skill, + slotValues, + userInput, + }), harness: buildHarnessRequestMetadata({ theme: targetTheme || "general", preferences: { @@ -210,16 +292,20 @@ export function supportsServiceSkillLocalAutomation( export function buildServiceSkillAutomationAgentTurnPayloadContext({ skill, + slotValues, + userInput, contentId, }: BuildServiceSkillAutomationAgentTurnPayloadContextInput): { content_id?: string | null; request_metadata?: Record | null; } { const normalizedContentId = contentId?.trim() || null; - const requestMetadata = buildServiceSkillAutomationRequestMetadata( + const requestMetadata = buildServiceSkillAutomationRequestMetadata({ skill, - normalizedContentId, - ); + slotValues, + userInput, + contentId: normalizedContentId, + }); return { content_id: normalizedContentId, @@ -253,6 +339,8 @@ export function buildServiceSkillAutomationInitialValues({ agent_content_id: "", agent_request_metadata: buildServiceSkillAutomationAgentTurnPayloadContext({ skill, + slotValues, + userInput, }).request_metadata, max_retries: "2", delivery_mode: "none", diff --git a/src/components/agent/chat/service-skills/automationLinkStorage.test.ts b/src/components/agent/chat/service-skills/automationLinkStorage.test.ts index 231bcd035..014770fdb 100644 --- a/src/components/agent/chat/service-skills/automationLinkStorage.test.ts +++ b/src/components/agent/chat/service-skills/automationLinkStorage.test.ts @@ -4,6 +4,7 @@ import { buildServiceSkillAutomationStatusMap, listServiceSkillAutomationLinks, recordServiceSkillAutomationLink, + resolveServiceSkillAutomationLinks, subscribeServiceSkillAutomationLinksChanged, } from "./automationLinkStorage"; @@ -27,6 +28,13 @@ function buildJob( prompt: "prompt", system_prompt: null, web_search: false, + request_metadata: { + service_skill: { + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + }, + }, }, delivery: { mode: "none", @@ -123,4 +131,28 @@ describe("automationLinkStorage", () => { ); expect(statusMap["daily-trend-briefing"]?.detail).toContain("下次"); }); + + it("应从任务 request_metadata 恢复持久化的服务型技能关联", () => { + const links = resolveServiceSkillAutomationLinks([buildJob()]); + + expect(links).toEqual([ + expect.objectContaining({ + skillId: "daily-trend-briefing", + jobId: "automation-job-1", + jobName: "每日趋势摘要|定时执行", + }), + ]); + }); + + it("没有本地 link 时也应根据持久化关联构建首页状态", () => { + const statusMap = buildServiceSkillAutomationStatusMap([buildJob()]); + + expect(statusMap["daily-trend-briefing"]).toEqual( + expect.objectContaining({ + jobId: "automation-job-1", + statusLabel: "成功", + tone: "emerald", + }), + ); + }); }); diff --git a/src/components/agent/chat/service-skills/automationLinkStorage.ts b/src/components/agent/chat/service-skills/automationLinkStorage.ts index 13af3b9a7..11987e436 100644 --- a/src/components/agent/chat/service-skills/automationLinkStorage.ts +++ b/src/components/agent/chat/service-skills/automationLinkStorage.ts @@ -13,6 +13,26 @@ function hasWindow(): boolean { return typeof window !== "undefined"; } +function isPlainRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function normalizeNonEmptyText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized || null; +} + +function parseLinkedAt(value?: string | null): number { + if (!value) { + return 0; + } + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +} + function isValidAutomationLinkRecord( value: unknown, ): value is ServiceSkillAutomationLinkRecord { @@ -180,6 +200,67 @@ function resolveStatusDetail(job: AutomationJobRecord): string | null { return null; } +function extractPersistedServiceSkillLink( + job: AutomationJobRecord, +): ServiceSkillAutomationLinkRecord | null { + if (job.payload.kind !== "agent_turn") { + return null; + } + + const requestMetadata = job.payload.request_metadata; + if (!isPlainRecord(requestMetadata)) { + return null; + } + + const serviceSkillValue = + requestMetadata.service_skill ?? requestMetadata.serviceSkill; + if (!isPlainRecord(serviceSkillValue)) { + return null; + } + + const skillId = + normalizeNonEmptyText(serviceSkillValue.id) ?? + normalizeNonEmptyText(serviceSkillValue.skill_id) ?? + normalizeNonEmptyText(serviceSkillValue.skillId); + if (!skillId) { + return null; + } + + return { + skillId, + jobId: job.id, + jobName: job.name, + linkedAt: parseLinkedAt(job.updated_at) || parseLinkedAt(job.created_at), + }; +} + +export function resolveServiceSkillAutomationLinks( + jobs: AutomationJobRecord[], +): ServiceSkillAutomationLinkRecord[] { + const merged = new Map(); + + jobs.forEach((job) => { + const persistedLink = extractPersistedServiceSkillLink(job); + if (!persistedLink) { + return; + } + + const current = merged.get(persistedLink.skillId); + if (!current || persistedLink.linkedAt >= current.linkedAt) { + merged.set(persistedLink.skillId, persistedLink); + } + }); + + listServiceSkillAutomationLinks().forEach((link) => { + const current = merged.get(link.skillId); + if (!current || link.linkedAt > current.linkedAt) { + merged.set(link.skillId, link); + } + }); + + return [...merged.values()].sort((left, right) => right.linkedAt - left.linkedAt); +} + export function listServiceSkillAutomationLinks(): ServiceSkillAutomationLinkRecord[] { if (!hasWindow()) { return []; @@ -266,7 +347,7 @@ export function buildServiceSkillAutomationStatusMap( ): Record { const jobsById = new Map(jobs.map((job) => [job.id, job])); - return listServiceSkillAutomationLinks().reduce< + return resolveServiceSkillAutomationLinks(jobs).reduce< Record >((result, link) => { const job = jobsById.get(link.jobId); diff --git a/src/components/agent/chat/service-skills/cloudRunStorage.ts b/src/components/agent/chat/service-skills/cloudRunStorage.ts new file mode 100644 index 000000000..382df7f45 --- /dev/null +++ b/src/components/agent/chat/service-skills/cloudRunStorage.ts @@ -0,0 +1,275 @@ +import type { ServiceSkillRun } from "@/lib/api/serviceSkillRuns"; +import type { ServiceSkillCloudRunStatus, ServiceSkillTone } from "./types"; + +const SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY = + "lime:service-skill-cloud-runs:v1"; +export const SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT = + "lime:service-skill-cloud-runs-changed"; + +interface ServiceSkillCloudRunRecord extends ServiceSkillCloudRunStatus { + skillId: string; + status: string; +} + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +function normalizeNonEmptyText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized || null; +} + +function parseTimestamp(value?: string | null): number { + if (!value) { + return 0; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function formatCloudRunTime(value?: string | null): string | null { + if (!value) { + return null; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +} + +function resolveCloudRunStatusLabel(status: string): string { + switch (status) { + case "queued": + return "排队中"; + case "running": + return "运行中"; + case "success": + return "成功"; + case "failed": + return "失败"; + case "canceled": + return "已取消"; + case "timeout": + return "超时"; + default: + return "处理中"; + } +} + +function resolveCloudRunStatusTone(status: string): ServiceSkillTone { + switch (status) { + case "queued": + case "running": + return "sky"; + case "success": + return "emerald"; + case "failed": + case "canceled": + case "timeout": + return "amber"; + default: + return "slate"; + } +} + +function resolveCloudRunDetail(run: ServiceSkillRun): string | null { + const outputSummary = normalizeNonEmptyText(run.outputSummary); + const errorMessage = normalizeNonEmptyText(run.errorMessage); + const startedAt = formatCloudRunTime(run.startedAt); + const finishedAt = formatCloudRunTime(run.finishedAt); + const updatedAt = formatCloudRunTime(run.updatedAt); + + switch (run.status) { + case "queued": + return updatedAt ? `已提交 · ${updatedAt}` : "已提交到云端,等待执行"; + case "running": + return startedAt ? `开始于 ${startedAt}` : "云端执行中"; + case "success": + return outputSummary ?? (finishedAt ? `完成于 ${finishedAt}` : "云端结果已生成"); + case "failed": + case "canceled": + case "timeout": + return errorMessage ?? (finishedAt ? `结束于 ${finishedAt}` : null); + default: + return updatedAt ? `最近更新 ${updatedAt}` : null; + } +} + +function isValidCloudRunRecord(value: unknown): value is ServiceSkillCloudRunRecord { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + typeof record.skillId === "string" && + record.skillId.length > 0 && + typeof record.runId === "string" && + record.runId.length > 0 && + typeof record.status === "string" && + record.status.length > 0 && + typeof record.statusLabel === "string" && + record.statusLabel.length > 0 && + typeof record.tone === "string" && + typeof record.updatedAt === "number" && + Number.isFinite(record.updatedAt) + ); +} + +function emitCloudRunsChanged(): void { + if (!hasWindow()) { + return; + } + + window.dispatchEvent( + new CustomEvent(SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT, { + detail: { + timestamp: Date.now(), + }, + }), + ); +} + +function persistCloudRuns( + records: ServiceSkillCloudRunRecord[], +): ServiceSkillCloudRunRecord[] { + if (!hasWindow()) { + return records; + } + + try { + window.localStorage.setItem( + SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY, + JSON.stringify(records), + ); + } catch { + // ignore write errors + } + + emitCloudRunsChanged(); + return records; +} + +export function listServiceSkillCloudRuns(): ServiceSkillCloudRunRecord[] { + if (!hasWindow()) { + return []; + } + + try { + const raw = window.localStorage.getItem(SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isValidCloudRunRecord) + .sort((left, right) => right.updatedAt - left.updatedAt); + } catch { + return []; + } +} + +export function getServiceSkillCloudRunStatusMap(): Record< + string, + ServiceSkillCloudRunStatus +> { + return listServiceSkillCloudRuns().reduce< + Record + >((result, record) => { + result[record.skillId] = { + runId: record.runId, + statusLabel: record.statusLabel, + tone: record.tone, + detail: record.detail, + updatedAt: record.updatedAt, + }; + return result; + }, {}); +} + +export function recordServiceSkillCloudRun( + skillId: string, + run: ServiceSkillRun, +): ServiceSkillCloudRunRecord[] { + const normalizedSkillId = skillId.trim(); + const normalizedRunId = run.id.trim(); + if (!normalizedSkillId || !normalizedRunId) { + return listServiceSkillCloudRuns(); + } + + const nextRecord: ServiceSkillCloudRunRecord = { + skillId: normalizedSkillId, + runId: normalizedRunId, + status: run.status, + statusLabel: resolveCloudRunStatusLabel(run.status), + tone: resolveCloudRunStatusTone(run.status), + detail: resolveCloudRunDetail(run), + updatedAt: + parseTimestamp(run.updatedAt) || + parseTimestamp(run.finishedAt) || + parseTimestamp(run.startedAt) || + Date.now(), + }; + + const nextRecords = [ + nextRecord, + ...listServiceSkillCloudRuns().filter( + (record) => record.skillId !== nextRecord.skillId, + ), + ]; + + return persistCloudRuns(nextRecords); +} + +export function subscribeServiceSkillCloudRunsChanged( + callback: () => void, +): () => void { + if (!hasWindow()) { + return () => undefined; + } + + const customEventHandler = () => { + callback(); + }; + + const storageHandler = (event: StorageEvent) => { + if (event.key !== SERVICE_SKILL_CLOUD_RUNS_STORAGE_KEY) { + return; + } + callback(); + }; + + window.addEventListener( + SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT, + customEventHandler, + ); + window.addEventListener("storage", storageHandler); + + return () => { + window.removeEventListener( + SERVICE_SKILL_CLOUD_RUNS_CHANGED_EVENT, + customEventHandler, + ); + window.removeEventListener("storage", storageHandler); + }; +} diff --git a/src/components/agent/chat/service-skills/entryAdapter.ts b/src/components/agent/chat/service-skills/entryAdapter.ts new file mode 100644 index 000000000..2e8d6500c --- /dev/null +++ b/src/components/agent/chat/service-skills/entryAdapter.ts @@ -0,0 +1,63 @@ +import type { ServiceSkillHomeItem, ServiceSkillItem } from "./types"; + +export type ServiceSkillEntrySurface = "home" | "mention" | "workspace"; + +const DEFAULT_SERVICE_SKILL_ENTRY_SURFACES: ServiceSkillEntrySurface[] = [ + "home", + "mention", + "workspace", +]; + +function normalizeSearchText(value: string): string { + return value.trim().toLowerCase(); +} + +function collectServiceSkillSearchTokens(skill: ServiceSkillItem): string[] { + return [ + skill.title, + skill.skillKey ?? "", + skill.category, + skill.summary, + skill.entryHint ?? "", + ...(skill.aliases ?? []), + ] + .map(normalizeSearchText) + .filter(Boolean); +} + +export function supportsServiceSkillEntrySurface( + skill: ServiceSkillItem, + surface: ServiceSkillEntrySurface, +): boolean { + const surfaces = skill.surfaceScopes?.length + ? skill.surfaceScopes + : DEFAULT_SERVICE_SKILL_ENTRY_SURFACES; + return surfaces.includes(surface); +} + +export function resolveServiceSkillEntryDescription( + skill: Pick, +): string { + return skill.entryHint?.trim() || skill.summary; +} + +export function filterMentionableServiceSkills( + skills: ServiceSkillHomeItem[], + query: string, +): ServiceSkillHomeItem[] { + const normalizedQuery = normalizeSearchText(query); + + return skills.filter((skill) => { + if (!supportsServiceSkillEntrySurface(skill, "mention")) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + return collectServiceSkillSearchTokens(skill).some((token) => + token.includes(normalizedQuery), + ); + }); +} diff --git a/src/components/agent/chat/service-skills/promptComposer.test.ts b/src/components/agent/chat/service-skills/promptComposer.test.ts index f710e450b..e73641208 100644 --- a/src/components/agent/chat/service-skills/promptComposer.test.ts +++ b/src/components/agent/chat/service-skills/promptComposer.test.ts @@ -18,6 +18,7 @@ const MOCK_SKILL: ServiceSkillItem = { runnerType: "scheduled", defaultExecutorBinding: "automation_job", executionLocation: "client_default", + promptTemplateKey: "trend_briefing", version: "seed-v1", slotSchema: [ { @@ -75,6 +76,7 @@ describe("service skill prompt composer", () => { expect(prompt).toContain("[服务型技能] 每日趋势摘要"); expect(prompt).toContain("- 行业关键词: AI Agent,创作者工具"); expect(prompt).toContain("[补充要求] 重点关注过去 24 小时的新增热点。"); + expect(prompt).toContain("现在什么最热"); expect(prompt).toContain("当前为客户端起步版"); }); @@ -88,6 +90,24 @@ describe("service skill prompt composer", () => { }); expect(prompt).toContain("[自动化执行要求]"); + expect(prompt).toContain("对比上轮变化"); expect(prompt).not.toContain("当前为客户端起步版"); }); + + it("远端目录缺少 promptTemplateKey 时应回退到 skillKey 推断模板", () => { + const prompt = composeServiceSkillPrompt({ + skill: { + ...MOCK_SKILL, + id: "service-skill-0005", + skillKey: "daily-trend-briefing", + promptTemplateKey: undefined, + }, + slotValues: { + platform: "x", + industry_keywords: "AI Agent,创作者工具", + }, + }); + + expect(prompt).toContain("现在什么最热"); + }); }); diff --git a/src/components/agent/chat/service-skills/promptComposer.ts b/src/components/agent/chat/service-skills/promptComposer.ts index 690efa885..f61ecf7e6 100644 --- a/src/components/agent/chat/service-skills/promptComposer.ts +++ b/src/components/agent/chat/service-skills/promptComposer.ts @@ -26,7 +26,89 @@ const EXECUTION_LOCATION_LABELS = { cloud_required: "服务端特例执行", } as const; -function resolveSlotValue( +function resolvePromptTemplateKey( + skill: Pick, +): NonNullable { + if (skill.promptTemplateKey) { + return skill.promptTemplateKey; + } + + const identity = skill.skillKey ?? skill.id; + if ( + identity === "carousel-post-replication" || + identity === "short-video-script-replication" + ) { + return "replication"; + } + if (identity === "daily-trend-briefing") { + return "trend_briefing"; + } + if (identity === "account-performance-tracking") { + return "account_growth"; + } + return "generic"; +} + +function appendServiceSkillTemplateRequirements( + lines: string[], + skill: ServiceSkillItem, +): void { + switch (resolvePromptTemplateKey(skill)) { + case "replication": + lines.push( + "[执行重点] 先拆解参考样本的结构、节奏、卖点与语言风格,再给出一版贴近原逻辑但可继续调整的结果。", + ); + lines.push( + "[输出结构] 先写拆解结论,再写首版内容,最后列出最值得继续微调的 3 个点。", + ); + break; + case "trend_briefing": + lines.push( + "[执行重点] 先判断现在什么最热、为什么会火、哪些变化最值得跟进,再整理建议动作。", + ); + lines.push( + "[输出结构] 结论摘要、热点变化、原因判断、建议动作、后续跟踪建议。", + ); + break; + case "account_growth": + lines.push( + "[执行重点] 先拆参考账号的内容策略、节奏和增长抓手,再输出可执行的增长方案与跟踪指标。", + ); + lines.push( + "[输出结构] 现状判断、对标拆解、增长动作、发布节奏、监测指标与告警条件。", + ); + break; + default: + break; + } +} + +function appendServiceSkillAutomationTemplateRequirements( + lines: string[], + skill: ServiceSkillItem, +): void { + switch (resolvePromptTemplateKey(skill)) { + case "trend_briefing": + lines.push( + "[自动化执行重点] 每轮先对比上轮变化,再明确新增热点、回落话题、值得跟进的信号和建议动作。", + ); + break; + case "account_growth": + lines.push( + "[自动化执行重点] 每轮先比较账号表现与目标差距,再输出增长异常、原因判断、行动建议与告警项。", + ); + break; + case "replication": + lines.push( + "[自动化执行重点] 每轮都要先提炼参考样本的新变化,再给出贴近当前样本的最新版内容建议。", + ); + break; + default: + break; + } +} + +export function resolveServiceSkillSlotValue( slot: ServiceSkillSlotDefinition, slotValues: ServiceSkillSlotValues, ): string { @@ -52,7 +134,9 @@ function buildServiceSkillPromptLines( ]; for (const slot of skill.slotSchema) { - lines.push(`- ${slot.label}: ${resolveSlotValue(slot, slotValues) || "未提供"}`); + lines.push( + `- ${slot.label}: ${resolveServiceSkillSlotValue(slot, slotValues) || "未提供"}`, + ); } if (userInput?.trim()) { @@ -79,7 +163,7 @@ export function validateServiceSkillSlotValues( if (!slot.required) { return false; } - return !resolveSlotValue(slot, slotValues); + return !resolveServiceSkillSlotValue(slot, slotValues); }); return { @@ -93,7 +177,10 @@ export function formatServiceSkillPromptPreview( slotValues: ServiceSkillSlotValues, ): string { const resolvedValues = skill.slotSchema - .map((slot) => `${slot.label}:${resolveSlotValue(slot, slotValues) || "待补充"}`) + .map( + (slot) => + `${slot.label}:${resolveServiceSkillSlotValue(slot, slotValues) || "待补充"}`, + ) .slice(0, 3); return `${skill.title}|${resolvedValues.join("|")}`; @@ -106,6 +193,8 @@ export function composeServiceSkillPrompt({ }: ComposeServiceSkillPromptInput): string { const lines = buildServiceSkillPromptLines(skill, slotValues, userInput); + appendServiceSkillTemplateRequirements(lines, skill); + if (skill.runnerType === "instant") { lines.push( "[执行要求] 现在直接开始,优先产出一版可交付结果;若信息不足,先给出最小缺口,再继续推进。", @@ -134,6 +223,8 @@ export function composeServiceSkillAutomationPrompt({ }: ComposeServiceSkillPromptInput): string { const lines = buildServiceSkillPromptLines(skill, slotValues, userInput); + appendServiceSkillAutomationTemplateRequirements(lines, skill); + if (skill.runnerType === "scheduled") { lines.push( "[自动化执行要求] 这是一个由本地自动化定时触发的任务。每次运行都要完成本轮结果,优先输出当前周期的摘要、变化、异常和下一步建议;若本轮没有明显变化,也要明确说明“本轮无显著变化”。", diff --git a/src/components/agent/chat/service-skills/siteCapabilityBinding.ts b/src/components/agent/chat/service-skills/siteCapabilityBinding.ts new file mode 100644 index 000000000..8977aeb2e --- /dev/null +++ b/src/components/agent/chat/service-skills/siteCapabilityBinding.ts @@ -0,0 +1,93 @@ +import type { ServiceSkillItem } from "@/lib/api/serviceSkills"; +import type { ServiceSkillSlotValues } from "./types"; +import { resolveServiceSkillSlotValue } from "./promptComposer"; + +export function isServiceSkillSiteCapabilityBound( + skill: Pick, +): skill is Pick< + ServiceSkillItem, + "defaultExecutorBinding" | "siteCapabilityBinding" +> & { + siteCapabilityBinding: NonNullable; +} { + return ( + skill.defaultExecutorBinding === "browser_assist" && + !!skill.siteCapabilityBinding + ); +} + +export function buildServiceSkillSiteCapabilityArgs( + skill: ServiceSkillItem, + slotValues: ServiceSkillSlotValues, +): Record { + if (!isServiceSkillSiteCapabilityBound(skill)) { + return {}; + } + + const mappedArgs = skill.slotSchema.reduce>( + (acc, slot) => { + const argName = skill.siteCapabilityBinding.slotArgMap?.[slot.key]; + if (!argName) { + return acc; + } + + const value = resolveServiceSkillSlotValue(slot, slotValues); + if (!value) { + return acc; + } + + acc[argName] = value; + return acc; + }, + {}, + ); + + return { + ...mappedArgs, + ...(skill.siteCapabilityBinding.fixedArgs ?? {}), + }; +} + +function normalizeTemplateSegment(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + const normalized = String(value).trim().replace(/\s+/g, " "); + return normalized; +} + +export function buildServiceSkillSiteCapabilitySaveTitle( + skill: ServiceSkillItem, + slotValues: ServiceSkillSlotValues, +): string | undefined { + if ( + !isServiceSkillSiteCapabilityBound(skill) || + !skill.siteCapabilityBinding.suggestedTitleTemplate + ) { + return undefined; + } + + const slotValueMap = Object.fromEntries( + skill.slotSchema.map((slot) => [ + slot.key, + resolveServiceSkillSlotValue(slot, slotValues), + ]), + ); + const template = skill.siteCapabilityBinding.suggestedTitleTemplate; + const rendered = template + .replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, rawToken: string) => { + switch (rawToken) { + case "skill.title": + return normalizeTemplateSegment(skill.title); + case "adapter.name": + return normalizeTemplateSegment(skill.siteCapabilityBinding.adapterName); + default: + return normalizeTemplateSegment(slotValueMap[rawToken]); + } + }) + .replace(/\s+/g, " ") + .trim(); + + return rendered || undefined; +} diff --git a/src/components/agent/chat/service-skills/types.ts b/src/components/agent/chat/service-skills/types.ts index 4618cd02f..97318300b 100644 --- a/src/components/agent/chat/service-skills/types.ts +++ b/src/components/agent/chat/service-skills/types.ts @@ -4,12 +4,14 @@ import type { ServiceSkillExecutionLocation, ServiceSkillExecutorBinding, ServiceSkillItem, + ServiceSkillPromptTemplateKey, ServiceSkillReadinessRequirements, ServiceSkillRunnerType, ServiceSkillSlotDefinition, ServiceSkillSlotOption, ServiceSkillSlotType, ServiceSkillSource, + ServiceSkillSurfaceScope, } from "@/lib/api/serviceSkills"; export type { @@ -18,12 +20,14 @@ export type { ServiceSkillExecutionLocation, ServiceSkillExecutorBinding, ServiceSkillItem, + ServiceSkillPromptTemplateKey, ServiceSkillReadinessRequirements, ServiceSkillRunnerType, ServiceSkillSlotDefinition, ServiceSkillSlotOption, ServiceSkillSlotType, ServiceSkillSource, + ServiceSkillSurfaceScope, }; export type ServiceSkillTone = "slate" | "sky" | "emerald" | "amber"; @@ -45,6 +49,14 @@ export interface ServiceSkillAutomationLinkRecord { linkedAt: number; } +export interface ServiceSkillCloudRunStatus { + runId: string; + statusLabel: string; + tone: ServiceSkillTone; + detail: string | null; + updatedAt: number; +} + export interface ServiceSkillHomeItem extends ServiceSkillItem { badge: string; recentUsedAt: number | null; @@ -54,6 +66,7 @@ export interface ServiceSkillHomeItem extends ServiceSkillItem { runnerDescription: string; actionLabel: string; automationStatus: ServiceSkillAutomationStatus | null; + cloudStatus?: ServiceSkillCloudRunStatus | null; } export interface ServiceSkillCatalogMeta { diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx index 5c320d2d2..c9892b707 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -8,6 +8,7 @@ import { type ServiceSkillCatalog, } from "@/lib/api/serviceSkills"; import { recordServiceSkillAutomationLink } from "./automationLinkStorage"; +import { recordServiceSkillCloudRun } from "./cloudRunStorage"; import { useServiceSkills } from "./useServiceSkills"; interface HookHarness { @@ -29,6 +30,35 @@ function buildRemoteCatalog(): ServiceSkillCatalog { summary: "远端同步后的目录项", version: "tenant-2026-03-24", }, + { + ...seeded.items[1]!, + id: "local-playbook-template", + title: "本地增长打法模版", + summary: "项目内维护的本地补充技能。", + source: "local_custom", + version: "local-2026-03-24", + }, + ], + }; +} + +function buildCloudCatalog(): ServiceSkillCatalog { + const seeded = getSeededServiceSkillCatalog(); + return { + version: "tenant-2026-03-27", + tenantId: "tenant-demo", + syncedAt: "2026-03-27T12:00:00.000Z", + items: [ + { + ...seeded.items[1]!, + id: "cloud-video-dubbing", + title: "云端视频配音", + summary: "把参考视频与文案提交到 OEM 云端执行,并把结果回流到本地工作区。", + executionLocation: "cloud_required", + defaultExecutorBinding: "cloud_scene", + themeTarget: "video", + version: "tenant-2026-03-27", + }, ], }; } @@ -122,6 +152,16 @@ describe("useServiceSkills", () => { isSeeded: true, }), ); + expect( + harness + .getValue() + .skills.find((skill) => skill.id === "github-repo-radar"), + ).toEqual( + expect.objectContaining({ + runnerLabel: "浏览器站点执行", + actionLabel: "启动采集", + }), + ); act(() => { recordServiceSkillAutomationLink({ @@ -158,9 +198,11 @@ describe("useServiceSkills", () => { await flushEffects(); - expect(harness.getValue().skills).toHaveLength(1); + expect(harness.getValue().skills).toHaveLength(2); expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing"); + expect(harness.getValue().skills[1]?.id).toBe("local-playbook-template"); expect(harness.getValue().skills[0]?.badge).toBe("云目录"); + expect(harness.getValue().skills[1]?.badge).toBe("本地技能"); expect(harness.getValue().catalogMeta).toEqual( expect.objectContaining({ tenantId: "tenant-demo", @@ -239,8 +281,9 @@ describe("useServiceSkills", () => { await flushEffects(4); - expect(harness.getValue().skills).toHaveLength(1); + expect(harness.getValue().skills).toHaveLength(2); expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing"); + expect(harness.getValue().skills[1]?.id).toBe("local-playbook-template"); expect(harness.getValue().catalogMeta).toEqual( expect.objectContaining({ tenantId: "tenant-demo", @@ -253,4 +296,52 @@ describe("useServiceSkills", () => { harness.unmount(); } }); + + it("cloud_required 技能状态变更后应回灌到首页技能列表", async () => { + const harness = mountHook(); + + try { + await flushEffects(); + + act(() => { + saveServiceSkillCatalog(buildCloudCatalog(), "manual_override"); + }); + + await flushEffects(); + + act(() => { + recordServiceSkillCloudRun("cloud-video-dubbing", { + id: "cloud-run-1", + status: "success", + outputSummary: "云端结果已生成", + finishedAt: "2026-03-27T12:03:00.000Z", + updatedAt: "2026-03-27T12:03:00.000Z", + }); + }); + + await flushEffects(); + + expect( + harness + .getValue() + .skills.find((skill) => skill.id === "cloud-video-dubbing") + ?.cloudStatus, + ).toEqual( + expect.objectContaining({ + runId: "cloud-run-1", + statusLabel: "成功", + tone: "emerald", + detail: "云端结果已生成", + }), + ); + expect( + harness + .getValue() + .skills.find((skill) => skill.id === "cloud-video-dubbing") + ?.runnerLabel, + ).toBe("云端托管执行"); + } finally { + harness.unmount(); + } + }); }); diff --git a/src/components/agent/chat/service-skills/useServiceSkills.ts b/src/components/agent/chat/service-skills/useServiceSkills.ts index 04df157c2..556d36f10 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.ts +++ b/src/components/agent/chat/service-skills/useServiceSkills.ts @@ -6,17 +6,25 @@ import { refreshServiceSkillCatalogFromRemote, subscribeServiceSkillCatalogChanged, } from "@/lib/api/serviceSkills"; +import { isServiceSkillSiteCapabilityBound } from "./siteCapabilityBinding"; import { buildServiceSkillAutomationStatusMap, listServiceSkillAutomationLinks, + resolveServiceSkillAutomationLinks, subscribeServiceSkillAutomationLinksChanged, } from "./automationLinkStorage"; +import { + getServiceSkillCloudRunStatusMap, + subscribeServiceSkillCloudRunsChanged, +} from "./cloudRunStorage"; +import { supportsServiceSkillLocalAutomation } from "./automationDraft"; import { getServiceSkillUsageMap, recordServiceSkillUsage } from "./storage"; import type { RecordServiceSkillUsageInput, ServiceSkillAutomationStatus, ServiceSkillCatalog, ServiceSkillCatalogMeta, + ServiceSkillCloudRunStatus, ServiceSkillHomeItem, ServiceSkillItem, ServiceSkillRunnerType, @@ -37,20 +45,23 @@ const RUNNER_TONES: Record = { const RUNNER_DESCRIPTIONS: Record = { instant: "客户端起步版可直接进入工作区执行。", - scheduled: "当前先进入工作区生成首版任务方案,后续再接本地自动化。", - managed: "当前先进入工作区生成首版跟踪方案,后续再接本地持续任务。", + scheduled: "可直接创建本地定时任务,并回流到任务中心与工作区。", + managed: "可直接创建本地持续跟踪任务,并回流到任务中心与工作区。", }; const LOCAL_ACTION_LABELS: Record = { instant: "填写参数", - scheduled: "先做方案", - managed: "先定指标", + scheduled: "创建任务", + managed: "创建跟踪", }; function getRunnerLabel(item: ServiceSkillItem): string { if (item.executionLocation === "cloud_required") { return "云端托管执行"; } + if (isServiceSkillSiteCapabilityBound(item)) { + return "浏览器站点执行"; + } return RUNNER_LABELS[item.runnerType]; } @@ -65,6 +76,9 @@ function getRunnerDescription(item: ServiceSkillItem): string { if (item.executionLocation === "cloud_required") { return "提交到 OEM 云端执行,结果由服务端异步返回。"; } + if (isServiceSkillSiteCapabilityBound(item)) { + return "直接进入浏览器工作台,复用真实登录态执行站点脚本并沉淀结果。"; + } return RUNNER_DESCRIPTIONS[item.runnerType]; } @@ -72,6 +86,9 @@ function getActionLabel(item: ServiceSkillItem): string { if (item.executionLocation === "cloud_required") { return "提交云端"; } + if (isServiceSkillSiteCapabilityBound(item)) { + return "启动采集"; + } return LOCAL_ACTION_LABELS[item.runnerType]; } @@ -88,6 +105,7 @@ function getSkillBadge(item: ServiceSkillItem, isRecent: boolean): string { function buildHomeItems( items: ServiceSkillItem[], automationStatusMap: Record, + cloudRunStatusMap: Record, ): ServiceSkillHomeItem[] { const usageMap = getServiceSkillUsageMap(); const mapped: Array = items.map( @@ -106,6 +124,10 @@ function buildHomeItems( runnerDescription: getRunnerDescription(item), actionLabel: getActionLabel(item), automationStatus: automationStatusMap[item.id] ?? null, + cloudStatus: + item.executionLocation === "cloud_required" + ? cloudRunStatusMap[item.id] ?? null + : null, _sortIndex: index, }; }, @@ -157,6 +179,9 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult { const [automationStatusMap, setAutomationStatusMap] = useState< Record >({}); + const [cloudRunStatusMap, setCloudRunStatusMap] = useState< + Record + >({}); const [catalogMeta, setCatalogMeta] = useState( null, ); @@ -168,19 +193,25 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult { const applyCatalogSnapshot = useCallback(async (catalog: ServiceSkillCatalog) => { const automationLinks = listServiceSkillAutomationLinks(); let automationStatuses: Record = {}; + let resolvedAutomationLinkCount = automationLinks.length; - if (automationLinks.length > 0) { + const hasLocalAutomationSkills = catalog.items.some((item) => + supportsServiceSkillLocalAutomation(item), + ); + + if (automationLinks.length > 0 || hasLocalAutomationSkills) { try { - automationStatuses = buildServiceSkillAutomationStatusMap( - await getAutomationJobs(), - ); + const automationJobs = await getAutomationJobs(); + automationStatuses = buildServiceSkillAutomationStatusMap(automationJobs); + resolvedAutomationLinkCount = + resolveServiceSkillAutomationLinks(automationJobs).length; } catch { automationStatuses = {}; } } - setItems(catalog.items.filter((item) => item.source === "cloud_catalog")); - setAutomationLinkCount(automationLinks.length); + setItems(catalog.items); + setAutomationLinkCount(resolvedAutomationLinkCount); setAutomationStatusMap(automationStatuses); setCatalogMeta(buildCatalogMeta(catalog)); }, []); @@ -254,6 +285,26 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult { }; }, [enabled, loadCurrentCatalog]); + useEffect(() => { + if (!enabled) { + setCloudRunStatusMap({}); + return; + } + + const syncCloudRuns = () => { + setCloudRunStatusMap(getServiceSkillCloudRunStatusMap()); + }; + + syncCloudRuns(); + const unsubscribeCloudRuns = subscribeServiceSkillCloudRunsChanged(() => { + syncCloudRuns(); + }); + + return () => { + unsubscribeCloudRuns(); + }; + }, [enabled]); + useEffect(() => { if (!enabled || automationLinkCount === 0) { return; @@ -278,8 +329,8 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult { const skills = useMemo(() => { void usageVersion; - return buildHomeItems(items, automationStatusMap); - }, [items, usageVersion, automationStatusMap]); + return buildHomeItems(items, automationStatusMap, cloudRunStatusMap); + }, [items, usageVersion, automationStatusMap, cloudRunStatusMap]); return { skills, diff --git a/src/components/agent/chat/utils/artifactPlaceholder.ts b/src/components/agent/chat/utils/artifactPlaceholder.ts deleted file mode 100644 index 7e970dd25..000000000 --- a/src/components/agent/chat/utils/artifactPlaceholder.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @file Artifact 占位符工具 - * @description 解析消息中的 artifact fence,替换为占位符 - * @module components/agent/chat/utils/artifactPlaceholder - */ - -/** - * 解析结果 - */ -export interface ArtifactPlaceholderResult { - /** 处理后的文本(artifact 被替换为占位符标记) */ - processedText: string; - /** 检测到的 artifact 信息 */ - artifacts: ArtifactInfo[]; - /** 是否有未闭合的 artifact */ - hasPending: boolean; -} - -/** - * Artifact 信息 - */ -export interface ArtifactInfo { - id: string; - type: string; - title: string; - language?: string; - isComplete: boolean; -} - -/** - * 占位符标记格式 - */ -export const ARTIFACT_PLACEHOLDER_PREFIX = "[[ARTIFACT:"; -export const ARTIFACT_PLACEHOLDER_SUFFIX = "]]"; - -/** - * 解析文本中的 artifact fence,返回处理后的文本和 artifact 信息 - * - * @param text - 原始文本 - * @param isStreaming - 是否正在流式输出 - * @returns 解析结果 - */ -export function parseArtifactPlaceholders( - text: string, - isStreaming: boolean = false, -): ArtifactPlaceholderResult { - const artifacts: ArtifactInfo[] = []; - let processedText = text; - let hasPending = false; - - // 匹配完整的 artifact fence: ```artifact ... ``` ... ``` - const completeArtifactRegex = /```artifact\s+([^`]*?)```([\s\S]*?)```/g; - - // 匹配未闭合的 artifact fence(流式输出时) - const pendingArtifactRegex = /```artifact\s+([^`]*?)```([\s\S]*)$/; - - // 先处理完整的 artifact - let match: RegExpExecArray | null; - const replacements: Array<{ - start: number; - end: number; - placeholder: string; - info: ArtifactInfo; - }> = []; - - while ((match = completeArtifactRegex.exec(text)) !== null) { - const attrString = match[1]; - const info = parseArtifactAttributes(attrString); - - const artifactInfo: ArtifactInfo = { - id: info.id || crypto.randomUUID(), - type: info.type || "code", - title: info.title || "未命名", - language: info.language, - isComplete: true, - }; - - artifacts.push(artifactInfo); - - const placeholder = `${ARTIFACT_PLACEHOLDER_PREFIX}${artifactInfo.id}:${artifactInfo.title}${ARTIFACT_PLACEHOLDER_SUFFIX}`; - replacements.push({ - start: match.index, - end: match.index + match[0].length, - placeholder, - info: artifactInfo, - }); - } - - // 从后往前替换,避免索引偏移 - for (let i = replacements.length - 1; i >= 0; i--) { - const { start, end, placeholder } = replacements[i]; - processedText = - processedText.slice(0, start) + placeholder + processedText.slice(end); - } - - // 检查是否有未闭合的 artifact(流式输出时) - if (isStreaming) { - const pendingMatch = pendingArtifactRegex.exec(processedText); - if (pendingMatch && !processedText.includes(ARTIFACT_PLACEHOLDER_PREFIX)) { - // 有未闭合的 artifact - hasPending = true; - const attrString = pendingMatch[1]; - const info = parseArtifactAttributes(attrString); - - const artifactInfo: ArtifactInfo = { - id: info.id || crypto.randomUUID(), - type: info.type || "code", - title: info.title || "生成中...", - language: info.language, - isComplete: false, - }; - - artifacts.push(artifactInfo); - - // 替换未闭合的 artifact 为占位符 - const placeholder = `${ARTIFACT_PLACEHOLDER_PREFIX}${artifactInfo.id}:${artifactInfo.title}:pending${ARTIFACT_PLACEHOLDER_SUFFIX}`; - processedText = processedText.slice(0, pendingMatch.index) + placeholder; - } - } - - return { - processedText, - artifacts, - hasPending, - }; -} - -/** - * 解析 artifact 属性字符串 - */ -function parseArtifactAttributes(attrString: string): { - id?: string; - type?: string; - title?: string; - language?: string; -} { - const result: { - id?: string; - type?: string; - title?: string; - language?: string; - } = {}; - - // 解析 key="value" 格式 - const attrRegex = /(\w+)\s*=\s*["']([^"']*)["']/g; - let match: RegExpExecArray | null; - - while ((match = attrRegex.exec(attrString)) !== null) { - const [, key, value] = match; - const normalizedKey = key.toLowerCase(); - - switch (normalizedKey) { - case "id": - result.id = value; - break; - case "type": - result.type = value; - break; - case "title": - result.title = value; - break; - case "language": - result.language = value; - break; - } - } - - return result; -} - -/** - * 检查文本是否包含 artifact 占位符 - */ -export function hasArtifactPlaceholder(text: string): boolean { - return text.includes(ARTIFACT_PLACEHOLDER_PREFIX); -} - -/** - * 从占位符中提取 artifact ID - */ -export function extractArtifactId(placeholder: string): string | null { - const match = placeholder.match(/\[\[ARTIFACT:([^:]+):/); - return match ? match[1] : null; -} diff --git a/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts b/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts new file mode 100644 index 000000000..c49702dc3 --- /dev/null +++ b/src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { buildUserInputSubmitOp } from "./buildUserInputSubmitOp"; + +describe("buildUserInputSubmitOp", () => { + it("应构造最小 user_input op,并裁掉 steady-state 字段", () => { + const op = buildUserInputSubmitOp({ + content: "继续生成社媒初稿", + images: [ + { + data: "base64-image", + mediaType: "image/png", + }, + ], + sessionId: "session-social-1", + eventName: "aster_stream_x", + workspaceId: "workspace-1", + turnId: "turn-1", + systemPrompt: "system", + queueIfBusy: true, + requestMetadata: { + harness: { + preferences: { + web_search: false, + thinking: true, + }, + theme: "social-media", + session_mode: "theme_workbench", + gate_key: "write_mode", + run_title: "社媒初稿", + content_id: "content-social-1", + }, + }, + executionRuntime: { + session_id: "session-social-1", + source: "runtime_snapshot", + provider_selector: "openai", + model_name: "gpt-4.1", + execution_strategy: "react", + recent_preferences: { + webSearch: false, + thinking: true, + task: false, + subagent: false, + }, + recent_theme: "social-media", + recent_session_mode: "theme_workbench", + recent_gate_key: "write_mode", + recent_run_title: "社媒初稿", + recent_content_id: "content-social-1", + }, + syncedRecentPreferences: { + webSearch: false, + thinking: true, + task: false, + subagent: false, + }, + syncedSessionModelPreference: { + providerType: "openai", + model: "gpt-4.1", + }, + syncedExecutionStrategy: "react", + effectiveExecutionStrategy: "react", + effectiveProviderType: "openai", + effectiveModel: "gpt-4.1", + webSearch: false, + thinking: true, + }); + + expect(op).toEqual({ + type: "user_input", + text: "继续生成社媒初稿", + sessionId: "session-social-1", + eventName: "aster_stream_x", + workspaceId: "workspace-1", + turnId: "turn-1", + images: [ + { + data: "base64-image", + media_type: "image/png", + }, + ], + preferences: { + providerPreference: undefined, + modelPreference: undefined, + thinking: undefined, + executionStrategy: undefined, + webSearch: undefined, + autoContinue: undefined, + }, + systemPrompt: "system", + metadata: undefined, + queueIfBusy: true, + }); + }); + + it("应保留尚未同步到 runtime 的显式偏好与 metadata", () => { + const op = buildUserInputSubmitOp({ + content: "切到发布确认", + images: [], + sessionId: "session-social-1", + eventName: "aster_stream_y", + turnId: "turn-2", + requestMetadata: { + harness: { + preferences: { + thinking: true, + }, + theme: "social-media", + session_mode: "theme_workbench", + gate_key: "publish_confirm", + run_title: "发布确认", + }, + }, + executionRuntime: { + session_id: "session-social-1", + source: "runtime_snapshot", + provider_selector: "openai", + model_name: "gpt-4.1", + execution_strategy: "react", + recent_preferences: { + webSearch: false, + thinking: false, + task: false, + subagent: false, + }, + recent_theme: "social-media", + recent_session_mode: "theme_workbench", + recent_gate_key: "write_mode", + recent_run_title: "社媒初稿", + }, + syncedRecentPreferences: { + webSearch: false, + thinking: false, + task: false, + subagent: false, + }, + syncedSessionModelPreference: { + providerType: "openai", + model: "gpt-4.1", + }, + syncedExecutionStrategy: "react", + effectiveExecutionStrategy: "code_orchestrated", + effectiveProviderType: "openai", + effectiveModel: "gpt-5", + modelOverride: "gpt-5", + webSearch: false, + thinking: true, + autoContinue: { + enabled: true, + fast_mode_enabled: false, + continuation_length: 2, + sensitivity: 0.5, + }, + }); + + expect(op.preferences).toEqual({ + providerPreference: undefined, + modelPreference: "gpt-5", + thinking: true, + executionStrategy: "code_orchestrated", + webSearch: undefined, + autoContinue: { + enabled: true, + fast_mode_enabled: false, + continuation_length: 2, + sensitivity: 0.5, + }, + }); + expect(op.metadata).toEqual({ + harness: { + preferences: { + thinking: true, + }, + gate_key: "publish_confirm", + run_title: "发布确认", + }, + }); + }); +}); diff --git a/src/components/agent/chat/utils/buildUserInputSubmitOp.ts b/src/components/agent/chat/utils/buildUserInputSubmitOp.ts new file mode 100644 index 000000000..323e9b543 --- /dev/null +++ b/src/components/agent/chat/utils/buildUserInputSubmitOp.ts @@ -0,0 +1,113 @@ +import type { AgentUserInputOp } from "@/lib/api/agentProtocol"; +import type { + AsterExecutionStrategy, + AsterSessionExecutionRuntime, + AutoContinueRequestPayload, + ImageInput, +} from "@/lib/api/agentRuntime"; +import type { SessionModelPreference } from "../hooks/agentChatShared"; +import type { MessageImage } from "../types"; +import type { ChatToolPreferences } from "./chatToolPreferences"; +import { buildSubmitOpRuntimeCompaction } from "./submitOpRuntimeCompaction"; + +function buildSubmitImages(images: MessageImage[]): ImageInput[] | undefined { + if (images.length === 0) { + return undefined; + } + + return images.map((image) => ({ + data: image.data, + media_type: image.mediaType, + })); +} + +export interface BuildUserInputSubmitOpOptions { + content: string; + images: MessageImage[]; + sessionId: string; + eventName: string; + workspaceId?: string; + turnId?: string; + systemPrompt?: string; + queueIfBusy?: boolean; + requestMetadata?: Record; + executionRuntime?: AsterSessionExecutionRuntime | null; + syncedRecentPreferences?: ChatToolPreferences | null; + syncedSessionModelPreference?: SessionModelPreference | null; + syncedExecutionStrategy?: AsterExecutionStrategy | null; + effectiveExecutionStrategy: AsterExecutionStrategy; + effectiveProviderType: string; + effectiveModel: string; + modelOverride?: string; + webSearch?: boolean; + thinking?: boolean; + autoContinue?: AutoContinueRequestPayload; +} + +export function buildUserInputSubmitOp( + options: BuildUserInputSubmitOpOptions, +): AgentUserInputOp { + const { + content, + images, + sessionId, + eventName, + workspaceId, + turnId, + systemPrompt, + queueIfBusy, + requestMetadata, + executionRuntime, + syncedRecentPreferences, + syncedSessionModelPreference, + syncedExecutionStrategy, + effectiveExecutionStrategy, + effectiveProviderType, + effectiveModel, + modelOverride, + webSearch, + thinking, + autoContinue, + } = options; + + const compaction = buildSubmitOpRuntimeCompaction({ + requestMetadata, + executionRuntime, + syncedRecentPreferences, + syncedSessionModelPreference, + syncedExecutionStrategy, + effectiveExecutionStrategy, + effectiveProviderType, + effectiveModel, + modelOverride, + webSearch, + thinking, + }); + + return { + type: "user_input", + text: content, + sessionId, + eventName, + workspaceId, + turnId, + images: buildSubmitImages(images), + preferences: { + providerPreference: compaction.shouldSubmitProviderPreference + ? effectiveProviderType + : undefined, + modelPreference: compaction.shouldSubmitModelPreference + ? effectiveModel + : undefined, + thinking: compaction.shouldSubmitThinking ? thinking : undefined, + executionStrategy: compaction.shouldSubmitExecutionStrategy + ? effectiveExecutionStrategy + : undefined, + webSearch: compaction.shouldSubmitWebSearch ? webSearch : undefined, + autoContinue, + }, + systemPrompt, + metadata: compaction.metadata, + queueIfBusy, + }; +} diff --git a/src/components/agent/chat/utils/harnessRequestMetadata.test.ts b/src/components/agent/chat/utils/harnessRequestMetadata.test.ts index 838145006..b3b97a4eb 100644 --- a/src/components/agent/chat/utils/harnessRequestMetadata.test.ts +++ b/src/components/agent/chat/utils/harnessRequestMetadata.test.ts @@ -25,6 +25,7 @@ describe("harnessRequestMetadata", () => { selectedTeamId: "custom-team-1", selectedTeamSource: "custom", selectedTeamLabel: "前端联调团队", + selectedTeamDescription: "分析、实现、验证三段式推进。", selectedTeamSummary: "分析、实现、验证三段式推进。", selectedTeamRoles: [ { @@ -54,6 +55,7 @@ describe("harnessRequestMetadata", () => { selected_team_id: "custom-team-1", selected_team_source: "custom", selected_team_label: "前端联调团队", + selected_team_description: "分析、实现、验证三段式推进。", selected_team_summary: "分析、实现、验证三段式推进。", selected_team_roles: [ expect.objectContaining({ diff --git a/src/components/agent/chat/utils/harnessRequestMetadata.ts b/src/components/agent/chat/utils/harnessRequestMetadata.ts index 14bf4be2f..fece750fd 100644 --- a/src/components/agent/chat/utils/harnessRequestMetadata.ts +++ b/src/components/agent/chat/utils/harnessRequestMetadata.ts @@ -23,6 +23,7 @@ export interface BuildHarnessRequestMetadataOptions { selectedTeamId?: string | null; selectedTeamSource?: TeamDefinitionSource | null; selectedTeamLabel?: string | null; + selectedTeamDescription?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; } @@ -89,6 +90,7 @@ export function buildHarnessRequestMetadata( selectedTeamId, selectedTeamSource, selectedTeamLabel, + selectedTeamDescription, selectedTeamSummary, selectedTeamRoles, } = options; @@ -127,6 +129,7 @@ export function buildHarnessRequestMetadata( selected_team_id: selectedTeamId || undefined, selected_team_source: selectedTeamSource || undefined, selected_team_label: selectedTeamLabel || undefined, + selected_team_description: selectedTeamDescription || undefined, selected_team_summary: selectedTeamSummary || undefined, selected_team_roles: serializeTeamRoles(selectedTeamRoles), browser_requirement: browserRequirement || undefined, diff --git a/src/components/agent/chat/utils/sessionExecutionRuntime.deepseek.test.ts b/src/components/agent/chat/utils/sessionExecutionRuntime.deepseek.test.ts new file mode 100644 index 000000000..b6758cc6d --- /dev/null +++ b/src/components/agent/chat/utils/sessionExecutionRuntime.deepseek.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + applyModelChangeExecutionRuntime, + applyTurnContextExecutionRuntime, + getExecutionRuntimeSummaryLabel, + getOutputSchemaRuntimeLabel, +} from "./sessionExecutionRuntime"; + +describe("sessionExecutionRuntime deepseek-reasoner", () => { + it("应在 deepseek-reasoner 的 Artifact runtime 上持续保留 final_output_tool 策略", () => { + const fromTurnContext = applyTurnContextExecutionRuntime(null, { + type: "turn_context", + session_id: "session-deepseek", + thread_id: "thread-1", + turn_id: "turn-1", + output_schema_runtime: { + source: "turn", + strategy: "final_output_tool", + providerName: "OpenAI", + modelName: "deepseek-reasoner", + }, + }); + + const runtime = applyModelChangeExecutionRuntime(fromTurnContext, { + type: "model_change", + model: "deepseek-reasoner", + mode: "chat_completions", + }); + + expect(runtime).toMatchObject({ + session_id: "session-deepseek", + source: "model_change", + provider_name: "OpenAI", + model_name: "deepseek-reasoner", + latest_turn_id: "turn-1", + latest_turn_status: "running", + }); + expect(runtime?.output_schema_runtime?.strategy).toBe("final_output_tool"); + expect(getOutputSchemaRuntimeLabel(runtime?.output_schema_runtime)).toBe( + "Final output tool · turn contract", + ); + expect(getExecutionRuntimeSummaryLabel(runtime)).toBe( + "执行模型 OpenAI · deepseek-reasoner", + ); + }); +}); diff --git a/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts b/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts index 10586c12f..73cf00bbb 100644 --- a/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts +++ b/src/components/agent/chat/utils/sessionExecutionRuntime.test.ts @@ -4,11 +4,15 @@ import { getExecutionRuntimeDisplayLabel, applyTurnContextExecutionRuntime, createChatToolPreferencesFromExecutionRuntime, + createSessionRecentPreferencesFromChatToolPreferences, + createSessionRecentTeamSelectionFromTeamDefinition, createSessionModelPreferenceFromExecutionRuntime, + createTeamDefinitionFromExecutionRuntimeRecentTeamSelection, getExecutionRuntimeProviderLabel, getExecutionRuntimeSummaryLabel, getOutputSchemaRuntimeLabel, } from "./sessionExecutionRuntime"; +import { createTeamDefinitionFromPreset } from "./teamDefinitions"; describe("sessionExecutionRuntime", () => { it("应根据 turn_context 事件同步 output schema runtime", () => { @@ -141,4 +145,97 @@ describe("sessionExecutionRuntime", () => { subagent: true, }); }); + + it("应把工具偏好转换成 session recent_preferences 请求载荷", () => { + expect( + createSessionRecentPreferencesFromChatToolPreferences({ + webSearch: true, + thinking: false, + task: true, + subagent: false, + }), + ).toEqual({ + webSearch: true, + thinking: false, + task: true, + subagent: false, + }); + }); + + it("应从 execution runtime 的 recent_team_selection 还原自定义 Team", () => { + expect( + createTeamDefinitionFromExecutionRuntimeRecentTeamSelection({ + disabled: false, + theme: "general", + preferredTeamPresetId: "code-triage-team", + selectedTeamId: "custom-team-1", + selectedTeamSource: "custom", + selectedTeamLabel: "前端联调团队", + selectedTeamDescription: "分析、实现、验证三段式推进。", + selectedTeamRoles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题与影响范围。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration"], + }, + ], + }), + ).toEqual({ + id: "custom-team-1", + source: "custom", + label: "前端联调团队", + description: "分析、实现、验证三段式推进。", + theme: "general", + presetId: "code-triage-team", + roles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题与影响范围。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration"], + }, + ], + updatedAt: expect.any(Number), + }); + }); + + it("应把 TeamDefinition 转成 session recent_team_selection 请求载荷", () => { + const builtinTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ); + + expect( + createSessionRecentTeamSelectionFromTeamDefinition(builtinTeam, "general"), + ).toEqual({ + disabled: false, + theme: "general", + preferredTeamPresetId: "code-triage-team", + selectedTeamId: "code-triage-team", + selectedTeamSource: "builtin", + selectedTeamLabel: "代码排障团队", + selectedTeamDescription: builtinTeam?.description, + selectedTeamSummary: expect.any(String), + selectedTeamRoles: expect.arrayContaining([ + expect.objectContaining({ + id: "explorer", + label: "分析", + profileId: "code-explorer", + }), + ]), + }); + }); + + it("空 Team 应转换成显式 disabled recent_team_selection", () => { + expect( + createSessionRecentTeamSelectionFromTeamDefinition(null, "general"), + ).toEqual({ + disabled: true, + theme: "general", + }); + }); }); diff --git a/src/components/agent/chat/utils/sessionExecutionRuntime.ts b/src/components/agent/chat/utils/sessionExecutionRuntime.ts index f2d09cf32..11ba2ff9c 100644 --- a/src/components/agent/chat/utils/sessionExecutionRuntime.ts +++ b/src/components/agent/chat/utils/sessionExecutionRuntime.ts @@ -2,6 +2,8 @@ import { getProviderLabel } from "@/lib/constants/providerMappings"; import type { AsterSessionExecutionRuntime, AsterSessionExecutionRuntimePreferences, + AsterSessionExecutionRuntimeRecentTeamRole, + AsterSessionExecutionRuntimeRecentTeamSelection, AsterSessionExecutionRuntimeSource, AsterTurnOutputSchemaRuntime, } from "@/lib/api/agentExecutionRuntime"; @@ -12,6 +14,12 @@ import type { } from "@/lib/api/agentProtocol"; import type { SessionModelPreference } from "../hooks/agentChatShared"; import type { ChatToolPreferences } from "./chatToolPreferences"; +import { + buildTeamDefinitionSummary, + createTeamDefinitionFromPreset, + normalizeTeamDefinition, + type TeamDefinition, +} from "./teamDefinitions"; function mergeExecutionRuntime( current: AsterSessionExecutionRuntime | null, @@ -29,6 +37,17 @@ function mergeExecutionRuntime( updates.output_schema_runtime ?? current?.output_schema_runtime ?? null; const recentPreferences = updates.recent_preferences ?? current?.recent_preferences ?? null; + const recentTeamSelection = + updates.recent_team_selection ?? current?.recent_team_selection ?? null; + const recentTheme = updates.recent_theme ?? current?.recent_theme ?? null; + const recentSessionMode = + updates.recent_session_mode ?? current?.recent_session_mode ?? null; + const recentGateKey = + updates.recent_gate_key ?? current?.recent_gate_key ?? null; + const recentRunTitle = + updates.recent_run_title ?? current?.recent_run_title ?? null; + const recentContentId = + updates.recent_content_id ?? current?.recent_content_id ?? null; const mode = updates.mode ?? current?.mode ?? null; const latestTurnId = updates.latest_turn_id ?? current?.latest_turn_id ?? null; const latestTurnStatus = @@ -44,7 +63,13 @@ function mergeExecutionRuntime( !modelName && !outputSchemaRuntime && !executionStrategy && - !recentPreferences + !recentPreferences && + !recentTeamSelection && + !recentTheme && + !recentSessionMode && + !recentGateKey && + !recentRunTitle && + !recentContentId ) { return null; } @@ -57,6 +82,12 @@ function mergeExecutionRuntime( execution_strategy: executionStrategy, output_schema_runtime: outputSchemaRuntime, recent_preferences: recentPreferences, + recent_team_selection: recentTeamSelection, + recent_theme: recentTheme, + recent_session_mode: recentSessionMode, + recent_gate_key: recentGateKey, + recent_run_title: recentRunTitle, + recent_content_id: recentContentId, source, mode, latest_turn_id: latestTurnId, @@ -129,6 +160,130 @@ export function createChatToolPreferencesFromExecutionRuntime( }; } +export function createSessionRecentPreferencesFromChatToolPreferences( + preferences: ChatToolPreferences, +): AsterSessionExecutionRuntimePreferences { + return { + webSearch: preferences.webSearch, + thinking: preferences.thinking, + task: preferences.task, + subagent: preferences.subagent, + }; +} + +function normalizeRuntimeText(value?: string | null): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function normalizeRuntimeSkillIds( + value?: string[] | null, +): string[] | undefined { + const skillIds = + value + ?.map((skillId) => normalizeRuntimeText(skillId)) + .filter((skillId): skillId is string => Boolean(skillId)) || []; + return skillIds.length > 0 ? skillIds : undefined; +} + +function createTeamRoleDefinitionsFromRuntimeSelection( + roles?: AsterSessionExecutionRuntimeRecentTeamRole[] | null, +) { + return (roles || []) + .map((role, index) => { + const label = normalizeRuntimeText(role.label) || `角色 ${index + 1}`; + const summary = + normalizeRuntimeText(role.summary) || `${label}负责当前子任务。`; + return { + id: normalizeRuntimeText(role.id) || `runtime-role-${index + 1}`, + label, + summary, + profileId: normalizeRuntimeText(role.profileId) || undefined, + roleKey: normalizeRuntimeText(role.roleKey) || undefined, + skillIds: normalizeRuntimeSkillIds(role.skillIds), + }; + }) + .filter((role) => role.label.trim().length > 0); +} + +export function createTeamDefinitionFromExecutionRuntimeRecentTeamSelection( + selection?: AsterSessionExecutionRuntimeRecentTeamSelection | null, +): TeamDefinition | null { + if (!selection || selection.disabled) { + return null; + } + + const selectedTeamSource = normalizeRuntimeText(selection.selectedTeamSource); + const selectedTeamId = normalizeRuntimeText(selection.selectedTeamId); + const preferredTeamPresetId = normalizeRuntimeText( + selection.preferredTeamPresetId, + ); + + if ( + selectedTeamSource === "builtin" || + (!selectedTeamSource && preferredTeamPresetId) + ) { + return createTeamDefinitionFromPreset( + selectedTeamId || preferredTeamPresetId || "", + ); + } + + const normalizedTeam = normalizeTeamDefinition({ + id: selectedTeamId || undefined, + source: + selectedTeamSource === "ephemeral" + ? "ephemeral" + : selectedTeamSource === "custom" + ? "custom" + : "custom", + label: normalizeRuntimeText(selection.selectedTeamLabel) || "", + description: + normalizeRuntimeText(selection.selectedTeamDescription) || undefined, + theme: normalizeRuntimeText(selection.theme) || undefined, + presetId: preferredTeamPresetId || undefined, + roles: createTeamRoleDefinitionsFromRuntimeSelection( + selection.selectedTeamRoles, + ), + }); + + return normalizedTeam; +} + +export function createSessionRecentTeamSelectionFromTeamDefinition( + team: TeamDefinition | null, + theme?: string | null, +): AsterSessionExecutionRuntimeRecentTeamSelection { + if (!team) { + return { + disabled: true, + theme: normalizeRuntimeText(theme) || undefined, + }; + } + + return { + disabled: false, + theme: normalizeRuntimeText(theme) || normalizeRuntimeText(team.theme), + preferredTeamPresetId: + normalizeRuntimeText(team.presetId) || + (team.source === "builtin" ? normalizeRuntimeText(team.id) : null) || + undefined, + selectedTeamId: normalizeRuntimeText(team.id) || undefined, + selectedTeamSource: team.source, + selectedTeamLabel: normalizeRuntimeText(team.label) || undefined, + selectedTeamDescription: + normalizeRuntimeText(team.description) || undefined, + selectedTeamSummary: buildTeamDefinitionSummary(team) || undefined, + selectedTeamRoles: team.roles.map((role) => ({ + id: normalizeRuntimeText(role.id) || undefined, + label: normalizeRuntimeText(role.label) || undefined, + summary: normalizeRuntimeText(role.summary) || undefined, + profileId: normalizeRuntimeText(role.profileId) || undefined, + roleKey: normalizeRuntimeText(role.roleKey) || undefined, + skillIds: normalizeRuntimeSkillIds(role.skillIds) || undefined, + })), + }; +} + export function applyTurnContextExecutionRuntime( current: AsterSessionExecutionRuntime | null, event: AgentEventTurnContext, diff --git a/src/components/agent/chat/utils/sessionRecovery.test.ts b/src/components/agent/chat/utils/sessionRecovery.test.ts deleted file mode 100644 index fb3f888c3..000000000 --- a/src/components/agent/chat/utils/sessionRecovery.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isValidSessionId, - resolveRestorableSessionId, -} from "./sessionRecovery"; - -describe("isValidSessionId", () => { - it("应拒绝空值与空字符串", () => { - expect(isValidSessionId(null)).toBe(false); - expect(isValidSessionId(undefined)).toBe(false); - expect(isValidSessionId(" ")).toBe(false); - }); - - it("应拒绝已知非法会话 ID", () => { - expect(isValidSessionId("abc/def")).toBe(false); - expect(isValidSessionId("[object Promise]")).toBe(false); - }); - - it("应接受正常会话 ID", () => { - expect(isValidSessionId("session_123")).toBe(true); - expect(isValidSessionId("f65b8b87-9b5b-4312-9cd4-8f55f20cb5dd")).toBe(true); - }); -}); - -describe("resolveRestorableSessionId", () => { - const workspaceMap: Record = { - s1: "ws-a", - s2: "ws-a", - s3: "ws-a", - s4: "ws-b", - }; - - const resolveWorkspaceIdBySessionId = (sessionId: string) => - workspaceMap[sessionId] ?? null; - - const topics = [{ id: "s1" }, { id: "s2" }, { id: "s4" }]; - - it("应优先使用 scoped transient 候选", () => { - const result = resolveRestorableSessionId({ - workspaceId: "ws-a", - topics, - scopedTransientCandidate: "s2", - scopedPersistedCandidate: "s1", - legacyCandidate: "s3", - resolveWorkspaceIdBySessionId, - }); - - expect(result).toBe("s2"); - }); - - it("应在 transient 非法时使用 scoped persisted", () => { - const result = resolveRestorableSessionId({ - workspaceId: "ws-a", - topics, - scopedTransientCandidate: "[object Promise]", - scopedPersistedCandidate: "s1", - legacyCandidate: "s3", - resolveWorkspaceIdBySessionId, - }); - - expect(result).toBe("s1"); - }); - - it("应在 scoped 候选失效时回退到 legacy", () => { - const result = resolveRestorableSessionId({ - workspaceId: "ws-a", - topics, - scopedTransientCandidate: "s4", - scopedPersistedCandidate: "unknown", - legacyCandidate: "s1", - resolveWorkspaceIdBySessionId, - }); - - expect(result).toBe("s1"); - }); - - it("应拒绝跨 workspace 候选并回退到 topics 首项", () => { - const result = resolveRestorableSessionId({ - workspaceId: "ws-a", - topics, - scopedTransientCandidate: "s4", - scopedPersistedCandidate: "s4", - legacyCandidate: "s4", - resolveWorkspaceIdBySessionId, - }); - - expect(result).toBe("s1"); - }); - - it("应在缺失 topics 时返回 null", () => { - const result = resolveRestorableSessionId({ - workspaceId: "ws-a", - topics: [], - scopedTransientCandidate: "s1", - scopedPersistedCandidate: "s1", - legacyCandidate: "s1", - resolveWorkspaceIdBySessionId, - }); - - expect(result).toBeNull(); - }); -}); diff --git a/src/components/agent/chat/utils/sessionRecovery.ts b/src/components/agent/chat/utils/sessionRecovery.ts deleted file mode 100644 index a105cdd53..000000000 --- a/src/components/agent/chat/utils/sessionRecovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -export interface TopicSessionRef { - id: string; -} - -export interface ResolveRestorableSessionIdOptions { - workspaceId: string; - topics: TopicSessionRef[]; - scopedTransientCandidate: string | null; - scopedPersistedCandidate: string | null; - legacyCandidate: string | null; - resolveWorkspaceIdBySessionId: (sessionId: string) => string | null; -} - -export function isValidSessionId( - sessionId: string | null | undefined, -): sessionId is string { - const normalized = sessionId?.trim(); - if (!normalized) { - return false; - } - - if (normalized.includes("/") || normalized.includes("[object Promise]")) { - return false; - } - - return true; -} - -export function resolveRestorableSessionId({ - workspaceId, - topics, - scopedTransientCandidate, - scopedPersistedCandidate, - legacyCandidate, - resolveWorkspaceIdBySessionId, -}: ResolveRestorableSessionIdOptions): string | null { - if (!workspaceId || topics.length === 0) { - return null; - } - - const topicIdSet = new Set(topics.map((topic) => topic.id)); - - const normalizeCandidate = (candidate: string | null): string | null => { - if (!isValidSessionId(candidate)) { - return null; - } - - if (!topicIdSet.has(candidate)) { - return null; - } - - const candidateWorkspaceId = resolveWorkspaceIdBySessionId(candidate); - if (candidateWorkspaceId !== workspaceId) { - return null; - } - - return candidate; - }; - - const scopedTransient = normalizeCandidate(scopedTransientCandidate); - if (scopedTransient) { - return scopedTransient; - } - - const scopedPersisted = normalizeCandidate(scopedPersistedCandidate); - if (scopedPersisted) { - return scopedPersisted; - } - - const legacy = normalizeCandidate(legacyCandidate); - if (legacy) { - return legacy; - } - - const fallback = topics[0]?.id ?? null; - if (!isValidSessionId(fallback)) { - return null; - } - - const fallbackWorkspaceId = resolveWorkspaceIdBySessionId(fallback); - if (fallbackWorkspaceId && fallbackWorkspaceId !== workspaceId) { - return null; - } - - return fallback; -} diff --git a/src/components/agent/chat/utils/streamDiagnostics.test.ts b/src/components/agent/chat/utils/streamDiagnostics.test.ts deleted file mode 100644 index 89d6e1ad7..000000000 --- a/src/components/agent/chat/utils/streamDiagnostics.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createStreamDiagnosticsReporter } from "./streamDiagnostics"; - -const updateCrashContextMock = vi.fn(); - -vi.mock("@/lib/crashReporting", () => ({ - updateCrashContext: (context: unknown) => updateCrashContextMock(context), -})); - -describe("streamDiagnostics", () => { - beforeEach(() => { - updateCrashContextMock.mockClear(); - }); - - it("开始流后应写入基础上下文", () => { - const reporter = createStreamDiagnosticsReporter("useAsterAgentChat"); - reporter.start({ - sessionId: "session-1", - eventName: "agent_stream_1", - assistantMessageId: "assistant-1", - source: "sendMessage", - }); - - expect(updateCrashContextMock).toHaveBeenCalledTimes(1); - expect(updateCrashContextMock.mock.calls[0]?.[0]).toMatchObject({ - agent_stream_diag: expect.objectContaining({ - component: "useAsterAgentChat", - sessionId: "session-1", - eventName: "agent_stream_1", - assistantMessageId: "assistant-1", - state: "streaming", - }), - }); - }); - - it("遇到关键事件应立即刷新上下文", () => { - const reporter = createStreamDiagnosticsReporter("useAsterAgentChat"); - reporter.start({ - sessionId: "session-1", - eventName: "agent_stream_1", - assistantMessageId: "assistant-1", - source: "sendMessage", - }); - - reporter.record({ - type: "tool_start", - tool_id: "tool-1", - tool_name: "WebSearch", - }); - reporter.record({ - type: "tool_end", - tool_id: "tool-1", - result: { - success: true, - output: "ok", - }, - }); - reporter.record({ - type: "final_done", - }); - - const lastCall = updateCrashContextMock.mock.calls.at(-1)?.[0] as { - agent_stream_diag: Record; - }; - expect(lastCall.agent_stream_diag).toMatchObject({ - state: "done", - toolStartCount: 1, - toolEndCount: 1, - finalDoneCount: 1, - lastToolId: "tool-1", - }); - }); - - it("tool_end 缺少 output 时不应抛错", () => { - const reporter = createStreamDiagnosticsReporter("useAsterAgentChat"); - reporter.start({ - sessionId: "session-1", - eventName: "agent_stream_1", - assistantMessageId: "assistant-1", - source: "sendMessage", - }); - - expect(() => - reporter.record({ - type: "tool_end", - tool_id: "tool-1", - result: { - success: false, - output: "", - error: "failed", - }, - }), - ).not.toThrow(); - }); - - it("解析失败时应记录 invalid 事件", () => { - const reporter = createStreamDiagnosticsReporter("useAsterAgentChat"); - reporter.start({ - sessionId: "session-1", - eventName: "agent_stream_1", - assistantMessageId: "assistant-1", - source: "sendMessage", - }); - - reporter.recordInvalidEvent({ foo: "bar" }); - - const snapshot = reporter.getSnapshot(); - expect(snapshot).toMatchObject({ - invalidEventCount: 1, - lastEventType: "invalid", - state: "streaming", - }); - }); -}); diff --git a/src/components/agent/chat/utils/streamDiagnostics.ts b/src/components/agent/chat/utils/streamDiagnostics.ts deleted file mode 100644 index dbb7f3e39..000000000 --- a/src/components/agent/chat/utils/streamDiagnostics.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { AgentEvent } from "@/lib/api/agentProtocol"; -import { updateCrashContext } from "@/lib/crashReporting"; - -const EVENT_PUBLISH_INTERVAL = 20; -const PREVIEW_MAX_CHARS = 240; - -export interface StreamDiagnosticsStartPayload { - sessionId: string; - eventName: string; - assistantMessageId: string; - source: string; -} - -export interface StreamDiagnosticsSnapshot { - source: string; - sessionId: string; - eventName: string; - assistantMessageId: string; - state: "streaming" | "done" | "error"; - startedAt: string; - lastEventAt: string; - totalEvents: number; - invalidEventCount: number; - textDeltaCount: number; - thinkingDeltaCount: number; - toolStartCount: number; - toolEndCount: number; - actionRequiredCount: number; - contextTraceCount: number; - warningCount: number; - doneCount: number; - finalDoneCount: number; - errorCount: number; - maxTextDeltaChars: number; - maxToolOutputChars: number; - maxContextTraceSteps: number; - lastEventType?: string; - lastToolName?: string; - lastToolId?: string; - lastWarningCode?: string; - lastErrorMessage?: string; -} - -function nowIso(): string { - return new Date().toISOString(); -} - -function truncatePreview(value: string | undefined): string | undefined { - if (!value) return value; - if (value.length <= PREVIEW_MAX_CHARS) { - return value; - } - return `${value.slice(0, PREVIEW_MAX_CHARS)}...`; -} - -export function createStreamDiagnosticsReporter(componentName: string) { - let snapshot: StreamDiagnosticsSnapshot | null = null; - let lastPublishedTotalEvents = 0; - - const publish = (force = false) => { - if (!snapshot) return; - if ( - !force && - snapshot.totalEvents - lastPublishedTotalEvents < EVENT_PUBLISH_INTERVAL - ) { - return; - } - lastPublishedTotalEvents = snapshot.totalEvents; - updateCrashContext({ - agent_stream_diag: { - component: componentName, - ...snapshot, - }, - }); - }; - - return { - start(payload: StreamDiagnosticsStartPayload) { - const startedAt = nowIso(); - snapshot = { - source: payload.source, - sessionId: payload.sessionId, - eventName: payload.eventName, - assistantMessageId: payload.assistantMessageId, - state: "streaming", - startedAt, - lastEventAt: startedAt, - totalEvents: 0, - invalidEventCount: 0, - textDeltaCount: 0, - thinkingDeltaCount: 0, - toolStartCount: 0, - toolEndCount: 0, - actionRequiredCount: 0, - contextTraceCount: 0, - warningCount: 0, - doneCount: 0, - finalDoneCount: 0, - errorCount: 0, - maxTextDeltaChars: 0, - maxToolOutputChars: 0, - maxContextTraceSteps: 0, - }; - lastPublishedTotalEvents = 0; - publish(true); - }, - - recordInvalidEvent(payload: unknown) { - if (!snapshot) return; - snapshot.totalEvents += 1; - snapshot.invalidEventCount += 1; - snapshot.lastEventAt = nowIso(); - snapshot.lastEventType = "invalid"; - try { - snapshot.lastErrorMessage = truncatePreview(JSON.stringify(payload)); - } catch { - snapshot.lastErrorMessage = "[unserializable_payload]"; - } - publish(true); - }, - - record(event: AgentEvent) { - if (!snapshot) return; - snapshot.totalEvents += 1; - snapshot.lastEventAt = nowIso(); - snapshot.lastEventType = event.type; - - switch (event.type) { - case "text_delta": { - snapshot.textDeltaCount += 1; - snapshot.maxTextDeltaChars = Math.max( - snapshot.maxTextDeltaChars, - event.text.length, - ); - break; - } - case "thinking_delta": { - snapshot.thinkingDeltaCount += 1; - break; - } - case "tool_start": { - snapshot.toolStartCount += 1; - snapshot.lastToolId = event.tool_id; - snapshot.lastToolName = truncatePreview(event.tool_name); - break; - } - case "tool_end": { - snapshot.toolEndCount += 1; - snapshot.lastToolId = event.tool_id; - const outputText = - typeof event.result.output === "string" ? event.result.output : ""; - snapshot.maxToolOutputChars = Math.max( - snapshot.maxToolOutputChars, - outputText.length, - ); - break; - } - case "action_required": { - snapshot.actionRequiredCount += 1; - break; - } - case "context_trace": { - snapshot.contextTraceCount += 1; - snapshot.maxContextTraceSteps = Math.max( - snapshot.maxContextTraceSteps, - event.steps.length, - ); - break; - } - case "warning": { - snapshot.warningCount += 1; - snapshot.lastWarningCode = event.code; - snapshot.lastErrorMessage = truncatePreview(event.message); - publish(true); - return; - } - case "done": { - snapshot.doneCount += 1; - publish(true); - return; - } - case "final_done": { - snapshot.finalDoneCount += 1; - snapshot.state = "done"; - publish(true); - return; - } - case "error": { - snapshot.errorCount += 1; - snapshot.state = "error"; - snapshot.lastErrorMessage = truncatePreview(event.message); - publish(true); - return; - } - } - - publish(false); - }, - - markError(message: string) { - if (!snapshot) return; - snapshot.state = "error"; - snapshot.lastEventAt = nowIso(); - snapshot.lastEventType = "error"; - snapshot.lastErrorMessage = truncatePreview(message); - publish(true); - }, - - markDone() { - if (!snapshot) return; - snapshot.state = "done"; - snapshot.lastEventAt = nowIso(); - publish(true); - }, - - getSnapshot(): StreamDiagnosticsSnapshot | null { - return snapshot ? { ...snapshot } : null; - }, - }; -} diff --git a/src/components/agent/chat/utils/submitOpRuntimeCompaction.test.ts b/src/components/agent/chat/utils/submitOpRuntimeCompaction.test.ts new file mode 100644 index 000000000..dd0531479 --- /dev/null +++ b/src/components/agent/chat/utils/submitOpRuntimeCompaction.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { buildSubmitOpRuntimeCompaction } from "./submitOpRuntimeCompaction"; + +describe("submitOpRuntimeCompaction", () => { + it("应裁掉已经由 session/runtime 承接的 steady-state 提交字段", () => { + const result = buildSubmitOpRuntimeCompaction({ + requestMetadata: { + harness: { + turn_purpose: "content_review", + preferences: { + web_search: false, + thinking: true, + task: false, + subagent: true, + }, + theme: "social-media", + session_mode: "theme_workbench", + gate_key: "write_mode", + run_title: "社媒初稿", + content_id: "content-social-1", + preferred_team_preset_id: "social-preset", + selected_team_id: "team-social-1", + selected_team_source: "builtin", + selected_team_label: "社媒执行团队", + selected_team_description: "负责选题、写作和校对。", + selected_team_summary: "负责选题、写作和校对。", + selected_team_roles: [ + { + id: "role-1", + label: "写手", + summary: "负责起草正文", + profile_id: "writer", + role_key: "writer", + skill_ids: ["draft"], + }, + ], + }, + }, + executionRuntime: { + session_id: "session-social-1", + source: "runtime_snapshot", + provider_selector: "openai", + model_name: "gpt-4.1", + execution_strategy: "react", + recent_preferences: { + webSearch: false, + thinking: true, + task: false, + subagent: true, + }, + recent_team_selection: { + disabled: false, + preferredTeamPresetId: "social-preset", + selectedTeamId: "team-social-1", + selectedTeamSource: "builtin", + selectedTeamLabel: "社媒执行团队", + selectedTeamDescription: "负责选题、写作和校对。", + selectedTeamSummary: "负责选题、写作和校对。", + selectedTeamRoles: [ + { + id: "role-1", + label: "写手", + summary: "负责起草正文", + profileId: "writer", + roleKey: "writer", + skillIds: ["draft"], + }, + ], + }, + recent_theme: "social-media", + recent_session_mode: "theme_workbench", + recent_gate_key: "write_mode", + recent_run_title: "社媒初稿", + recent_content_id: "content-social-1", + }, + syncedRecentPreferences: { + webSearch: false, + thinking: true, + task: false, + subagent: true, + }, + syncedSessionModelPreference: { + providerType: "openai", + model: "gpt-4.1", + }, + syncedExecutionStrategy: "react", + effectiveExecutionStrategy: "react", + effectiveProviderType: "openai", + effectiveModel: "gpt-4.1", + webSearch: false, + thinking: true, + }); + + expect(result.shouldSubmitProviderPreference).toBe(false); + expect(result.shouldSubmitModelPreference).toBe(false); + expect(result.shouldSubmitExecutionStrategy).toBe(false); + expect(result.shouldSubmitWebSearch).toBe(false); + expect(result.shouldSubmitThinking).toBe(false); + expect(result.metadata).toEqual({ + harness: { + turn_purpose: "content_review", + }, + }); + }); + + it("应保留尚未同步到 runtime 的显式变更", () => { + const result = buildSubmitOpRuntimeCompaction({ + requestMetadata: { + harness: { + preferences: { + thinking: true, + }, + theme: "social-media", + session_mode: "theme_workbench", + gate_key: "publish_confirm", + run_title: "发布确认", + content_id: "content-social-1", + }, + }, + executionRuntime: { + session_id: "session-social-1", + source: "runtime_snapshot", + provider_selector: "openai", + model_name: "gpt-4.1", + execution_strategy: "react", + recent_preferences: { + webSearch: false, + thinking: false, + task: false, + subagent: false, + }, + recent_theme: "social-media", + recent_session_mode: "theme_workbench", + recent_gate_key: "write_mode", + recent_run_title: "社媒初稿", + recent_content_id: "content-social-1", + }, + syncedRecentPreferences: { + webSearch: false, + thinking: false, + task: false, + subagent: false, + }, + syncedSessionModelPreference: { + providerType: "openai", + model: "gpt-4.1", + }, + syncedExecutionStrategy: "react", + effectiveExecutionStrategy: "code_orchestrated", + effectiveProviderType: "openai", + effectiveModel: "gpt-5", + modelOverride: "gpt-5", + webSearch: false, + thinking: true, + }); + + expect(result.shouldSubmitProviderPreference).toBe(false); + expect(result.shouldSubmitModelPreference).toBe(true); + expect(result.shouldSubmitExecutionStrategy).toBe(true); + expect(result.shouldSubmitWebSearch).toBe(false); + expect(result.shouldSubmitThinking).toBe(true); + expect(result.metadata).toEqual({ + harness: { + preferences: { + thinking: true, + }, + gate_key: "publish_confirm", + run_title: "发布确认", + }, + }); + }); +}); diff --git a/src/components/agent/chat/utils/submitOpRuntimeCompaction.ts b/src/components/agent/chat/utils/submitOpRuntimeCompaction.ts new file mode 100644 index 000000000..3bed4662e --- /dev/null +++ b/src/components/agent/chat/utils/submitOpRuntimeCompaction.ts @@ -0,0 +1,609 @@ +import type { + AsterExecutionStrategy, + AsterSessionExecutionRuntime, + AsterSessionExecutionRuntimeRecentTeamSelection, +} from "@/lib/api/agentRuntime"; +import type { SessionModelPreference } from "../hooks/agentChatShared"; +import type { ChatToolPreferences } from "./chatToolPreferences"; + +const HARNESS_WEB_SEARCH_PREFERENCE_KEYS = [ + "web_search", + "webSearch", +] as const; +const HARNESS_THINKING_PREFERENCE_KEYS = [ + "thinking", + "thinking_enabled", + "thinkingEnabled", +] as const; +const HARNESS_TASK_PREFERENCE_KEYS = ["task", "task_mode", "taskMode"] as const; +const HARNESS_SUBAGENT_PREFERENCE_KEYS = [ + "subagent", + "subagent_mode", + "subagentMode", +] as const; +const HARNESS_CONTENT_ID_KEYS = ["content_id", "contentId"] as const; +const HARNESS_THEME_KEYS = ["theme", "harness_theme", "harnessTheme"] as const; +const HARNESS_SESSION_MODE_KEYS = ["session_mode", "sessionMode"] as const; +const HARNESS_GATE_KEY_KEYS = ["gate_key", "gateKey"] as const; +const HARNESS_RUN_TITLE_KEYS = ["run_title", "runTitle", "title"] as const; +const HARNESS_TEAM_SELECTION_PRESET_KEYS = [ + "preferred_team_preset_id", + "preferredTeamPresetId", +] as const; +const HARNESS_TEAM_SELECTION_ID_KEYS = [ + "selected_team_id", + "selectedTeamId", +] as const; +const HARNESS_TEAM_SELECTION_SOURCE_KEYS = [ + "selected_team_source", + "selectedTeamSource", +] as const; +const HARNESS_TEAM_SELECTION_LABEL_KEYS = [ + "selected_team_label", + "selectedTeamLabel", +] as const; +const HARNESS_TEAM_SELECTION_DESCRIPTION_KEYS = [ + "selected_team_description", + "selectedTeamDescription", +] as const; +const HARNESS_TEAM_SELECTION_SUMMARY_KEYS = [ + "selected_team_summary", + "selectedTeamSummary", +] as const; +const HARNESS_TEAM_SELECTION_ROLE_KEYS = [ + "selected_team_roles", + "selectedTeamRoles", +] as const; +const HARNESS_TEAM_SELECTION_KEYS = [ + ...HARNESS_TEAM_SELECTION_PRESET_KEYS, + ...HARNESS_TEAM_SELECTION_ID_KEYS, + ...HARNESS_TEAM_SELECTION_SOURCE_KEYS, + ...HARNESS_TEAM_SELECTION_LABEL_KEYS, + ...HARNESS_TEAM_SELECTION_DESCRIPTION_KEYS, + ...HARNESS_TEAM_SELECTION_SUMMARY_KEYS, + ...HARNESS_TEAM_SELECTION_ROLE_KEYS, +] as const; + +function normalizeRuntimeIdentifier(value?: string | null): string { + return value?.trim().toLowerCase() || ""; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function omitHarnessPreferenceFromRequestMetadata( + requestMetadata: Record | undefined, + keys: readonly string[], +): Record | undefined { + if (!requestMetadata) { + return requestMetadata; + } + + const nestedHarness = requestMetadata.harness; + const usesNestedHarness = isPlainRecord(nestedHarness); + const harness = usesNestedHarness + ? (nestedHarness as Record) + : requestMetadata; + const preferences = harness.preferences; + if (!isPlainRecord(preferences)) { + return requestMetadata; + } + + let changed = false; + const nextPreferences = { ...preferences }; + for (const key of keys) { + if (!(key in nextPreferences)) { + continue; + } + delete nextPreferences[key]; + changed = true; + } + + if (!changed) { + return requestMetadata; + } + + const nextHarness = { ...harness }; + if (Object.keys(nextPreferences).length === 0) { + delete nextHarness.preferences; + } else { + nextHarness.preferences = nextPreferences; + } + + if (!usesNestedHarness) { + return nextHarness; + } + + return { + ...requestMetadata, + harness: nextHarness, + }; +} + +function readHarnessPreferenceFromRequestMetadata( + requestMetadata: Record | undefined, + keys: readonly string[], +): boolean | null { + if (!requestMetadata) { + return null; + } + + const nestedHarness = requestMetadata.harness; + const harness = isPlainRecord(nestedHarness) + ? (nestedHarness as Record) + : requestMetadata; + const preferences = harness.preferences; + if (!isPlainRecord(preferences)) { + return null; + } + + for (const key of keys) { + if (typeof preferences[key] === "boolean") { + return preferences[key] as boolean; + } + } + + return null; +} + +function readHarnessStringFromRequestMetadata( + requestMetadata: Record | undefined, + keys: readonly string[], +): string | null { + if (!requestMetadata) { + return null; + } + + const nestedHarness = requestMetadata.harness; + const harness = isPlainRecord(nestedHarness) + ? (nestedHarness as Record) + : requestMetadata; + + for (const key of keys) { + if (typeof harness[key] === "string" && harness[key].trim()) { + return harness[key] as string; + } + } + + return null; +} + +function readHarnessArrayFromRequestMetadata( + requestMetadata: Record | undefined, + keys: readonly string[], +): unknown[] | null { + if (!requestMetadata) { + return null; + } + + const nestedHarness = requestMetadata.harness; + const harness = isPlainRecord(nestedHarness) + ? (nestedHarness as Record) + : requestMetadata; + + for (const key of keys) { + if (Array.isArray(harness[key])) { + return harness[key] as unknown[]; + } + } + + return null; +} + +function omitHarnessFieldsFromRequestMetadata( + requestMetadata: Record | undefined, + keys: readonly string[], +): Record | undefined { + if (!requestMetadata) { + return requestMetadata; + } + + const nestedHarness = requestMetadata.harness; + const usesNestedHarness = isPlainRecord(nestedHarness); + const harness = usesNestedHarness + ? { ...(nestedHarness as Record) } + : { ...requestMetadata }; + let changed = false; + + for (const key of keys) { + if (!(key in harness)) { + continue; + } + delete harness[key]; + changed = true; + } + + if (!changed) { + return requestMetadata; + } + + if (usesNestedHarness) { + if (Object.keys(harness).length === 0) { + const { harness: _removedHarness, ...rest } = requestMetadata; + return Object.keys(rest).length > 0 ? rest : undefined; + } + return { + ...requestMetadata, + harness, + }; + } + + return Object.keys(harness).length > 0 ? harness : undefined; +} + +function normalizeComparableText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function normalizeComparableSkillIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeComparableTeamRole( + role: unknown, +): Record | null { + if (!isPlainRecord(role)) { + return null; + } + + return { + id: normalizeComparableText(role["id"]), + label: normalizeComparableText(role["label"]), + summary: normalizeComparableText(role["summary"]), + profileId: normalizeComparableText( + role["profile_id"] ?? role["profileId"], + ), + roleKey: normalizeComparableText(role["role_key"] ?? role["roleKey"]), + skillIds: normalizeComparableSkillIds( + role["skill_ids"] ?? role["skillIds"], + ), + }; +} + +function createComparableRequestTeamSelection( + requestMetadata: Record | undefined, +): Record | null { + const roles = readHarnessArrayFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_ROLE_KEYS, + ); + const normalizedRoles = roles + ?.map((role) => normalizeComparableTeamRole(role)) + .filter((role): role is Record => Boolean(role)); + const comparableSelection = { + preferredTeamPresetId: normalizeComparableText( + readHarnessStringFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_PRESET_KEYS, + ), + ), + selectedTeamId: normalizeComparableText( + readHarnessStringFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_ID_KEYS, + ), + ), + selectedTeamSource: normalizeComparableText( + readHarnessStringFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_SOURCE_KEYS, + ), + ), + selectedTeamLabel: normalizeComparableText( + readHarnessStringFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_LABEL_KEYS, + ), + ), + selectedTeamDescription: normalizeComparableText( + readHarnessStringFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_DESCRIPTION_KEYS, + ), + ), + selectedTeamSummary: normalizeComparableText( + readHarnessStringFromRequestMetadata( + requestMetadata, + HARNESS_TEAM_SELECTION_SUMMARY_KEYS, + ), + ), + selectedTeamRoles: + normalizedRoles && normalizedRoles.length > 0 ? normalizedRoles : null, + }; + + return Object.values(comparableSelection).some((value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + return value !== null; + }) + ? comparableSelection + : null; +} + +function createComparableRuntimeTeamSelection( + selection?: AsterSessionExecutionRuntimeRecentTeamSelection | null, +): Record | null { + if (!selection || selection.disabled) { + return null; + } + + const normalizedRoles = selection.selectedTeamRoles + ?.map((role) => normalizeComparableTeamRole(role)) + .filter((role): role is Record => Boolean(role)); + + return { + preferredTeamPresetId: normalizeComparableText( + selection.preferredTeamPresetId, + ), + selectedTeamId: normalizeComparableText(selection.selectedTeamId), + selectedTeamSource: normalizeComparableText(selection.selectedTeamSource), + selectedTeamLabel: normalizeComparableText(selection.selectedTeamLabel), + selectedTeamDescription: normalizeComparableText( + selection.selectedTeamDescription, + ), + selectedTeamSummary: normalizeComparableText(selection.selectedTeamSummary), + selectedTeamRoles: + normalizedRoles && normalizedRoles.length > 0 ? normalizedRoles : null, + }; +} + +export interface BuildSubmitOpRuntimeCompactionOptions { + requestMetadata?: Record; + executionRuntime?: AsterSessionExecutionRuntime | null; + syncedRecentPreferences?: ChatToolPreferences | null; + syncedSessionModelPreference?: SessionModelPreference | null; + syncedExecutionStrategy?: AsterExecutionStrategy | null; + effectiveExecutionStrategy: AsterExecutionStrategy; + effectiveProviderType: string; + effectiveModel: string; + modelOverride?: string; + webSearch?: boolean; + thinking?: boolean; +} + +export interface SubmitOpRuntimeCompactionResult { + metadata?: Record; + shouldSubmitProviderPreference: boolean; + shouldSubmitModelPreference: boolean; + shouldSubmitExecutionStrategy: boolean; + shouldSubmitWebSearch: boolean; + shouldSubmitThinking: boolean; +} + +export function buildSubmitOpRuntimeCompaction( + options: BuildSubmitOpRuntimeCompactionOptions, +): SubmitOpRuntimeCompactionResult { + const { + requestMetadata, + executionRuntime, + syncedRecentPreferences, + syncedSessionModelPreference, + syncedExecutionStrategy, + effectiveExecutionStrategy, + effectiveProviderType, + effectiveModel, + modelOverride, + webSearch, + thinking, + } = options; + + const syncedProviderSelector = + syncedSessionModelPreference?.providerType?.trim() || null; + const syncedModelName = syncedSessionModelPreference?.model?.trim() || null; + const runtimeProviderSelector = + executionRuntime?.provider_selector?.trim() || + executionRuntime?.provider_name?.trim() || + null; + const runtimeModelName = executionRuntime?.model_name?.trim() || null; + const knownProviderSelector = syncedProviderSelector || runtimeProviderSelector; + const knownModelName = syncedModelName || runtimeModelName; + const shouldSubmitProviderPreference = + !knownProviderSelector || + normalizeRuntimeIdentifier(knownProviderSelector) !== + normalizeRuntimeIdentifier(effectiveProviderType); + const shouldSubmitModelPreference = + Boolean(modelOverride?.trim()) || + shouldSubmitProviderPreference || + !knownModelName || + normalizeRuntimeIdentifier(knownModelName) !== + normalizeRuntimeIdentifier(effectiveModel); + + const knownExecutionStrategy = + syncedExecutionStrategy?.trim() || + executionRuntime?.execution_strategy?.trim() || + null; + const shouldSubmitExecutionStrategy = + !knownExecutionStrategy || + normalizeRuntimeIdentifier(knownExecutionStrategy) !== + normalizeRuntimeIdentifier(effectiveExecutionStrategy); + + const knownWebSearchPreference = + typeof syncedRecentPreferences?.webSearch === "boolean" + ? syncedRecentPreferences.webSearch + : typeof executionRuntime?.recent_preferences?.webSearch === "boolean" + ? executionRuntime.recent_preferences.webSearch + : null; + const knownTaskPreference = + typeof syncedRecentPreferences?.task === "boolean" + ? syncedRecentPreferences.task + : typeof executionRuntime?.recent_preferences?.task === "boolean" + ? executionRuntime.recent_preferences.task + : null; + const knownSubagentPreference = + typeof syncedRecentPreferences?.subagent === "boolean" + ? syncedRecentPreferences.subagent + : typeof executionRuntime?.recent_preferences?.subagent === "boolean" + ? executionRuntime.recent_preferences.subagent + : null; + const knownThinkingPreference = + typeof syncedRecentPreferences?.thinking === "boolean" + ? syncedRecentPreferences.thinking + : typeof executionRuntime?.recent_preferences?.thinking === "boolean" + ? executionRuntime.recent_preferences.thinking + : null; + const shouldSubmitWebSearch = + typeof webSearch === "boolean" && + (knownWebSearchPreference === null || + knownWebSearchPreference !== webSearch); + const shouldSubmitThinking = + typeof thinking === "boolean" && + (knownThinkingPreference === null || + knownThinkingPreference !== thinking); + const requestTaskPreference = readHarnessPreferenceFromRequestMetadata( + requestMetadata, + HARNESS_TASK_PREFERENCE_KEYS, + ); + const requestThinkingPreference = readHarnessPreferenceFromRequestMetadata( + requestMetadata, + HARNESS_THINKING_PREFERENCE_KEYS, + ); + const requestSubagentPreference = readHarnessPreferenceFromRequestMetadata( + requestMetadata, + HARNESS_SUBAGENT_PREFERENCE_KEYS, + ); + let metadata = shouldSubmitWebSearch + ? requestMetadata + : omitHarnessPreferenceFromRequestMetadata( + requestMetadata, + HARNESS_WEB_SEARCH_PREFERENCE_KEYS, + ); + + if ( + requestThinkingPreference !== null && + knownThinkingPreference !== null && + knownThinkingPreference === requestThinkingPreference + ) { + metadata = omitHarnessPreferenceFromRequestMetadata( + metadata, + HARNESS_THINKING_PREFERENCE_KEYS, + ); + } + if ( + requestTaskPreference !== null && + knownTaskPreference !== null && + knownTaskPreference === requestTaskPreference + ) { + metadata = omitHarnessPreferenceFromRequestMetadata( + metadata, + HARNESS_TASK_PREFERENCE_KEYS, + ); + } + if ( + requestSubagentPreference !== null && + knownSubagentPreference !== null && + knownSubagentPreference === requestSubagentPreference + ) { + metadata = omitHarnessPreferenceFromRequestMetadata( + metadata, + HARNESS_SUBAGENT_PREFERENCE_KEYS, + ); + } + + if ( + JSON.stringify(createComparableRequestTeamSelection(metadata)) === + JSON.stringify( + createComparableRuntimeTeamSelection( + executionRuntime?.recent_team_selection ?? null, + ), + ) + ) { + metadata = omitHarnessFieldsFromRequestMetadata( + metadata, + HARNESS_TEAM_SELECTION_KEYS, + ); + } + + const requestContentId = normalizeComparableText( + readHarnessStringFromRequestMetadata(metadata, HARNESS_CONTENT_ID_KEYS), + ); + const knownRecentContentId = normalizeComparableText( + executionRuntime?.recent_content_id, + ); + if ( + requestContentId !== null && + knownRecentContentId !== null && + requestContentId === knownRecentContentId + ) { + metadata = omitHarnessFieldsFromRequestMetadata(metadata, HARNESS_CONTENT_ID_KEYS); + } + + const requestTheme = normalizeComparableText( + readHarnessStringFromRequestMetadata(metadata, HARNESS_THEME_KEYS), + ); + const knownRecentTheme = normalizeComparableText(executionRuntime?.recent_theme); + if ( + requestTheme !== null && + knownRecentTheme !== null && + requestTheme === knownRecentTheme + ) { + metadata = omitHarnessFieldsFromRequestMetadata(metadata, HARNESS_THEME_KEYS); + } + + const requestSessionMode = normalizeComparableText( + readHarnessStringFromRequestMetadata(metadata, HARNESS_SESSION_MODE_KEYS), + ); + const knownRecentSessionMode = normalizeComparableText( + executionRuntime?.recent_session_mode, + ); + if ( + requestSessionMode !== null && + knownRecentSessionMode !== null && + requestSessionMode === knownRecentSessionMode + ) { + metadata = omitHarnessFieldsFromRequestMetadata( + metadata, + HARNESS_SESSION_MODE_KEYS, + ); + } + + const requestGateKey = normalizeComparableText( + readHarnessStringFromRequestMetadata(metadata, HARNESS_GATE_KEY_KEYS), + ); + const knownRecentGateKey = normalizeComparableText( + executionRuntime?.recent_gate_key, + ); + if ( + requestGateKey !== null && + knownRecentGateKey !== null && + requestGateKey === knownRecentGateKey + ) { + metadata = omitHarnessFieldsFromRequestMetadata(metadata, HARNESS_GATE_KEY_KEYS); + } + + const requestRunTitle = normalizeComparableText( + readHarnessStringFromRequestMetadata(metadata, HARNESS_RUN_TITLE_KEYS), + ); + const knownRecentRunTitle = normalizeComparableText( + executionRuntime?.recent_run_title, + ); + if ( + requestRunTitle !== null && + knownRecentRunTitle !== null && + requestRunTitle === knownRecentRunTitle + ) { + metadata = omitHarnessFieldsFromRequestMetadata(metadata, HARNESS_RUN_TITLE_KEYS); + } + + return { + metadata, + shouldSubmitProviderPreference, + shouldSubmitModelPreference, + shouldSubmitExecutionStrategy, + shouldSubmitWebSearch, + shouldSubmitThinking, + }; +} diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx index 1da0b4237..ac881fbdd 100644 --- a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx @@ -70,13 +70,20 @@ interface MountedShell { const mountedShells: MountedShell[] = []; -function createArtifactDocumentArtifact(): Artifact { +function createArtifactDocumentArtifact( + options: { + status?: "ready" | "archived"; + currentVersionStatus?: "ready" | "archived"; + } = {}, +): Artifact { + const status = options.status || "ready"; + const currentVersionStatus = options.currentVersionStatus || status; const content = JSON.stringify({ schemaVersion: "artifact_document.v1", artifactId: "artifact-document:demo", kind: "analysis", title: "董事会季度复盘", - status: "ready", + status, language: "zh-CN", summary: "需要优先补齐来源与版本线索。", blocks: [ @@ -134,7 +141,7 @@ function createArtifactDocumentArtifact(): Artifact { versionNo: 2, title: "董事会季度复盘", summary: "补齐来源与版本信息", - status: "ready", + status: currentVersionStatus, }, ], }, @@ -267,7 +274,10 @@ function renderShell( return container; } -function setTextControlValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) { +function setTextControlValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { const descriptor = Object.getOwnPropertyDescriptor( element instanceof HTMLInputElement ? HTMLInputElement.prototype @@ -322,9 +332,9 @@ describe("ArtifactWorkbenchShell", () => { expect(container.textContent).toContain("差异"); expect(container.textContent).toContain("更新 block 内容"); - const sourcesTrigger = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("来源"), - ); + const sourcesTrigger = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("来源")); expect(sourcesTrigger).not.toBeUndefined(); await act(async () => { @@ -343,9 +353,9 @@ describe("ArtifactWorkbenchShell", () => { await Promise.resolve(); }); - const sourcesTrigger = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("来源"), - ); + const sourcesTrigger = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("来源")); expect(sourcesTrigger).not.toBeUndefined(); await act(async () => { @@ -377,9 +387,9 @@ describe("ArtifactWorkbenchShell", () => { await Promise.resolve(); }); - const diffJumpButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("跳到 block"), - ); + const diffJumpButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("跳到 block")); expect(diffJumpButton).not.toBeUndefined(); await act(async () => { @@ -411,9 +421,9 @@ describe("ArtifactWorkbenchShell", () => { await Promise.resolve(); }); - const bodyBlockTrigger = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("正文块 1"), - ); + const bodyBlockTrigger = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("正文块 1")); expect(bodyBlockTrigger).not.toBeUndefined(); await act(async () => { @@ -466,6 +476,41 @@ describe("ArtifactWorkbenchShell", () => { ); }); + it("已归档文档不应继续展示编辑页签", async () => { + const container = renderShell( + createArtifactDocumentArtifact({ + status: "archived", + currentVersionStatus: "archived", + }), + { + onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined), + }, + ); + + await act(async () => { + await Promise.resolve(); + }); + + const editTrigger = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("编辑"), + ); + expect(editTrigger).toBeUndefined(); + + const overviewTrigger = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("概览")); + expect(overviewTrigger).not.toBeUndefined(); + + await act(async () => { + overviewTrigger?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + }); + + expect(container.textContent).toContain("已归档"); + }); + it("应支持在 workbench 中编辑结构化摘要块并回写 highlights", async () => { const handleSaveArtifactDocument = vi.fn().mockResolvedValue(undefined); const container = renderShell(createStructuredEditableArtifact(), { @@ -564,9 +609,9 @@ describe("ArtifactWorkbenchShell", () => { await Promise.resolve(); }); - const bodyBlockTrigger = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("正文块 1"), - ); + const bodyBlockTrigger = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("正文块 1")); expect(bodyBlockTrigger).not.toBeUndefined(); await act(async () => { @@ -609,9 +654,9 @@ describe("ArtifactWorkbenchShell", () => { await Promise.resolve(); }); - const calloutTrigger = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("风险提示"), - ); + const calloutTrigger = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("风险提示")); expect(calloutTrigger).not.toBeUndefined(); await act(async () => { diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx index 008634817..c1db8a700 100644 --- a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx @@ -1316,7 +1316,10 @@ export const ArtifactWorkbenchShell: React.FC = mem [artifact, document, threadItems], ); const canEditDocument = Boolean( - document && onSaveArtifactDocument && editableBlocks.length > 0, + document && + document.status !== "archived" && + onSaveArtifactDocument && + editableBlocks.length > 0, ); const defaultInspectorTab = currentVersionDiff?.changedBlocks.length diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchToolbarActions.test.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchToolbarActions.test.tsx new file mode 100644 index 000000000..78c5bef6e --- /dev/null +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchToolbarActions.test.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ArtifactWorkbenchToolbarActions } from "./ArtifactWorkbenchToolbarActions"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderActions( + overrides: Partial< + React.ComponentProps + > = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const props: React.ComponentProps = { + showSaveToProject: true, + saveToProjectDisabled: false, + isSavingToProject: false, + onSaveToProject: vi.fn(), + onExportJson: vi.fn(), + onExportMarkdown: vi.fn(), + showArchiveToggle: true, + isUpdatingArchive: false, + archiveLabel: "归档", + onToggleArchive: vi.fn(), + ...overrides, + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { + container, + props, + }; +} + +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.restoreAllMocks(); +}); + +describe("ArtifactWorkbenchToolbarActions", () => { + it("应渲染项目复用、导出与归档按钮", () => { + const { container } = renderActions(); + + expect( + container.querySelector( + '[data-testid="artifact-workbench-save-to-project"]', + ), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="artifact-workbench-export-markdown"]', + ), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="artifact-workbench-export-json"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="artifact-workbench-archive-toggle"]', + ), + ).not.toBeNull(); + }); + + it("点击按钮时应回调对应动作", async () => { + const onSaveToProject = vi.fn().mockResolvedValue(undefined); + const onExportJson = vi.fn().mockResolvedValue(undefined); + const onExportMarkdown = vi.fn().mockResolvedValue(undefined); + const onToggleArchive = vi.fn().mockResolvedValue(undefined); + const { container } = renderActions({ + onSaveToProject, + onExportJson, + onExportMarkdown, + onToggleArchive, + }); + + const click = async (testId: string) => { + const element = container.querySelector( + `[data-testid="${testId}"]`, + ) as HTMLButtonElement | null; + expect(element).not.toBeNull(); + await act(async () => { + element?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + }; + + await click("artifact-workbench-save-to-project"); + await click("artifact-workbench-export-markdown"); + await click("artifact-workbench-export-json"); + await click("artifact-workbench-archive-toggle"); + + expect(onSaveToProject).toHaveBeenCalledTimes(1); + expect(onExportMarkdown).toHaveBeenCalledTimes(1); + expect(onExportJson).toHaveBeenCalledTimes(1); + expect(onToggleArchive).toHaveBeenCalledTimes(1); + }); + + it("保存中或处理中时应更新按钮文案", () => { + const { container } = renderActions({ + isSavingToProject: true, + isUpdatingArchive: true, + archiveLabel: "取消归档", + }); + + expect(container.textContent).toContain("保存中"); + expect(container.textContent).toContain("处理中"); + }); +}); diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchToolbarActions.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchToolbarActions.tsx new file mode 100644 index 000000000..943eaad80 --- /dev/null +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchToolbarActions.tsx @@ -0,0 +1,117 @@ +import React, { memo } from "react"; +import { Code, FileClock, Save, ScrollText } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ActionButtonProps { + icon: React.ReactNode; + label: string; + onClick?: () => void; + disabled?: boolean; + tone?: "default" | "accent" | "warning"; + testId: string; +} + +function resolveToneClassName(tone: ActionButtonProps["tone"]): string { + switch (tone) { + case "accent": + return "border-sky-200 bg-sky-50 text-sky-700 hover:border-sky-300 hover:bg-sky-100"; + case "warning": + return "border-amber-200 bg-amber-50 text-amber-700 hover:border-amber-300 hover:bg-amber-100"; + default: + return "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900"; + } +} + +const ActionButton: React.FC = memo( + ({ icon, label, onClick, disabled = false, tone = "default", testId }) => ( + + ), +); +ActionButton.displayName = "ActionButton"; + +export interface ArtifactWorkbenchToolbarActionsProps { + showSaveToProject: boolean; + saveToProjectDisabled: boolean; + isSavingToProject: boolean; + onSaveToProject: () => void; + onExportJson: () => void; + onExportMarkdown: () => void; + showArchiveToggle: boolean; + isUpdatingArchive: boolean; + archiveLabel: string; + onToggleArchive: () => void; +} + +export const ArtifactWorkbenchToolbarActions: React.FC = + memo( + ({ + showSaveToProject, + saveToProjectDisabled, + isSavingToProject, + onSaveToProject, + onExportJson, + onExportMarkdown, + showArchiveToggle, + isUpdatingArchive, + archiveLabel, + onToggleArchive, + }) => ( +
+ {showSaveToProject ? ( + } + label={isSavingToProject ? "保存中" : "项目复用"} + onClick={() => { + void onSaveToProject(); + }} + disabled={saveToProjectDisabled || isSavingToProject} + tone="accent" + /> + ) : null} + } + label="导出 MD" + onClick={() => { + void onExportMarkdown(); + }} + /> + } + label="导出 JSON" + onClick={() => { + void onExportJson(); + }} + /> + {showArchiveToggle ? ( + } + label={isUpdatingArchive ? "处理中" : archiveLabel} + onClick={() => { + void onToggleArchive(); + }} + disabled={isUpdatingArchive} + tone="warning" + /> + ) : null} +
+ ), + ); +ArtifactWorkbenchToolbarActions.displayName = "ArtifactWorkbenchToolbarActions"; diff --git a/src/components/agent/chat/workspace/artifactWorkbenchActions.test.ts b/src/components/agent/chat/workspace/artifactWorkbenchActions.test.ts new file mode 100644 index 000000000..9809fefb6 --- /dev/null +++ b/src/components/agent/chat/workspace/artifactWorkbenchActions.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; +import type { Artifact } from "@/lib/artifact/types"; +import { + resolveArtifactWorkbenchJsonFilename, + resolveArtifactWorkbenchMarkdownFilename, + serializeArtifactDocumentToMarkdown, + updateArtifactDocumentStatus, +} from "./artifactWorkbenchActions"; + +function createArtifact(): Artifact { + return { + id: "artifact-1", + type: "document", + title: "q1-review.artifact.json", + content: "", + status: "complete", + meta: { + filePath: ".lime/artifacts/thread-1/q1-review.artifact.json", + filename: "q1-review.artifact.json", + language: "json", + }, + position: { start: 0, end: 0 }, + createdAt: 1, + updatedAt: 1, + }; +} + +function createDocument(): ArtifactDocumentV1 { + return { + schemaVersion: "artifact_document.v1", + artifactId: "artifact-document:q1-review", + kind: "report", + title: "董事会季度复盘", + status: "ready", + language: "zh-CN", + summary: "本季度增长稳定,但交付效率仍需提升。", + blocks: [ + { + id: "section-1", + type: "section_header", + title: "执行摘要", + description: "先看结论,再看展开分析。", + }, + { + id: "hero-1", + type: "hero_summary", + eyebrow: "季度经营", + title: "核心结论", + summary: "收入增长与成本控制表现良好。", + highlights: ["收入增长 18%", "毛利率提升 4 个点"], + }, + { + id: "body-1", + type: "rich_text", + markdown: "这里是正文分析。", + }, + { + id: "callout-1", + type: "callout", + title: "风险提示", + content: "第二季度需重点压缩项目交付周期。", + }, + { + id: "check-1", + type: "checklist", + title: "后续动作", + items: [ + { label: "重排项目节奏", checked: true }, + { label: "补齐交付监控", checked: false }, + ], + }, + { + id: "code-1", + type: "code_block", + language: "json", + code: '{\n "next_step": "optimize-delivery"\n}', + }, + ], + sources: [ + { + id: "source-1", + title: "季度经营看板", + url: "https://lime.example.com/q1", + note: "内部经营分析来源", + }, + ], + metadata: { + currentVersionId: "artifact-document:q1-review:v2", + currentVersionNo: 2, + versionHistory: [ + { + id: "artifact-document:q1-review:v1", + artifactId: "artifact-document:q1-review", + versionNo: 1, + title: "董事会季度复盘", + status: "ready", + }, + { + id: "artifact-document:q1-review:v2", + artifactId: "artifact-document:q1-review", + versionNo: 2, + title: "董事会季度复盘", + status: "ready", + }, + ], + }, + }; +} + +describe("artifactWorkbenchActions", () => { + it("应为结构化文档导出稳定的 JSON 与 Markdown 文件名", () => { + const artifact = createArtifact(); + const document = createDocument(); + + expect(resolveArtifactWorkbenchJsonFilename(artifact, document)).toBe( + "q1-review.artifact.json", + ); + expect(resolveArtifactWorkbenchMarkdownFilename(artifact, document)).toBe( + "q1-review.md", + ); + }); + + it("应把结构化文档降级导出为可阅读 Markdown", () => { + const markdown = serializeArtifactDocumentToMarkdown(createDocument()); + + expect(markdown).toContain("# 董事会季度复盘"); + expect(markdown).toContain("## 执行摘要"); + expect(markdown).toContain("- 收入增长 18%"); + expect(markdown).toContain("> **风险提示**"); + expect(markdown).toContain("- [x] 重排项目节奏"); + expect(markdown).toContain("```json"); + expect(markdown).toContain("## 来源"); + expect(markdown).toContain("[季度经营看板](https://lime.example.com/q1)"); + }); + + it("更新归档状态时应同步当前版本摘要状态", () => { + const nextDocument = updateArtifactDocumentStatus( + createDocument(), + "archived", + ); + + expect(nextDocument.status).toBe("archived"); + expect(nextDocument.metadata.versionHistory?.[1]?.status).toBe("archived"); + expect(nextDocument.metadata.versionHistory?.[0]?.status).toBe("ready"); + }); +}); diff --git a/src/components/agent/chat/workspace/artifactWorkbenchActions.ts b/src/components/agent/chat/workspace/artifactWorkbenchActions.ts new file mode 100644 index 000000000..f051efca9 --- /dev/null +++ b/src/components/agent/chat/workspace/artifactWorkbenchActions.ts @@ -0,0 +1,539 @@ +import { + extractPortableText, + resolveArtifactDocumentCurrentVersion, + type ArtifactDocumentBlock, + type ArtifactDocumentStatus, + type ArtifactDocumentV1, +} from "@/lib/artifact-document"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; +import type { Artifact } from "@/lib/artifact/types"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +function normalizeText(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => normalizeText(item)) + .filter((item): item is string => Boolean(item)); +} + +function sanitizeFilename(value: string): string { + const sanitized = value + .replace(/[<>:"/\\|?*]/g, "_") + .replace(/\s+/g, "_") + .trim(); + + return sanitized || "artifact"; +} + +function stripKnownArtifactExtensions(value: string): string { + return value + .replace(/\.artifact\.json$/i, "") + .replace(/\.markdown$/i, "") + .replace(/\.md$/i, "") + .replace(/\.json$/i, "") + .replace(/\.txt$/i, ""); +} + +function resolveExportBaseName( + artifact: Artifact, + document: ArtifactDocumentV1, +): string { + const filePath = resolveArtifactProtocolFilePath(artifact); + const filename = + normalizeText(artifact.meta.filename) || + normalizeText(filePath.split(/[\\/]/).pop()) || + normalizeText(document.title) || + normalizeText(artifact.title) || + "artifact"; + + return sanitizeFilename(stripKnownArtifactExtensions(filename)); +} + +function resolveChecklistItems( + block: ArtifactDocumentBlock, +): Array<{ label: string; checked: boolean }> { + const items = Array.isArray(block.items) ? block.items : []; + + return items + .map((item) => { + if (typeof item === "string") { + const label = item.trim(); + return label ? { label, checked: false } : null; + } + + const record = asRecord(item); + const label = + normalizeText(record?.label) || + normalizeText(record?.text) || + normalizeText(record?.title); + if (!label) { + return null; + } + + return { + label, + checked: + Boolean(record?.checked) || + Boolean(record?.done) || + Boolean(record?.completed), + }; + }) + .filter((item): item is { label: string; checked: boolean } => + Boolean(item), + ); +} + +function resolveMetricItems( + block: ArtifactDocumentBlock, +): Array<{ label: string; value: string; detail?: string }> { + const items = Array.isArray(block.items) ? block.items : []; + const resolvedItems: Array<{ + label: string; + value: string; + detail?: string; + }> = []; + + items.forEach((item) => { + const record = asRecord(item); + if (!record) { + return; + } + + const label = + normalizeText(record.label) || normalizeText(record.title) || "指标"; + const value = + normalizeText(record.value) || + normalizeText(record.metric) || + normalizeText(record.score); + if (!value) { + return; + } + + resolvedItems.push({ + label, + value, + detail: + normalizeText(record.detail) || + normalizeText(record.description) || + normalizeText(record.trend), + }); + }); + + return resolvedItems; +} + +function resolveTableColumns(block: ArtifactDocumentBlock): string[] { + if (Array.isArray(block.columns)) { + return block.columns + .map((column) => + typeof column === "string" + ? column.trim() + : normalizeText(asRecord(column)?.label) || + normalizeText(asRecord(column)?.title) || + "", + ) + .filter(Boolean); + } + + if (Array.isArray(block.headers)) { + return block.headers + .map((header) => (typeof header === "string" ? header.trim() : "")) + .filter(Boolean); + } + + return []; +} + +function resolveTableRows( + block: ArtifactDocumentBlock, + columns: string[], +): string[][] { + if (!Array.isArray(block.rows)) { + return []; + } + + return block.rows + .map((row) => { + if (Array.isArray(row)) { + return row.map((cell) => normalizeText(cell) || ""); + } + + const record = asRecord(row); + if (!record) { + return null; + } + + if (columns.length > 0) { + return columns.map((column) => normalizeText(record[column]) || ""); + } + + return Object.values(record).map((value) => normalizeText(value) || ""); + }) + .filter((row): row is string[] => Boolean(row)); +} + +function resolveBlockText(block: ArtifactDocumentBlock): string { + return ( + normalizeText(block.markdown) || + normalizeText(block.text) || + normalizeText(block.content) || + normalizeText(block.summary) || + extractPortableText(block.content) || + extractPortableText(block.tiptap) || + extractPortableText(block.proseMirror) || + "" + ); +} + +function renderTableMarkdown(block: ArtifactDocumentBlock): string | null { + const columns = resolveTableColumns(block); + const rows = resolveTableRows(block, columns); + if (columns.length === 0 && rows.length === 0) { + return null; + } + + const headings = + columns.length > 0 + ? columns + : rows[0]?.map((_, index) => `列 ${index + 1}`) || []; + const bodyRows = rows; + const lines: string[] = []; + + if (normalizeText(block.title)) { + lines.push(`### ${normalizeText(block.title)}`); + lines.push(""); + } + + lines.push(`| ${headings.join(" | ")} |`); + lines.push(`| ${headings.map(() => "---").join(" | ")} |`); + bodyRows.forEach((row) => { + const paddedRow = headings.map((_, index) => row[index] || ""); + lines.push(`| ${paddedRow.join(" | ")} |`); + }); + + return lines.join("\n"); +} + +function renderCitationListMarkdown( + block: ArtifactDocumentBlock, +): string | null { + const items = Array.isArray(block.items) ? block.items : []; + if (items.length === 0) { + return null; + } + + const lines: string[] = []; + if (normalizeText(block.title)) { + lines.push(`### ${normalizeText(block.title)}`); + lines.push(""); + } + + items.forEach((item, index) => { + const record = asRecord(item); + const title = + normalizeText(record?.title) || + normalizeText(record?.label) || + normalizeText(record?.url) || + `来源 ${index + 1}`; + const url = + normalizeText(record?.url) || + normalizeText(record?.href) || + normalizeText(record?.link); + const note = + normalizeText(record?.note) || + normalizeText(record?.summary) || + normalizeText(record?.description); + + lines.push(`${index + 1}. ${url ? `[${title}](${url})` : title}`); + if (note) { + lines.push(` - ${note}`); + } + }); + + return lines.join("\n"); +} + +function renderBlockMarkdown(block: ArtifactDocumentBlock): string | null { + switch (block.type) { + case "section_header": { + const title = normalizeText(block.title) || "未命名章节"; + const description = normalizeText(block.description); + return [title ? `## ${title}` : null, description || null] + .filter(Boolean) + .join("\n\n"); + } + case "hero_summary": { + const eyebrow = normalizeText(block.eyebrow); + const title = normalizeText(block.title); + const summary = normalizeText(block.summary); + const highlights = normalizeStringArray(block.highlights); + const lines: string[] = []; + + if (eyebrow) { + lines.push(`> ${eyebrow}`); + lines.push(""); + } + if (title) { + lines.push(`## ${title}`); + } + if (summary) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(summary); + } + if (highlights.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + highlights.forEach((item) => lines.push(`- ${item}`)); + } + + return lines.join("\n"); + } + case "key_points": { + const items = normalizeStringArray(block.items); + if (items.length === 0) { + return null; + } + const title = normalizeText(block.title); + return [ + title ? `### ${title}` : null, + title ? "" : null, + ...items.map((item) => `- ${item}`), + ] + .filter((item): item is string => item !== null) + .join("\n"); + } + case "rich_text": { + const content = resolveBlockText(block); + return content || null; + } + case "callout": { + const title = normalizeText(block.title); + const content = resolveBlockText(block); + const lines = [title ? `**${title}**` : null, content || null].filter( + Boolean, + ) as string[]; + if (lines.length === 0) { + return null; + } + return lines + .join("\n\n") + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + } + case "table": + return renderTableMarkdown(block); + case "checklist": { + const items = resolveChecklistItems(block); + if (items.length === 0) { + return null; + } + const title = normalizeText(block.title); + return [ + title ? `### ${title}` : null, + title ? "" : null, + ...items.map((item) => `- [${item.checked ? "x" : " "}] ${item.label}`), + ] + .filter((item): item is string => item !== null) + .join("\n"); + } + case "metric_grid": { + const items = resolveMetricItems(block); + if (items.length === 0) { + return null; + } + const title = normalizeText(block.title); + return [ + title ? `### ${title}` : null, + title ? "" : null, + ...items.map((item) => + item.detail + ? `- **${item.label}**:${item.value}(${item.detail})` + : `- **${item.label}**:${item.value}`, + ), + ] + .filter((item): item is string => item !== null) + .join("\n"); + } + case "quote": { + const quote = + normalizeText(block.quote) || + normalizeText(block.text) || + extractPortableText(block.content); + const author = normalizeText(block.author) || normalizeText(block.source); + if (!quote) { + return null; + } + + return [ + ...quote.split("\n").map((line) => `> ${line}`), + ...(author ? [">", `> — ${author}`] : []), + ].join("\n"); + } + case "citation_list": + return renderCitationListMarkdown(block); + case "image": { + const src = + normalizeText(block.url) || + normalizeText(block.src) || + normalizeText(block.imageUrl); + if (!src) { + return null; + } + + const title = normalizeText(block.title); + const alt = + normalizeText(block.alt) || + normalizeText(block.caption) || + title || + "artifact image"; + const caption = normalizeText(block.caption); + return [ + title ? `### ${title}` : null, + title ? "" : null, + `![${alt}](${src})`, + caption ? "" : null, + caption ? `_${caption}_` : null, + ] + .filter((item): item is string => item !== null) + .join("\n"); + } + case "code_block": { + const code = + normalizeText(block.code) || + normalizeText(block.content) || + extractPortableText(block.content); + if (!code) { + return null; + } + + const title = normalizeText(block.title); + const language = normalizeText(block.language) || ""; + return [ + title ? `### ${title}` : null, + title ? "" : null, + `\`\`\`${language}`, + code, + "```", + ] + .filter((item): item is string => item !== null) + .join("\n"); + } + case "divider": + return "---"; + default: + return resolveBlockText(block) || null; + } +} + +export function resolveArtifactWorkbenchJsonFilename( + artifact: Artifact, + document: ArtifactDocumentV1, +): string { + return `${resolveExportBaseName(artifact, document)}.artifact.json`; +} + +export function resolveArtifactWorkbenchMarkdownFilename( + artifact: Artifact, + document: ArtifactDocumentV1, +): string { + return `${resolveExportBaseName(artifact, document)}.md`; +} + +export function serializeArtifactDocumentToMarkdown( + document: ArtifactDocumentV1, +): string { + const sections: string[] = [`# ${document.title}`]; + + if (normalizeText(document.summary)) { + sections.push(normalizeText(document.summary)!); + } + + document.blocks.forEach((block) => { + const nextSection = renderBlockMarkdown(block); + if (nextSection) { + sections.push(nextSection); + } + }); + + if (document.sources.length > 0) { + const appendixLines = ["## 来源"]; + document.sources.forEach((source, index) => { + const title = + normalizeText(source.title) || + normalizeText(source.url) || + `来源 ${index + 1}`; + appendixLines.push( + `${index + 1}. ${source.url ? `[${title}](${source.url})` : title}`, + ); + if (normalizeText(source.note)) { + appendixLines.push(` - ${normalizeText(source.note)}`); + } + if (normalizeText(source.quote)) { + appendixLines.push(` - 摘录:${normalizeText(source.quote)}`); + } + if (normalizeText(source.publishedAt)) { + appendixLines.push(` - 时间:${normalizeText(source.publishedAt)}`); + } + }); + sections.push(appendixLines.join("\n")); + } + + return sections + .filter(Boolean) + .join("\n\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +export function updateArtifactDocumentStatus( + document: ArtifactDocumentV1, + status: ArtifactDocumentStatus, +): ArtifactDocumentV1 { + const currentVersion = resolveArtifactDocumentCurrentVersion(document); + const nextVersionHistory = Array.isArray(document.metadata.versionHistory) + ? document.metadata.versionHistory.map((version) => { + if (!currentVersion) { + return version; + } + + const matchesCurrentVersion = + version.id === currentVersion.id || + version.versionNo === currentVersion.versionNo; + if (!matchesCurrentVersion) { + return version; + } + + return { + ...version, + status, + }; + }) + : document.metadata.versionHistory; + + return { + ...document, + status, + metadata: { + ...document.metadata, + ...(nextVersionHistory ? { versionHistory: nextVersionHistory } : {}), + }, + }; +} diff --git a/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts b/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts index 18f170358..b2c71bd84 100644 --- a/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts +++ b/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { Message } from "../types"; -import { buildThemeWorkbenchWorkflowSteps } from "./themeWorkbenchHelpers"; +import { + applyBackendThemeWorkbenchDocumentState, + buildThemeWorkbenchWorkflowSteps, + readPersistedThemeWorkbenchDocument, +} from "./themeWorkbenchHelpers"; describe("themeWorkbenchHelpers", () => { it("应通过 artifact protocol 解析嵌套参数中的写文件路径标题", () => { @@ -50,4 +54,120 @@ describe("themeWorkbenchHelpers", () => { ]), ); }); + + it("应读取后端持久化的主题工作台版本元数据", () => { + const persisted = readPersistedThemeWorkbenchDocument({ + theme_workbench_document_v1: { + currentVersionId: "artifact-document:auto-report:v2", + versions: [ + { + id: "artifact-document:auto-report:v1", + createdAt: 1710000000000, + description: "第一版", + }, + { + id: "artifact-document:auto-report:v2", + createdAt: 1710003600000, + description: "第二版", + }, + ], + versionStatusMap: { + "artifact-document:auto-report:v1": "merged", + "artifact-document:auto-report:v2": "pending", + }, + }, + }); + + expect(persisted).toEqual({ + currentVersionId: "artifact-document:auto-report:v2", + versions: [ + { + id: "artifact-document:auto-report:v1", + content: "", + createdAt: 1710000000000, + description: "第一版", + }, + { + id: "artifact-document:auto-report:v2", + content: "", + createdAt: 1710003600000, + description: "第二版", + }, + ], + versionStatusMap: { + "artifact-document:auto-report:v1": "merged", + "artifact-document:auto-report:v2": "pending", + }, + }); + }); + + it("应把后端主题工作台状态与正文恢复为当前版本", () => { + const result = applyBackendThemeWorkbenchDocumentState( + { + type: "document", + content: "", + platform: "markdown", + versions: [ + { + id: "draft-initial", + content: "", + createdAt: 1709990000000, + description: "初始草稿", + }, + ], + currentVersionId: "draft-initial", + isEditing: true, + } as never, + { + content_id: "content-1", + current_version_id: "artifact-document:auto-report:v2", + version_count: 2, + versions: [ + { + id: "artifact-document:auto-report:v1", + created_at: 1710000000000, + description: "第一版", + status: "merged", + is_current: false, + }, + { + id: "artifact-document:auto-report:v2", + created_at: 1710003600000, + description: "第二版", + status: "pending", + is_current: true, + }, + ], + }, + "# 自动化日报\n\n这里是最新正文。", + ); + + expect(result).not.toBeNull(); + expect(result?.state.type).toBe("document"); + if (result?.state.type !== "document") { + throw new Error("应返回 document state"); + } + expect(result.state.currentVersionId).toBe( + "artifact-document:auto-report:v2", + ); + expect(result.state.content).toContain("这里是最新正文"); + expect(result.state.versions).toEqual([ + { + id: "artifact-document:auto-report:v1", + content: "", + createdAt: 1710000000000, + description: "第一版", + }, + { + id: "artifact-document:auto-report:v2", + content: "# 自动化日报\n\n这里是最新正文。", + createdAt: 1710003600000, + description: "第二版", + }, + ]); + expect(result.statusMap).toEqual({ + "artifact-document:auto-report:v1": "merged", + "artifact-document:auto-report:v2": "pending", + }); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceArtifactWorkbenchActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceArtifactWorkbenchActions.test.tsx new file mode 100644 index 000000000..0510d55a5 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceArtifactWorkbenchActions.test.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; +import type { Artifact } from "@/lib/artifact/types"; +import { useWorkspaceArtifactWorkbenchActions } from "./useWorkspaceArtifactWorkbenchActions"; + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + }, +})); + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createArtifact(): Artifact { + return { + id: "artifact-1", + type: "document", + title: "board-review.artifact.json", + content: "", + status: "complete", + meta: { + filePath: ".lime/artifacts/thread-1/board-review.artifact.json", + filename: "board-review.artifact.json", + language: "json", + }, + position: { start: 0, end: 0 }, + createdAt: 1, + updatedAt: 1, + }; +} + +function createDocument( + status: "ready" | "archived" = "ready", +): ArtifactDocumentV1 { + return { + schemaVersion: "artifact_document.v1", + artifactId: "artifact-document:board-review", + kind: "analysis", + title: "董事会季度复盘", + status, + language: "zh-CN", + blocks: [ + { + id: "body-1", + type: "rich_text", + markdown: "正文内容", + }, + ], + sources: [], + metadata: { + currentVersionId: "artifact-document:board-review:v2", + currentVersionNo: 2, + versionHistory: [ + { + id: "artifact-document:board-review:v1", + artifactId: "artifact-document:board-review", + versionNo: 1, + title: "董事会季度复盘", + status: "ready", + }, + { + id: "artifact-document:board-review:v2", + artifactId: "artifact-document:board-review", + versionNo: 2, + title: "董事会季度复盘", + status, + }, + ], + }, + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType< + typeof useWorkspaceArtifactWorkbenchActions + > | null = null; + + const defaultProps: HookProps = { + activeTheme: "general", + projectId: "project-1", + syncGeneralArtifactToResource: vi.fn().mockResolvedValue({ + status: "uploaded", + }), + onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined), + }; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceArtifactWorkbenchActions(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + toastSuccess.mockReset(); + toastError.mockReset(); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.restoreAllMocks(); +}); + +describe("useWorkspaceArtifactWorkbenchActions", () => { + it("项目复用动作应复用现有资源同步主线", async () => { + const syncGeneralArtifactToResource = vi.fn().mockResolvedValue({ + status: "uploaded", + }); + const { render, getValue } = renderHook({ + syncGeneralArtifactToResource, + }); + await render(); + + const state = getValue().getToolbarActionState( + createArtifact(), + createDocument(), + ); + expect(state).not.toBeNull(); + + await act(async () => { + await state?.onSaveToProject(); + }); + + expect(syncGeneralArtifactToResource).toHaveBeenCalledWith({ + rawFilePath: ".lime/artifacts/thread-1/board-review.artifact.json", + preferredName: "董事会季度复盘", + }); + expect(toastSuccess).toHaveBeenCalledWith("已保存到项目资源"); + }); + + it("归档动作应回写同一份 ArtifactDocument 状态", async () => { + const onSaveArtifactDocument = vi.fn().mockResolvedValue(undefined); + const { render, getValue } = renderHook({ + onSaveArtifactDocument, + }); + await render(); + + const artifact = createArtifact(); + const document = createDocument(); + const state = getValue().getToolbarActionState(artifact, document); + expect(state?.archiveLabel).toBe("归档"); + + await act(async () => { + await state?.onToggleArchive(); + }); + + expect(onSaveArtifactDocument).toHaveBeenCalledWith( + expect.objectContaining({ id: "artifact-1" }), + expect.objectContaining({ + status: "archived", + metadata: expect.objectContaining({ + versionHistory: expect.arrayContaining([ + expect.objectContaining({ + id: "artifact-document:board-review:v2", + status: "archived", + }), + ]), + }), + }), + ); + expect(toastSuccess).toHaveBeenCalledWith("已归档当前交付物"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceArtifactWorkbenchActions.tsx b/src/components/agent/chat/workspace/useWorkspaceArtifactWorkbenchActions.tsx new file mode 100644 index 000000000..35cc679c1 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceArtifactWorkbenchActions.tsx @@ -0,0 +1,245 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; +import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; +import type { Artifact } from "@/lib/artifact/types"; +import { + resolveArtifactWorkbenchJsonFilename, + resolveArtifactWorkbenchMarkdownFilename, + serializeArtifactDocumentToMarkdown, + updateArtifactDocumentStatus, +} from "./artifactWorkbenchActions"; +import { ArtifactWorkbenchToolbarActions } from "./ArtifactWorkbenchToolbarActions"; +import type { GeneralArtifactSyncResult } from "./useWorkspaceGeneralResourceSync"; + +function downloadText(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +function resolveResourceSyncFeedback(result: GeneralArtifactSyncResult): { + kind: "success" | "error"; + message: string; +} { + switch (result.status) { + case "uploaded": + return { + kind: "success", + message: "已保存到项目资源", + }; + case "duplicate": + return { + kind: "success", + message: "项目资源中已存在该交付物", + }; + case "missing_project": + return { + kind: "error", + message: "请先选择项目后再保存到项目资源", + }; + case "unsupported": + return { + kind: "error", + message: "当前交付物暂不支持保存到项目资源", + }; + case "missing_file": + return { + kind: "error", + message: "当前交付物还没有可复用的落盘文件", + }; + case "inactive": + return { + kind: "error", + message: "当前主题暂未接入项目资源复用", + }; + case "error": + default: + return { + kind: "error", + message: result.errorMessage || "保存到项目资源失败", + }; + } +} + +export interface ArtifactWorkbenchToolbarActionState { + showSaveToProject: boolean; + saveToProjectDisabled: boolean; + isSavingToProject: boolean; + onSaveToProject: () => Promise; + onExportJson: () => Promise; + onExportMarkdown: () => Promise; + showArchiveToggle: boolean; + isUpdatingArchive: boolean; + archiveLabel: string; + onToggleArchive: () => Promise; +} + +interface UseWorkspaceArtifactWorkbenchActionsParams { + activeTheme: string; + projectId?: string | null; + syncGeneralArtifactToResource: (input: { + rawFilePath: string; + preferredName?: string; + }) => Promise; + onSaveArtifactDocument?: ( + artifact: Artifact, + document: ArtifactDocumentV1, + ) => Promise | void; +} + +export function useWorkspaceArtifactWorkbenchActions({ + activeTheme, + projectId, + syncGeneralArtifactToResource, + onSaveArtifactDocument, +}: UseWorkspaceArtifactWorkbenchActionsParams) { + const [savingResourceArtifactId, setSavingResourceArtifactId] = useState< + string | null + >(null); + const [updatingArchiveArtifactId, setUpdatingArchiveArtifactId] = useState< + string | null + >(null); + + const handleExportJson = useCallback( + async (artifact: Artifact, document: ArtifactDocumentV1) => { + downloadText( + JSON.stringify(document, null, 2), + resolveArtifactWorkbenchJsonFilename(artifact, document), + "application/json;charset=utf-8", + ); + toast.success("已导出 Artifact JSON"); + }, + [], + ); + + const handleExportMarkdown = useCallback( + async (artifact: Artifact, document: ArtifactDocumentV1) => { + downloadText( + serializeArtifactDocumentToMarkdown(document), + resolveArtifactWorkbenchMarkdownFilename(artifact, document), + "text/markdown;charset=utf-8", + ); + toast.success("已导出 Markdown"); + }, + [], + ); + + const handleSaveToProject = useCallback( + async (artifact: Artifact, document: ArtifactDocumentV1) => { + const rawFilePath = resolveArtifactProtocolFilePath(artifact); + setSavingResourceArtifactId(artifact.id); + + try { + const result = await syncGeneralArtifactToResource({ + rawFilePath, + preferredName: document.title || artifact.title, + }); + const feedback = resolveResourceSyncFeedback(result); + if (feedback.kind === "success") { + toast.success(feedback.message); + } else { + toast.error(feedback.message); + } + } finally { + setSavingResourceArtifactId((current) => + current === artifact.id ? null : current, + ); + } + }, + [syncGeneralArtifactToResource], + ); + + const handleToggleArchive = useCallback( + async (artifact: Artifact, document: ArtifactDocumentV1) => { + if (!onSaveArtifactDocument) { + toast.error("当前交付物暂不支持归档"); + return; + } + + const nextStatus = document.status === "archived" ? "ready" : "archived"; + setUpdatingArchiveArtifactId(artifact.id); + + try { + await onSaveArtifactDocument( + artifact, + updateArtifactDocumentStatus(document, nextStatus), + ); + toast.success( + nextStatus === "archived" ? "已归档当前交付物" : "已恢复当前交付物", + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "更新归档状态失败", + ); + } finally { + setUpdatingArchiveArtifactId((current) => + current === artifact.id ? null : current, + ); + } + }, + [onSaveArtifactDocument], + ); + + const getToolbarActionState = useCallback( + ( + artifact: Artifact, + document: ArtifactDocumentV1 | null, + ): ArtifactWorkbenchToolbarActionState | null => { + if (!document) { + return null; + } + + const normalizedProjectId = projectId?.trim() || ""; + return { + showSaveToProject: activeTheme === "general", + saveToProjectDisabled: + activeTheme !== "general" || + !normalizedProjectId || + savingResourceArtifactId === artifact.id, + isSavingToProject: savingResourceArtifactId === artifact.id, + onSaveToProject: () => handleSaveToProject(artifact, document), + onExportJson: () => handleExportJson(artifact, document), + onExportMarkdown: () => handleExportMarkdown(artifact, document), + showArchiveToggle: Boolean(onSaveArtifactDocument), + isUpdatingArchive: updatingArchiveArtifactId === artifact.id, + archiveLabel: document.status === "archived" ? "取消归档" : "归档", + onToggleArchive: () => handleToggleArchive(artifact, document), + }; + }, + [ + activeTheme, + handleExportJson, + handleExportMarkdown, + handleSaveToProject, + handleToggleArchive, + onSaveArtifactDocument, + projectId, + savingResourceArtifactId, + updatingArchiveArtifactId, + ], + ); + + const renderToolbarActions = useCallback( + (params: { artifact: Artifact; document: ArtifactDocumentV1 | null }) => { + const state = getToolbarActionState(params.artifact, params.document); + if (!state) { + return null; + } + + return ; + }, + [getToolbarActionState], + ); + + return { + getToolbarActionState, + renderToolbarActions, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts index 213696c1a..7b16dffe3 100644 --- a/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceAutoGuideRuntime.ts @@ -14,6 +14,7 @@ interface UseWorkspaceAutoGuideRuntimeParams { sessionId?: string | null; initialUserPrompt?: string; initialUserImages?: MessageImage[]; + autoRunInitialPromptOnMount: boolean; initialDispatchKey: string | null; messagesCount: number; projectReady: boolean; @@ -41,6 +42,7 @@ export function useWorkspaceAutoGuideRuntime({ sessionId, initialUserPrompt, initialUserImages, + autoRunInitialPromptOnMount, initialDispatchKey, messagesCount, projectReady, @@ -94,7 +96,11 @@ export function useWorkspaceAutoGuideRuntime({ } if (initialDispatchKey) { - if (isThemeWorkbench && pendingInitialImages.length === 0) { + if ( + isThemeWorkbench && + pendingInitialImages.length === 0 && + !autoRunInitialPromptOnMount + ) { return; } if (consumedInitialPromptRef.current === initialDispatchKey) { @@ -179,6 +185,7 @@ export function useWorkspaceAutoGuideRuntime({ initialDispatchKey, initialUserImages, initialUserPrompt, + autoRunInitialPromptOnMount, isSending, isThemeWorkbench, mappedTheme, diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx index 14de3e757..b688229db 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasPreviewPresentation.tsx @@ -5,10 +5,7 @@ import { type ReactNode, } from "react"; import { toast } from "sonner"; -import { - ArtifactCanvasOverlay, - ArtifactToolbar, -} from "@/components/artifact"; +import { ArtifactCanvasOverlay, ArtifactToolbar } from "@/components/artifact"; import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory"; import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils"; import type { ThemeType } from "@/components/content-creator/types"; @@ -16,7 +13,10 @@ import { CanvasPanel as GeneralCanvasPanel, type CanvasState as GeneralCanvasState, } from "@/components/general-chat/bridge"; -import { openPathWithDefaultApp, revealPathInFinder } from "@/lib/api/fileSystem"; +import { + openPathWithDefaultApp, + revealPathInFinder, +} from "@/lib/api/fileSystem"; import type { Artifact } from "@/lib/artifact/types"; import { ImageWorkbenchCanvas } from "../components/ImageWorkbenchCanvas"; import type { @@ -59,7 +59,9 @@ interface WorkspaceCanvasDefaultPreviewParams { interface WorkspaceCanvasPreviewArtifactParams { currentCanvasArtifact: Artifact | null; displayedCanvasArtifact: Artifact | null; - artifactOverlay: ComponentProps["overlay"] | null; + artifactOverlay: + | ComponentProps["overlay"] + | null; showPreviousVersionBadge: boolean; artifactViewMode: ComponentProps["viewMode"]; onArtifactViewModeChange: NonNullable< @@ -77,6 +79,9 @@ interface WorkspaceCanvasPreviewArtifactParams { blockFocusRequestKey?: number; onJumpToTimelineItem?: (itemId: string) => void; onCloseCanvas: () => void; + renderToolbarActions?: ComponentProps< + typeof ArtifactWorkbenchPreview + >["renderToolbarActions"]; } interface WorkspaceCanvasPreviewImageWorkbenchParams { @@ -162,8 +167,10 @@ interface WorkspaceCanvasPreviewFactoryParams { onNovelChapterListCollapsedChange: (collapsed: boolean) => void; } -interface WorkspaceCanvasPreviewTeamWorkbenchParams - extends Omit { +interface WorkspaceCanvasPreviewTeamWorkbenchParams extends Omit< + UseTeamWorkbenchPresentationParams, + "surfaceProps" +> { surfaceProps: TeamWorkbenchSurfaceProps; } @@ -253,6 +260,7 @@ export function useWorkspaceCanvasPreviewPresentation({ blockFocusRequestKey: artifactPreview.blockFocusRequestKey, onJumpToTimelineItem: artifactPreview.onJumpToTimelineItem, onCloseCanvas: artifactPreview.onCloseCanvas, + renderToolbarActions: artifactPreview.renderToolbarActions, }), [ artifactPreview.artifactOverlay, @@ -267,6 +275,7 @@ export function useWorkspaceCanvasPreviewPresentation({ artifactPreview.onArtifactViewModeChange, artifactPreview.onJumpToTimelineItem, artifactPreview.onCloseCanvas, + artifactPreview.renderToolbarActions, artifactPreview.showPreviousVersionBadge, artifactPreview.threadItems, ], @@ -333,7 +342,11 @@ export function useWorkspaceCanvasPreviewPresentation({ onClose: generalCanvas.onCloseCanvas, onContentChange: generalCanvas.onContentChange, }), - [generalCanvas.onCloseCanvas, generalCanvas.onContentChange, generalCanvas.state], + [ + generalCanvas.onCloseCanvas, + generalCanvas.onContentChange, + generalCanvas.state, + ], ); const canvasLoadingLabel = useMemo( @@ -344,7 +357,9 @@ export function useWorkspaceCanvasPreviewPresentation({ [loading.initialContentLoadError, loading.isInitialContentLoading], ); - const canvasFactoryProps = useMemo | null>( + const canvasFactoryProps = useMemo | null>( () => canvasFactory.resolvedCanvasState ? { @@ -358,8 +373,7 @@ export function useWorkspaceCanvasPreviewPresentation({ projectId: canvasFactory.projectId, contentId: canvasFactory.contentId, autoImageTopic: canvasFactory.autoImageTopic, - autoContinueProviderType: - canvasFactory.autoContinueProviderType, + autoContinueProviderType: canvasFactory.autoContinueProviderType, onAutoContinueProviderTypeChange: canvasFactory.onAutoContinueProviderTypeChange, autoContinueModel: canvasFactory.autoContinueModel, @@ -437,13 +451,13 @@ export function useWorkspaceCanvasPreviewPresentation({ return Boolean( (artifactPreview.currentCanvasArtifact && artifactPreview.displayedCanvasArtifact) || - defaultPreview.generalCanvasState.isOpen, + defaultPreview.generalCanvasState.isOpen, ); } return Boolean( loading.shouldShowCanvasLoadingState || - defaultPreview.resolvedCanvasState, + defaultPreview.resolvedCanvasState, ); }, [ artifactPreview.currentCanvasArtifact, diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx index fa785da99..bcb5da6c1 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx @@ -42,7 +42,9 @@ interface UseWorkspaceCanvasSceneRuntimeParams { projectRootPath: CanvasPreviewPresentationParams["defaultPreview"]["workspaceRoot"]; generalCanvasState: CanvasPreviewPresentationParams["defaultPreview"]["generalCanvasState"]; setGeneralCanvasState: Dispatch< - SetStateAction + SetStateAction< + CanvasPreviewPresentationParams["defaultPreview"]["generalCanvasState"] + > >; currentCanvasArtifact: ArtifactPreviewParams["currentCanvasArtifact"]; displayedCanvasArtifact: ArtifactPreviewParams["displayedCanvasArtifact"]; @@ -55,6 +57,7 @@ interface UseWorkspaceCanvasSceneRuntimeParams { artifactPreviewSize: ArtifactPreviewParams["artifactPreviewSize"]; setArtifactPreviewSize: ArtifactPreviewParams["onArtifactPreviewSizeChange"]; onSaveArtifactDocument: ArtifactPreviewParams["onSaveArtifactDocument"]; + renderArtifactWorkbenchToolbarActions: ArtifactPreviewParams["renderToolbarActions"]; threadItems: AgentThreadItem[]; focusedBlockId: string | null; blockFocusRequestKey: number; @@ -112,6 +115,7 @@ export function useWorkspaceCanvasSceneRuntime({ artifactPreviewSize, setArtifactPreviewSize, onSaveArtifactDocument, + renderArtifactWorkbenchToolbarActions, threadItems, focusedBlockId, blockFocusRequestKey, @@ -169,13 +173,13 @@ export function useWorkspaceCanvasSceneRuntime({ currentCanvasArtifact, displayedCanvasArtifact, artifactOverlay: artifactDisplayState.overlay, - showPreviousVersionBadge: - artifactDisplayState.showPreviousVersionBadge, + showPreviousVersionBadge: artifactDisplayState.showPreviousVersionBadge, artifactViewMode, onArtifactViewModeChange: setArtifactViewMode, artifactPreviewSize, onArtifactPreviewSizeChange: setArtifactPreviewSize, onSaveArtifactDocument, + renderToolbarActions: renderArtifactWorkbenchToolbarActions, threadItems, focusedBlockId, blockFocusRequestKey, diff --git a/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts b/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts index 4692b30c6..532f44dca 100644 --- a/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts +++ b/src/components/agent/chat/workspace/useWorkspaceGeneralResourceSync.ts @@ -22,6 +22,21 @@ interface UseWorkspaceGeneralResourceSyncParams { projectRootPath?: string | null; } +export interface GeneralArtifactSyncResult { + status: + | "uploaded" + | "duplicate" + | "inactive" + | "missing_project" + | "unsupported" + | "missing_file" + | "error"; + projectId?: string; + filePath?: string; + materialId?: string; + errorMessage?: string; +} + export function useWorkspaceGeneralResourceSync({ activeTheme, projectId, @@ -46,29 +61,33 @@ export function useWorkspaceGeneralResourceSync({ [], ); - const ensureGeneralResourceHashes = useCallback(async (targetProjectId: string) => { - const existingHashes = generalResourceHashesRef.current.get(targetProjectId); - if (existingHashes) { - return existingHashes; - } + const ensureGeneralResourceHashes = useCallback( + async (targetProjectId: string) => { + const existingHashes = + generalResourceHashesRef.current.get(targetProjectId); + if (existingHashes) { + return existingHashes; + } - const nextHashes = new Set(); + const nextHashes = new Set(); - try { - const materials = await listMaterials(targetProjectId); - materials.forEach((material) => { - const hash = extractGeneralChatResourceHash(material); - if (hash) { - nextHashes.add(hash); - } - }); - } catch (error) { - console.warn("[AgentChatPage] 读取资源去重缓存失败:", error); - } + try { + const materials = await listMaterials(targetProjectId); + materials.forEach((material) => { + const hash = extractGeneralChatResourceHash(material); + if (hash) { + nextHashes.add(hash); + } + }); + } catch (error) { + console.warn("[AgentChatPage] 读取资源去重缓存失败:", error); + } - generalResourceHashesRef.current.set(targetProjectId, nextHashes); - return nextHashes; - }, []); + generalResourceHashesRef.current.set(targetProjectId, nextHashes); + return nextHashes; + }, + [], + ); const resolveGeneralArtifactSyncPath = useCallback( async (rawFilePath: string): Promise => { @@ -95,7 +114,8 @@ export function useWorkspaceGeneralResourceSync({ } return ( - resolveAbsoluteWorkspacePath(projectRootPath, normalizedFilePath) || null + resolveAbsoluteWorkspacePath(projectRootPath, normalizedFilePath) || + null ); }, [projectRootPath, sessionId], @@ -104,20 +124,27 @@ export function useWorkspaceGeneralResourceSync({ const syncGeneralArtifactToResource = useCallback( async (input: { rawFilePath: string; preferredName?: string }) => { if (activeTheme !== "general") { - return; + return { + status: "inactive", + } satisfies GeneralArtifactSyncResult; } const normalizedProjectId = normalizeProjectId(projectId); const normalizedRawFilePath = input.rawFilePath.trim(); if (!normalizedProjectId || !normalizedRawFilePath) { - return; + return { + status: "missing_project", + } satisfies GeneralArtifactSyncResult; } const materialType = inferGeneralChatResourceMaterialType( normalizedRawFilePath, ); if (!materialType) { - return; + return { + status: "unsupported", + projectId: normalizedProjectId, + } satisfies GeneralArtifactSyncResult; } const resolvedFilePath = await resolveGeneralArtifactSyncPath( @@ -125,23 +152,37 @@ export function useWorkspaceGeneralResourceSync({ ); const normalizedResolvedFilePath = resolvedFilePath?.trim(); if (!normalizedResolvedFilePath) { - return; + return { + status: "missing_file", + projectId: normalizedProjectId, + } satisfies GeneralArtifactSyncResult; } const pathHash = buildGeneralChatResourceHash(normalizedResolvedFilePath); const dedupeKey = `${normalizedProjectId}:${pathHash}`; if (generalResourceSyncInFlightRef.current.has(dedupeKey)) { - return; + syncResourceProjectSelection(normalizedProjectId); + return { + status: "duplicate", + projectId: normalizedProjectId, + filePath: normalizedResolvedFilePath, + } satisfies GeneralArtifactSyncResult; } - const knownHashes = await ensureGeneralResourceHashes(normalizedProjectId); + const knownHashes = + await ensureGeneralResourceHashes(normalizedProjectId); if (knownHashes.has(pathHash)) { - return; + syncResourceProjectSelection(normalizedProjectId); + return { + status: "duplicate", + projectId: normalizedProjectId, + filePath: normalizedResolvedFilePath, + } satisfies GeneralArtifactSyncResult; } generalResourceSyncInFlightRef.current.add(dedupeKey); try { - await uploadMaterial({ + const material = await uploadMaterial({ projectId: normalizedProjectId, name: input.preferredName?.trim() || @@ -157,8 +198,20 @@ export function useWorkspaceGeneralResourceSync({ knownHashes.add(pathHash); syncResourceProjectSelection(normalizedProjectId); + return { + status: "uploaded", + projectId: normalizedProjectId, + filePath: normalizedResolvedFilePath, + materialId: material.id, + } satisfies GeneralArtifactSyncResult; } catch (error) { console.warn("[AgentChatPage] 自动补录资源失败:", error); + return { + status: "error", + projectId: normalizedProjectId, + filePath: normalizedResolvedFilePath, + errorMessage: error instanceof Error ? error.message : String(error), + } satisfies GeneralArtifactSyncResult; } finally { generalResourceSyncInFlightRef.current.delete(dedupeKey); } diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx index a0d03e310..68bd761f1 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -86,7 +86,9 @@ interface UseWorkspaceInputbarSceneRuntimeParams { handleTaskFileClick: InputbarParams["onTaskFileClick"]; characters: InputbarParams["characters"]; skills: InputbarParams["skills"]; + serviceSkills: InputbarParams["serviceSkills"]; skillsLoading: InputbarParams["isSkillsLoading"]; + onSelectServiceSkill: InputbarParams["onSelectServiceSkill"]; setChatToolPreferences: InputbarParams["onToolStatesChange"]; handleNavigateToSkillSettings: InputbarParams["onNavigateToSettings"]; handleRefreshSkills: InputbarParams["onRefreshSkills"]; @@ -172,7 +174,9 @@ export function useWorkspaceInputbarSceneRuntime({ handleTaskFileClick, characters, skills, + serviceSkills, skillsLoading, + onSelectServiceSkill, setChatToolPreferences, handleNavigateToSkillSettings, handleRefreshSkills, @@ -290,7 +294,9 @@ export function useWorkspaceInputbarSceneRuntime({ onTaskFileClick: handleTaskFileClick, characters, skills, + serviceSkills, isSkillsLoading: skillsLoading, + onSelectServiceSkill, toolStates: resolvedChatToolPreferences, onToolStatesChange: setChatToolPreferences, onNavigateToSettings: handleNavigateToSkillSettings, diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 00997dc28..b445725d2 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -1,14 +1,8 @@ import { useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; import type { Dispatch, SetStateAction } from "react"; -import type { Character } from "@/lib/api/memory"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; -import { preheatBrowserAssistInBackground } from "../utils/browserAssistPreheat"; import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; -import { - buildHarnessRequestMetadata, - extractExistingHarnessMetadata, -} from "../utils/harnessRequestMetadata"; import { isTeamRuntimeRecommendation } from "../utils/contextualRecommendations"; import { saveChatToolPreferences, @@ -19,24 +13,24 @@ import type { ThemeWorkbenchSendBoundaryState } from "../hooks/useThemeWorkbench import type { UseRuntimeTeamFormationResult } from "../hooks/useRuntimeTeamFormation"; import type { SendMessageFn } from "../hooks/agentChatShared"; import type { MessageImage } from "../types"; -import type { ThemeType } from "@/components/content-creator/types"; import type { TeamDefinition } from "../utils/teamDefinitions"; import type { RuntimeTeamDispatchPreviewSnapshot } from "./runtimeTeamPreview"; - -const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; +import { + buildRuntimeTeamDispatchPreview, + buildWorkspaceRequestMetadata, + buildWorkspaceSendText, + primeBrowserAssistBeforeSend, + type ContextWorkspaceSummary, + type EnsureBrowserAssistCanvasOptions, +} from "./workspaceSendHelpers"; +import type { Character } from "@/lib/api/memory"; +import type { ThemeType } from "@/components/content-creator/types"; type ExecutionStrategy = "react" | "code_orchestrated" | "auto"; type SetStringState = (value: string) => void; - -interface ContextWorkspaceSummary { - enabled: boolean; - prepareActiveContextPrompt: () => Promise; -} - -interface EnsureBrowserAssistCanvasOptions { - silent?: boolean; - navigationMode?: "none" | "explicit-url" | "best-effort"; -} +type ParsedImageWorkbenchCommand = NonNullable< + ReturnType +>; interface UseWorkspaceSendActionsParams { input: string; @@ -88,63 +82,36 @@ interface UseWorkspaceSendActionsParams { ) => Promise; handleImageWorkbenchCommand: (input: { rawText: string; - parsedCommand: NonNullable>; + parsedCommand: ParsedImageWorkbenchCommand; images: MessageImage[]; }) => Promise; } -function applyActiveContextPrompt( - text: string, - activeContextPrompt: string, -): string { - if (!activeContextPrompt.trim()) { - return text; - } - - const slashCommandMatch = text.match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/); - if (slashCommandMatch) { - const [, skillName, skillArgs] = slashCommandMatch; - const mergedArgs = [activeContextPrompt, skillArgs.trim()] - .filter((part) => part.length > 0) - .join("\n\n"); - return `/${skillName} ${mergedArgs}`.trim(); - } - - return `${activeContextPrompt}\n\n${text}`; +interface WorkspaceResolvedSendState { + sourceText: string; + sendBoundary: ThemeWorkbenchSendBoundaryState; + effectiveToolPreferences: ChatToolPreferences; + effectiveWebSearch?: boolean; + effectiveThinking?: boolean; } -function applyMentionedCharacterContext( - text: string, - mentionedCharacters: Character[], -): string { - if (mentionedCharacters.length === 0) { - return text; - } - - const characterContext = mentionedCharacters - .map((char) => { - let context = `角色:${char.name}`; - if (char.description) context += `\n简介:${char.description}`; - if (char.personality) context += `\n性格:${char.personality}`; - if (char.background) context += `\n背景:${char.background}`; - return context; - }) - .join("\n\n"); - - return `[角色上下文]\n${characterContext}\n\n[用户输入]\n${text}`; +interface WorkspaceSendPlan extends WorkspaceResolvedSendState { + text: string; + images: MessageImage[]; + sendExecutionStrategy?: ExecutionStrategy; + autoContinuePayload?: AutoContinueRequestPayload; + sendOptions?: HandleSendOptions; } -function applyRuntimeStyleMessagePrompt( - text: string, - runtimeStyleMessagePrompt: string, - sendOptions?: HandleSendOptions, -): string { - if (sendOptions?.purpose || !runtimeStyleMessagePrompt.trim()) { - return text; - } - - return `[本次任务风格要求]\n${runtimeStyleMessagePrompt}\n\n[用户输入]\n${text}`; -} +type WorkspaceSendResolution = + | { + kind: "done"; + result: boolean; + } + | { + kind: "ready"; + plan: WorkspaceSendPlan; + }; export type WorkspaceHandleSend = ( images?: MessageImage[], @@ -190,19 +157,19 @@ export function useWorkspaceSendActions({ ensureBrowserAssistCanvas, handleImageWorkbenchCommand, }: UseWorkspaceSendActionsParams) { - const handleSend = useCallback( + const resolveSendExecutionPlan = useCallback( async ( - images, - webSearch, - thinking, - textOverride, - sendExecutionStrategy, - autoContinuePayload, - sendOptions, - ) => { + images?: MessageImage[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + sendExecutionStrategy?: ExecutionStrategy, + autoContinuePayload?: AutoContinueRequestPayload, + sendOptions?: HandleSendOptions, + ): Promise => { let sourceText = textOverride ?? input; if (!sourceText.trim() && (!images || images.length === 0)) { - return false; + return { kind: "done", result: false }; } const sendBoundary = resolveSendBoundary({ @@ -213,7 +180,7 @@ export function useWorkspaceSendActions({ if (isBlockedByBrowserPreflight(sendOptions)) { toast.info("请先完成当前浏览器准备后,再继续发送新的任务"); - return false; + return { kind: "done", result: false }; } const effectiveToolPreferences = @@ -231,7 +198,7 @@ export function useWorkspaceSendActions({ if (!projectId) { sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); toast.error("请先选择项目后再开始对话"); - return false; + return { kind: "done", result: false }; } const parsedImageWorkbenchCommand = @@ -239,11 +206,14 @@ export function useWorkspaceSendActions({ ? parseImageWorkbenchCommand(sourceText) : null; if (parsedImageWorkbenchCommand) { - return handleImageWorkbenchCommand({ - rawText: sourceText, - parsedCommand: parsedImageWorkbenchCommand, - images: images || [], - }); + return { + kind: "done", + result: await handleImageWorkbenchCommand({ + rawText: sourceText, + parsedCommand: parsedImageWorkbenchCommand, + images: images || [], + }), + }; } if ( @@ -257,54 +227,70 @@ export function useWorkspaceSendActions({ sendOptions, }) ) { - return true; + return { kind: "done", result: true }; } - let text = sourceText; - const preparedActiveContextPrompt = contextWorkspace.enabled - ? await contextWorkspace.prepareActiveContextPrompt() - : ""; - if (contextWorkspace.enabled && preparedActiveContextPrompt) { - text = applyActiveContextPrompt(text, preparedActiveContextPrompt); - } - - text = applyMentionedCharacterContext(text, mentionedCharacters); - text = applyRuntimeStyleMessagePrompt( - text, + const text = await buildWorkspaceSendText({ + sourceText, + contextWorkspace, + mentionedCharacters, runtimeStyleMessagePrompt, sendOptions, - ); + }); - if (browserRequirementMatch) { - void ensureBrowserAssistCanvas( - browserRequirementMatch.launchUrl || sourceText, - { - silent: true, - navigationMode: - browserRequirementMatch.launchUrl && - browserRequirementMatch.launchUrl !== sourceText - ? "explicit-url" - : "best-effort", - }, - ).catch((error) => { - console.warn( - "[AgentChatPage] 强浏览器任务发送前准备浏览器失败,继续由主流程处理:", - error, - ); - }); - } else { - preheatBrowserAssistInBackground({ - activeTheme, + primeBrowserAssistBeforeSend({ + activeTheme, + sourceText, + browserRequirementMatch, + ensureBrowserAssistCanvas, + }); + + return { + kind: "ready", + plan: { sourceText, - ensureBrowserAssistCanvas, - onError: (error) => { - console.warn( - "[AgentChatPage] 发送前预热浏览器协助失败,继续发送消息:", - error, - ); - }, - }); - } + text, + images: images || [], + sendBoundary, + effectiveToolPreferences, + effectiveWebSearch, + effectiveThinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + }, + }; + }, + [ + activeTheme, + chatToolPreferences, + contextWorkspace, + ensureBrowserAssistCanvas, + handleImageWorkbenchCommand, + input, + isBlockedByBrowserPreflight, + maybeStartBrowserTaskPreflight, + mentionedCharacters, + projectId, + resolveSendBoundary, + runtimeStyleMessagePrompt, + ], + ); + + const executeSendPlan = useCallback( + async (plan: WorkspaceSendPlan): Promise => { + const { + sourceText, + text, + images, + sendBoundary, + effectiveToolPreferences, + effectiveWebSearch, + effectiveThinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + } = plan; setRuntimeTeamDispatchPreview(null); @@ -315,57 +301,37 @@ export function useWorkspaceSendActions({ subagentEnabled: effectiveToolPreferences.subagent, }); if (preparedRuntimeTeamState) { - setRuntimeTeamDispatchPreview({ - key: preparedRuntimeTeamState.requestId, - prompt: sourceText, - images: images || [], - baseMessageCount: messagesCount, - status: preparedRuntimeTeamState.status, - formationState: preparedRuntimeTeamState, - failureMessage: - preparedRuntimeTeamState.errorMessage?.trim() || null, - }); + setRuntimeTeamDispatchPreview( + buildRuntimeTeamDispatchPreview( + preparedRuntimeTeamState, + sourceText, + images, + messagesCount, + ), + ); } setInput(""); setMentionedCharacters([]); - const existingHarnessMetadata = extractExistingHarnessMetadata({ - ...(workspaceRequestMetadataBase || {}), - ...(sendOptions?.requestMetadata || {}), + const nextRequestMetadata = buildWorkspaceRequestMetadata({ + workspaceRequestMetadataBase, + sendOptions: { + ...(sendOptions || {}), + toolPreferencesOverride: effectiveToolPreferences, + }, + effectiveToolPreferences, + mappedTheme, + isThemeWorkbench, + currentGateKey, + themeWorkbenchActiveQueueTitle, + contentId, + browserRequirementMatch: sendBoundary.browserRequirementMatch, + preferredTeamPresetId, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, }); - const nextRequestMetadata: Record = { - ...(workspaceRequestMetadataBase || {}), - ...(sendOptions?.requestMetadata || {}), - harness: buildHarnessRequestMetadata({ - base: existingHarnessMetadata, - theme: mappedTheme, - turnPurpose: sendOptions?.purpose, - preferences: { - webSearch: effectiveWebSearch, - thinking: effectiveThinking, - task: effectiveToolPreferences.task, - subagent: effectiveToolPreferences.subagent, - }, - sessionMode: isThemeWorkbench ? "theme_workbench" : "default", - gateKey: isThemeWorkbench ? currentGateKey : undefined, - runTitle: themeWorkbenchActiveQueueTitle?.trim() || undefined, - contentId: contentId || undefined, - browserRequirement: browserRequirementMatch?.requirement, - browserRequirementReason: browserRequirementMatch?.reason, - browserLaunchUrl: browserRequirementMatch?.launchUrl, - browserAssistProfileKey: - mappedTheme === "general" - ? GENERAL_BROWSER_ASSIST_PROFILE_KEY - : undefined, - preferredTeamPresetId, - selectedTeamId: selectedTeam?.id, - selectedTeamSource: selectedTeam?.source, - selectedTeamLabel, - selectedTeamSummary, - selectedTeamRoles: selectedTeam?.roles, - }), - }; const nextSendOptions: HandleSendOptions = { ...(sendOptions || {}), requestMetadata: nextRequestMetadata, @@ -373,7 +339,7 @@ export function useWorkspaceSendActions({ await sendMessage( text, - images || [], + images, effectiveWebSearch, effectiveThinking, false, @@ -406,39 +372,54 @@ export function useWorkspaceSendActions({ } }, [ - activeTheme, - chatToolPreferences, + _prepareRuntimeTeamBeforeSend, contentId, - contextWorkspace, currentGateKey, - ensureBrowserAssistCanvas, finalizeAfterSendSuccess, - handleImageWorkbenchCommand, - input, - isBlockedByBrowserPreflight, isThemeWorkbench, mappedTheme, - maybeStartBrowserTaskPreflight, - mentionedCharacters, messagesCount, preferredTeamPresetId, - _prepareRuntimeTeamBeforeSend, - projectId, - resolveSendBoundary, rollbackAfterSendFailure, - runtimeStyleMessagePrompt, selectedTeam, selectedTeamLabel, selectedTeamSummary, sendMessage, - setMentionedCharacters, setInput, + setMentionedCharacters, setRuntimeTeamDispatchPreview, themeWorkbenchActiveQueueTitle, workspaceRequestMetadataBase, ], ); + const handleSend = useCallback( + async ( + images, + webSearch, + thinking, + textOverride, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + ) => { + const resolution = await resolveSendExecutionPlan( + images, + webSearch, + thinking, + textOverride, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + ); + if (resolution.kind === "done") { + return resolution.result; + } + return executeSendPlan(resolution.plan); + }, + [executeSendPlan, resolveSendExecutionPlan], + ); + const handleRecommendationClick = useCallback( (shortLabel: string, fullPrompt: string) => { setInput(fullPrompt); diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx new file mode 100644 index 000000000..ac455f90f --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx @@ -0,0 +1,551 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { ServiceSkillHomeItem } from "../service-skills/types"; +import { useWorkspaceServiceSkillEntryActions } from "./useWorkspaceServiceSkillEntryActions"; + +const mockCreateAutomationJob = vi.fn(); +const mockCreateServiceSkillRun = vi.fn(); +const mockGetServiceSkillRun = vi.fn(); +const mockIsTerminalServiceSkillRunStatus = vi.fn(); +const mockCreateContent = vi.fn(); +const mockListProjects = vi.fn(); +const mockRecordServiceSkillAutomationLink = vi.fn(); +const mockToastSuccess = vi.fn(); +const mockToastError = vi.fn(); +const mockToastInfo = vi.fn(); +const mockToastLoading = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + info: (...args: unknown[]) => mockToastInfo(...args), + loading: (...args: unknown[]) => mockToastLoading(...args), + }, +})); + +vi.mock("@/lib/api/automation", () => ({ + createAutomationJob: (request: unknown) => mockCreateAutomationJob(request), +})); + +vi.mock("@/lib/api/serviceSkillRuns", () => ({ + createServiceSkillRun: (...args: unknown[]) => mockCreateServiceSkillRun(...args), + getServiceSkillRun: (...args: unknown[]) => mockGetServiceSkillRun(...args), + isTerminalServiceSkillRunStatus: (status: unknown) => + mockIsTerminalServiceSkillRunStatus(status), +})); + +vi.mock("@/lib/api/project", () => ({ + createContent: (request: unknown) => mockCreateContent(request), + listProjects: () => mockListProjects(), + getDefaultContentTypeForProject: (projectType: string) => { + switch (projectType) { + case "social-media": + return "post"; + case "video": + return "episode"; + case "knowledge": + case "general": + default: + return "document"; + } + }, +})); + +vi.mock("../service-skills/automationLinkStorage", () => ({ + recordServiceSkillAutomationLink: (input: unknown) => + mockRecordServiceSkillAutomationLink(input), +})); + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; +const DEFAULT_CHAT_TOOL_PREFERENCES: ChatToolPreferences = { + webSearch: false, + thinking: false, + task: false, + subagent: false, +}; + +function createProject(id = "project-1") { + return { + id, + name: "项目一", + workspaceType: "general", + rootPath: "", + isDefault: false, + createdAt: 1, + updatedAt: 1, + isFavorite: false, + isArchived: false, + tags: [], + }; +} + +function createBrowserServiceSkill(): ServiceSkillHomeItem { + return { + id: "github-repo-radar", + title: "GitHub 仓库线索检索", + summary: + "复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。", + category: "情报研究", + outputHint: "仓库列表 + 关键线索", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + defaultArtifactKind: "analysis", + themeTarget: "knowledge", + version: "seed-v1", + readinessRequirements: { + requiresBrowser: true, + requiresProject: true, + }, + siteCapabilityBinding: { + adapterName: "github/search", + autoRun: true, + requireAttachedSession: true, + saveMode: "current_content", + slotArgMap: { + repository_query: "query", + }, + fixedArgs: { + limit: 10, + }, + suggestedTitleTemplate: "GitHub 仓库线索 · {{repository_query}}", + }, + slotSchema: [ + { + key: "repository_query", + label: "检索主题", + type: "text", + required: true, + placeholder: "例如 browser assist mcp", + }, + ], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald", + runnerDescription: + "直接进入浏览器工作台,复用真实登录态执行站点脚本并沉淀结果。", + actionLabel: "启动采集", + automationStatus: null, + }; +} + +function createScheduledServiceSkill(): ServiceSkillHomeItem { + return { + id: "daily-trend-briefing", + title: "每日趋势摘要", + summary: "围绕指定平台与关键词输出趋势摘要。", + category: "社媒运营", + outputHint: "趋势摘要 + 调度建议", + source: "cloud_catalog", + runnerType: "scheduled", + defaultExecutorBinding: "automation_job", + executionLocation: "client_default", + defaultArtifactKind: "analysis", + themeTarget: "social-media", + version: "seed-v1", + slotSchema: [ + { + key: "platform", + label: "监测平台", + type: "platform", + required: true, + placeholder: "选择平台", + defaultValue: "x", + options: [{ value: "x", label: "X / Twitter" }], + }, + { + key: "industry_keywords", + label: "行业关键词", + type: "textarea", + required: true, + placeholder: "输入关键词", + }, + { + key: "schedule_time", + label: "推送时间", + type: "schedule_time", + required: false, + placeholder: "例如 每天 09:00", + defaultValue: "每天 09:00", + }, + ], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "本地计划任务", + runnerTone: "sky", + runnerDescription: "可直接创建本地定时任务,并回流到任务中心与工作区。", + actionLabel: "创建任务", + automationStatus: null, + }; +} + +function createCloudServiceSkill(): ServiceSkillHomeItem { + return { + id: "cloud-video-dubbing", + title: "云端视频配音", + summary: "把视频文案与素材提交到云端,生成一版可继续加工的配音结果。", + category: "视频创作", + outputHint: "配音文案 + 结果摘要", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "cloud_scene", + executionLocation: "cloud_required", + defaultArtifactKind: "brief", + themeTarget: "video", + version: "seed-v1", + slotSchema: [ + { + key: "reference_video", + label: "参考视频链接/素材", + type: "url", + required: true, + placeholder: "输入视频链接", + }, + ], + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "云端托管执行", + runnerTone: "slate", + runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", + actionLabel: "提交云端", + automationStatus: null, + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType< + typeof useWorkspaceServiceSkillEntryActions + > | null = null; + + const defaultProps: HookProps = { + activeTheme: "general", + creationMode: "guided", + projectId: "project-1", + contentId: "content-current", + input: "请结合当前上下文继续", + chatToolPreferences: DEFAULT_CHAT_TOOL_PREFERENCES, + onNavigate: vi.fn(), + recordServiceSkillUsage: vi.fn(), + }; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceServiceSkillEntryActions(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockCreateAutomationJob.mockResolvedValue({ + id: "automation-job-1", + name: "每日趋势摘要|定时执行", + }); + mockCreateServiceSkillRun.mockReset(); + mockGetServiceSkillRun.mockReset(); + mockIsTerminalServiceSkillRunStatus.mockReset(); + mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) => + ["success", "failed", "canceled", "timeout"].includes(status), + ); + mockCreateContent.mockResolvedValue({ + id: "content-created-by-service-skill", + }); + mockListProjects.mockResolvedValue([createProject()]); + mockRecordServiceSkillAutomationLink.mockReset(); + mockToastSuccess.mockReset(); + mockToastError.mockReset(); + mockToastInfo.mockReset(); + mockToastLoading.mockReset(); + mockToastLoading.mockImplementation(() => "toast-loading"); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +describe("useWorkspaceServiceSkillEntryActions", () => { + it("浏览器站点型技能在已有 contentId 时应复用当前主稿", async () => { + const onNavigate = vi.fn(); + const recordServiceSkillUsage = vi.fn(); + const { render, getValue } = renderHook({ + onNavigate, + recordServiceSkillUsage, + }); + await render(); + + await act(async () => { + await getValue().handleServiceSkillLaunch(createBrowserServiceSkill(), { + repository_query: "browser assist mcp", + }); + }); + + expect(mockCreateContent).not.toHaveBeenCalled(); + expect(onNavigate).toHaveBeenCalledWith("browser-runtime", { + projectId: "project-1", + contentId: "content-current", + initialAdapterName: "github/search", + initialArgs: { + query: "browser assist mcp", + limit: 10, + }, + initialAutoRun: true, + initialRequireAttachedSession: true, + initialSaveTitle: undefined, + }); + expect(recordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "github-repo-radar", + runnerType: "instant", + }); + }); + + it("cloud_required 服务型技能成功后应回流本地工作区", async () => { + const onNavigate = vi.fn(); + const recordServiceSkillUsage = vi.fn(); + mockCreateServiceSkillRun.mockResolvedValue({ + id: "service-skill-run-cloud-1", + status: "success", + outputSummary: "云端结果已生成", + outputText: "# 云端视频配音\n\n第一版成稿", + finishedAt: "2026-03-26T01:02:03.000Z", + }); + + const { render, getValue } = renderHook({ + activeTheme: "video", + onNavigate, + recordServiceSkillUsage, + }); + await render(); + + await act(async () => { + await getValue().handleServiceSkillLaunch(createCloudServiceSkill(), { + reference_video: "https://example.com/cloud-video", + }); + }); + + expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( + "cloud-video-dubbing", + expect.stringContaining("[服务型技能] 云端视频配音"), + ); + expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( + "cloud-video-dubbing", + expect.stringContaining("- 参考视频链接/素材: https://example.com/cloud-video"), + ); + expect(mockCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "project-1", + title: "云端视频配音", + content_type: "episode", + body: "# 云端视频配音\n\n第一版成稿", + metadata: expect.objectContaining({ + source: "service_skill", + serviceSkill: expect.objectContaining({ + id: "cloud-video-dubbing", + executionLocation: "cloud_required", + themeTarget: "video", + }), + cloudRun: expect.objectContaining({ + id: "service-skill-run-cloud-1", + status: "success", + outputSummary: "云端结果已生成", + finishedAt: "2026-03-26T01:02:03.000Z", + }), + }), + }), + ); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + projectId: "project-1", + contentId: "content-created-by-service-skill", + theme: "video", + initialCreationMode: "guided", + initialRequestMetadata: { + artifact: { + artifact_mode: "draft", + artifact_kind: "brief", + workbench_surface: "right_panel", + }, + }, + }), + ); + expect(recordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "cloud-video-dubbing", + runnerType: "instant", + }); + expect(mockToastLoading).toHaveBeenCalledWith( + "正在提交 云端视频配音 到云端...", + ); + expect(mockToastSuccess).toHaveBeenCalledWith( + "云端视频配音 云端运行完成:云端结果已生成,正在回流本地工作区。", + { + id: "toast-loading", + }, + ); + }); + + it("本地自动化型技能在已有 contentId 时应复用当前主稿创建任务并进入工作区", async () => { + const onNavigate = vi.fn(); + const recordServiceSkillUsage = vi.fn(); + const { render, getValue } = renderHook({ + onNavigate, + recordServiceSkillUsage, + }); + await render(); + + await act(async () => { + await getValue().handleServiceSkillAutomationSetup( + createScheduledServiceSkill(), + { + platform: "x", + industry_keywords: "AI Agent,创作者工具", + schedule_time: "每天 09:00", + }, + ); + }); + + expect(getValue().automationDialogOpen).toBe(true); + + await act(async () => { + await getValue().handleAutomationDialogSubmit({ + mode: "create", + request: { + name: "每日趋势摘要|定时执行", + description: "围绕指定平台与关键词输出趋势摘要。", + workspace_id: "project-1", + execution_mode: "skill", + schedule: { + kind: "cron", + expr: "00 09 * * *", + tz: "Asia/Shanghai", + }, + payload: { + kind: "agent_turn", + prompt: "自动化 prompt", + system_prompt: "", + web_search: false, + }, + delivery: { + mode: "none", + best_effort: true, + output_schema: "text", + output_format: "text", + }, + }, + }); + }); + + expect(mockCreateContent).not.toHaveBeenCalled(); + expect(mockCreateAutomationJob).toHaveBeenCalledWith( + expect.objectContaining({ + workspace_id: "project-1", + execution_mode: "skill", + payload: expect.objectContaining({ + kind: "agent_turn", + content_id: "content-current", + request_metadata: expect.objectContaining({ + service_skill: expect.objectContaining({ + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + slot_values: [ + { + key: "platform", + label: "监测平台", + value: "X / Twitter", + }, + { + key: "industry_keywords", + label: "行业关键词", + value: "AI Agent,创作者工具", + }, + { + key: "schedule_time", + label: "推送时间", + value: "每天 09:00", + }, + ], + slot_summary: [ + "监测平台: X / Twitter", + "行业关键词: AI Agent,创作者工具", + "推送时间: 每天 09:00", + ], + user_input: "请结合当前上下文继续", + }), + harness: expect.objectContaining({ + theme: "social-media", + session_mode: "theme_workbench", + content_id: "content-current", + }), + }), + }), + }), + ); + expect(mockRecordServiceSkillAutomationLink).toHaveBeenCalledWith({ + skillId: "daily-trend-briefing", + jobId: "automation-job-1", + jobName: "每日趋势摘要|定时执行", + }); + expect(recordServiceSkillUsage).toHaveBeenCalledWith({ + skillId: "daily-trend-briefing", + runnerType: "scheduled", + }); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + projectId: "project-1", + contentId: "content-current", + theme: "social-media", + initialCreationMode: "guided", + initialUserPrompt: expect.stringContaining("[服务型技能] 每日趋势摘要"), + autoRunInitialPromptOnMount: true, + }), + ); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts new file mode 100644 index 000000000..093a17d33 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts @@ -0,0 +1,817 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { createAutomationJob } from "@/lib/api/automation"; +import { + createServiceSkillRun, + getServiceSkillRun, + isTerminalServiceSkillRunStatus, + type ServiceSkillRun, +} from "@/lib/api/serviceSkillRuns"; +import { + createContent, + listProjects, + type Project, +} from "@/lib/api/project"; +import { + type AutomationJobDialogInitialValues, + type AutomationJobDialogSubmit, +} from "@/components/settings-v2/system/automation/AutomationJobDialog"; +import type { BrowserRuntimePageParams, Page, PageParams } from "@/types/page"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { CreationMode } from "../components/types"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { + resolveHomeShellWorkspaceEntry, + type HomeShellEnterWorkspacePayload, +} from "../homeShellEntry"; +import { composeServiceSkillPrompt } from "../service-skills/promptComposer"; +import { + buildServiceSkillAutomationAgentTurnPayloadContext, + buildServiceSkillAutomationInitialValues, + supportsServiceSkillLocalAutomation, +} from "../service-skills/automationDraft"; +import { recordServiceSkillAutomationLink } from "../service-skills/automationLinkStorage"; +import { recordServiceSkillCloudRun } from "../service-skills/cloudRunStorage"; +import { buildServiceSkillWorkspaceSeed } from "../service-skills/workspaceLaunch"; +import { + buildServiceSkillSiteCapabilityArgs, + buildServiceSkillSiteCapabilitySaveTitle, + isServiceSkillSiteCapabilityBound, +} from "../service-skills/siteCapabilityBinding"; +import type { + ServiceSkillHomeItem, + ServiceSkillSlotValues, +} from "../service-skills/types"; + +const SERVICE_SKILL_RUN_STATUS_LABELS: Record = { + queued: "排队中", + running: "运行中", + success: "已完成", + failed: "执行失败", + canceled: "已取消", + timeout: "已超时", +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return "请稍后重试"; +} + +function normalizeOptionalText(value?: string | null): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim(); + return normalized ? normalized : undefined; +} + +function buildServiceSkillCloudResultBody( + skill: ServiceSkillHomeItem, + run: ServiceSkillRun, +): string { + return ( + normalizeOptionalText(run.outputText) || + normalizeOptionalText(run.outputSummary) || + `# ${skill.title}\n\n云端结果已生成。` + ); +} + +function buildServiceSkillCloudResultMetadata( + run: ServiceSkillRun, +): Record { + return { + cloudRun: { + id: run.id, + status: run.status, + executorKind: run.executorKind ?? null, + outputSummary: normalizeOptionalText(run.outputSummary) ?? null, + errorCode: run.errorCode ?? null, + errorMessage: run.errorMessage ?? null, + startedAt: run.startedAt ?? null, + finishedAt: run.finishedAt ?? null, + updatedAt: run.updatedAt ?? null, + }, + }; +} + +function resolveFallbackProjectType(theme?: string): Project["workspaceType"] { + switch (theme) { + case "social-media": + case "poster": + case "music": + case "knowledge": + case "planning": + case "document": + case "video": + case "novel": + case "general": + return theme; + default: + return "general"; + } +} + +function buildFallbackAutomationWorkspace( + projectId: string, + theme?: string, +): Project { + return { + id: projectId, + name: projectId, + workspaceType: resolveFallbackProjectType(theme), + rootPath: "", + isDefault: false, + createdAt: 0, + updatedAt: 0, + isFavorite: false, + isArchived: false, + tags: [], + }; +} + +function prioritizeAutomationWorkspaces( + workspaces: Project[], + projectId?: string | null, + theme?: string, +): Project[] { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + return workspaces; + } + + const matched = workspaces.find( + (workspace) => workspace.id === normalizedProjectId, + ); + const fallbackWorkspace = + matched ?? buildFallbackAutomationWorkspace(normalizedProjectId, theme); + const remaining = workspaces.filter( + (workspace) => workspace.id !== normalizedProjectId, + ); + + return [fallbackWorkspace, ...remaining]; +} + +function getServiceSkillRunStatusLabel(status: string): string { + return SERVICE_SKILL_RUN_STATUS_LABELS[status] ?? status; +} + +function buildServiceSkillRunSuccessMessage( + skill: ServiceSkillHomeItem, + run: ServiceSkillRun, +): string { + const summary = run.outputSummary || run.outputText || run.inputSummary; + if (summary) { + return `${skill.title} 云端运行完成:${summary},正在回流本地工作区。`; + } + + return `${skill.title} 云端运行完成,正在回流本地工作区。`; +} + +interface PendingServiceSkillAutomationLaunch { + skill: ServiceSkillHomeItem; + prompt: string; + slotValues: ServiceSkillSlotValues; + userInput?: string; + usage: { + skillId: string; + runnerType: ServiceSkillHomeItem["runnerType"]; + }; +} + +interface UseWorkspaceServiceSkillEntryActionsParams { + activeTheme: string; + creationMode: CreationMode; + projectId?: string | null; + contentId?: string | null; + input: string; + chatToolPreferences: ChatToolPreferences; + onNavigate?: (page: Page, params?: PageParams) => void; + recordServiceSkillUsage: (input: { + skillId: string; + runnerType: ServiceSkillHomeItem["runnerType"]; + }) => void; +} + +export function useWorkspaceServiceSkillEntryActions({ + activeTheme, + creationMode, + projectId, + contentId, + input, + chatToolPreferences, + onNavigate, + recordServiceSkillUsage, +}: UseWorkspaceServiceSkillEntryActionsParams) { + const [selectedServiceSkill, setSelectedServiceSkill] = + useState(null); + const [serviceSkillDialogOpen, setServiceSkillDialogOpen] = useState(false); + const [automationDialogOpen, setAutomationDialogOpen] = useState(false); + const [automationDialogInitialValues, setAutomationDialogInitialValues] = + useState(null); + const [automationWorkspaces, setAutomationWorkspaces] = useState( + [], + ); + const [automationJobSaving, setAutomationJobSaving] = useState(false); + const [pendingServiceSkillAutomation, setPendingServiceSkillAutomation] = + useState(null); + + const currentProjectId = normalizeProjectId(projectId); + const currentContentId = contentId?.trim() || null; + + const navigateToServiceSkillWorkspace = useCallback( + (payload: HomeShellEnterWorkspacePayload): boolean => { + const resolved = resolveHomeShellWorkspaceEntry({ + projectId: currentProjectId, + activeTheme, + creationMode, + defaultToolPreferences: chatToolPreferences, + payload, + }); + + if (!resolved.ok) { + if (resolved.reason === "missing_project") { + toast.error("缺少项目工作区,请先选择项目后再启动服务技能。"); + return false; + } + toast.error("服务技能缺少可执行内容,请先补齐参数后重试。"); + return false; + } + + if (!onNavigate) { + toast.error("当前入口暂不支持切换服务技能工作区,请从桌面主界面重试。"); + return false; + } + + onNavigate("agent", resolved.navigationParams); + return true; + }, + [ + activeTheme, + chatToolPreferences, + creationMode, + currentProjectId, + onNavigate, + ], + ); + + const createServiceSkillSeededContent = useCallback( + async ( + skill: ServiceSkillHomeItem, + targetProjectId?: string | null, + options?: { + body?: string; + metadata?: Record; + }, + ) => { + const normalizedProjectId = normalizeProjectId( + targetProjectId ?? currentProjectId, + ); + const seed = buildServiceSkillWorkspaceSeed( + skill, + skill.themeTarget ?? activeTheme, + ); + + if (!normalizedProjectId || !seed) { + return null; + } + + const mergedMetadata = { + ...(seed.metadata ?? {}), + ...(options?.metadata ?? {}), + }; + + return createContent({ + project_id: normalizedProjectId, + title: seed.title, + content_type: seed.contentType, + body: options?.body ?? "", + metadata: + Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined, + }); + }, + [activeTheme, currentProjectId], + ); + + const prepareServiceSkillWorkspacePayload = useCallback( + async ( + skill: ServiceSkillHomeItem, + prompt: string, + options?: { + contentId?: string | null; + projectId?: string | null; + }, + ): Promise => { + const normalizedProjectId = normalizeProjectId( + options?.projectId ?? currentProjectId, + ); + const existingContentId = + options?.contentId?.trim() || currentContentId || undefined; + const seed = buildServiceSkillWorkspaceSeed( + skill, + skill.themeTarget ?? activeTheme, + ); + + if (existingContentId) { + return { + prompt, + contentId: existingContentId, + themeOverride: skill.themeTarget, + initialRequestMetadata: seed?.requestMetadata, + autoRunInitialPromptOnMount: true, + }; + } + + if (!normalizedProjectId || !seed) { + return { + prompt, + themeOverride: skill.themeTarget, + initialRequestMetadata: seed?.requestMetadata, + autoRunInitialPromptOnMount: true, + }; + } + + const created = await createServiceSkillSeededContent( + skill, + normalizedProjectId, + ); + + if (!created) { + return { + prompt, + themeOverride: skill.themeTarget, + initialRequestMetadata: seed.requestMetadata, + autoRunInitialPromptOnMount: true, + }; + } + + return { + prompt, + contentId: created.id, + themeOverride: skill.themeTarget, + initialRequestMetadata: seed.requestMetadata, + autoRunInitialPromptOnMount: true, + }; + }, + [activeTheme, createServiceSkillSeededContent, currentContentId, currentProjectId], + ); + + const prepareServiceSkillCloudResultWorkspacePayload = useCallback( + async ( + skill: ServiceSkillHomeItem, + run: ServiceSkillRun, + ): Promise => { + const seed = buildServiceSkillWorkspaceSeed( + skill, + skill.themeTarget ?? activeTheme, + ); + + if (!currentProjectId || !seed) { + return null; + } + + const created = await createServiceSkillSeededContent( + skill, + currentProjectId, + { + body: buildServiceSkillCloudResultBody(skill, run), + metadata: buildServiceSkillCloudResultMetadata(run), + }, + ); + + if (!created) { + return null; + } + + return { + contentId: created.id, + themeOverride: skill.themeTarget, + initialRequestMetadata: seed.requestMetadata, + }; + }, + [activeTheme, createServiceSkillSeededContent, currentProjectId], + ); + + const handleServiceSkillSelect = useCallback((skill: ServiceSkillHomeItem) => { + setSelectedServiceSkill(skill); + setServiceSkillDialogOpen(true); + }, []); + + const handleServiceSkillDialogOpenChange = useCallback((open: boolean) => { + setServiceSkillDialogOpen(open); + if (!open) { + setSelectedServiceSkill(null); + } + }, []); + + const handleServiceSkillBrowserRuntimeLaunch = useCallback( + async ( + skill: ServiceSkillHomeItem, + slotValues: ServiceSkillSlotValues, + ): Promise => { + if (!isServiceSkillSiteCapabilityBound(skill)) { + return; + } + + if (!onNavigate) { + toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。"); + return; + } + + if ( + skill.readinessRequirements?.requiresProject && + !currentProjectId + ) { + toast.error("缺少项目工作区,请先选择项目后再启动浏览器采集。"); + return; + } + + const binding = skill.siteCapabilityBinding; + const saveMode = binding.saveMode ?? "project_resource"; + const initialArgs = buildServiceSkillSiteCapabilityArgs( + skill, + slotValues, + ); + const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle( + skill, + slotValues, + ); + let nextContentId = currentContentId || undefined; + + if ( + saveMode === "current_content" && + !nextContentId && + currentProjectId + ) { + try { + const created = await createServiceSkillSeededContent( + skill, + currentProjectId, + ); + nextContentId = created?.id ?? undefined; + } catch (error) { + toast.error(`准备浏览器采集主稿失败:${getErrorMessage(error)}`); + return; + } + } + + const navigationParams: BrowserRuntimePageParams = { + projectId: currentProjectId ?? undefined, + contentId: nextContentId, + initialAdapterName: binding.adapterName, + initialArgs, + initialAutoRun: binding.autoRun ?? false, + initialRequireAttachedSession: binding.requireAttachedSession ?? false, + initialSaveTitle: nextContentId ? undefined : initialSaveTitle, + }; + + onNavigate("browser-runtime", navigationParams); + recordServiceSkillUsage({ + skillId: skill.id, + runnerType: skill.runnerType, + }); + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + }, + [ + createServiceSkillSeededContent, + currentContentId, + currentProjectId, + onNavigate, + recordServiceSkillUsage, + ], + ); + + const handleServiceSkillLaunch = useCallback( + async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => { + if (isServiceSkillSiteCapabilityBound(skill)) { + await handleServiceSkillBrowserRuntimeLaunch(skill, slotValues); + return; + } + + const prompt = composeServiceSkillPrompt({ + skill, + slotValues, + userInput: input.trim() || undefined, + }); + + if (skill.executionLocation === "cloud_required") { + const toastId = toast.loading(`正在提交 ${skill.title} 到云端...`); + + try { + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + + let run = await createServiceSkillRun(skill.id, prompt); + recordServiceSkillCloudRun(skill.id, run); + recordServiceSkillUsage({ + skillId: skill.id, + runnerType: skill.runnerType, + }); + + if (!isTerminalServiceSkillRunStatus(run.status)) { + toast.loading( + `${skill.title} ${getServiceSkillRunStatusLabel(run.status)},正在等待结果...`, + { + id: toastId, + }, + ); + + for (let attempt = 0; attempt < 12; attempt += 1) { + await sleep(2_000); + run = await getServiceSkillRun(run.id); + recordServiceSkillCloudRun(skill.id, run); + if (isTerminalServiceSkillRunStatus(run.status)) { + break; + } + } + } + + if (run.status === "success") { + let workspacePayload: HomeShellEnterWorkspacePayload | null = null; + let workspaceErrorMessage: string | null = null; + + try { + workspacePayload = + await prepareServiceSkillCloudResultWorkspacePayload( + skill, + run, + ); + } catch (error) { + workspaceErrorMessage = getErrorMessage(error); + } + + toast.success(buildServiceSkillRunSuccessMessage(skill, run), { + id: toastId, + }); + + if (workspacePayload) { + const entered = navigateToServiceSkillWorkspace(workspacePayload); + if (!entered) { + toast.error( + "云端结果已生成,但进入工作区失败,请稍后手动打开。", + ); + } + } else if (workspaceErrorMessage) { + toast.error( + `云端结果已生成,但回流本地工作区失败:${workspaceErrorMessage}`, + ); + } + return; + } + + if (isTerminalServiceSkillRunStatus(run.status)) { + throw new Error( + run.errorMessage || + `${skill.title} ${getServiceSkillRunStatusLabel(run.status)}`, + ); + } + + toast.info( + `${skill.title} 已提交云端,当前仍在 ${getServiceSkillRunStatusLabel(run.status)}。`, + { + id: toastId, + }, + ); + } catch (error) { + toast.error(`提交云端运行失败:${getErrorMessage(error)}`, { + id: toastId, + }); + } + return; + } + + if (skill.runnerType !== "instant") { + toast.info( + "当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。", + ); + } + + let workspacePayload: HomeShellEnterWorkspacePayload; + try { + workspacePayload = await prepareServiceSkillWorkspacePayload( + skill, + prompt, + ); + } catch (error) { + toast.error(`准备服务型技能工作区失败:${getErrorMessage(error)}`); + return; + } + + const entered = navigateToServiceSkillWorkspace(workspacePayload); + if (!entered) { + return; + } + + recordServiceSkillUsage({ + skillId: skill.id, + runnerType: skill.runnerType, + }); + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + }, + [ + handleServiceSkillBrowserRuntimeLaunch, + input, + navigateToServiceSkillWorkspace, + prepareServiceSkillCloudResultWorkspacePayload, + prepareServiceSkillWorkspacePayload, + recordServiceSkillUsage, + ], + ); + + const handleServiceSkillAutomationSetup = useCallback( + async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => { + if (!supportsServiceSkillLocalAutomation(skill)) { + await handleServiceSkillLaunch(skill, slotValues); + return; + } + + if (!currentProjectId) { + toast.error("缺少项目工作区,请先选择项目后再创建本地自动化任务。"); + return; + } + + const prompt = composeServiceSkillPrompt({ + skill, + slotValues, + userInput: input.trim() || undefined, + }); + const userInput = input.trim() || undefined; + + try { + let workspaces: Project[]; + try { + workspaces = prioritizeAutomationWorkspaces( + await listProjects(), + currentProjectId, + skill.themeTarget ?? activeTheme, + ); + } catch { + workspaces = [ + buildFallbackAutomationWorkspace( + currentProjectId, + skill.themeTarget ?? activeTheme, + ), + ]; + } + + setAutomationWorkspaces(workspaces); + setAutomationDialogInitialValues( + buildServiceSkillAutomationInitialValues({ + skill, + slotValues, + userInput, + workspaceId: currentProjectId, + }), + ); + setPendingServiceSkillAutomation({ + skill, + prompt, + slotValues, + userInput, + usage: { + skillId: skill.id, + runnerType: skill.runnerType, + }, + }); + setServiceSkillDialogOpen(false); + setSelectedServiceSkill(null); + setAutomationDialogOpen(true); + } catch (error) { + toast.error(`准备本地自动化任务失败:${getErrorMessage(error)}`); + } + }, + [ + activeTheme, + currentProjectId, + handleServiceSkillLaunch, + input, + ], + ); + + const handleAutomationDialogOpenChange = useCallback((open: boolean) => { + setAutomationDialogOpen(open); + if (!open) { + setAutomationDialogInitialValues(null); + setPendingServiceSkillAutomation(null); + } + }, []); + + const handleAutomationDialogSubmit = useCallback( + async (payload: AutomationJobDialogSubmit) => { + if (payload.mode !== "create") { + throw new Error("服务型技能入口当前只支持创建新的本地自动化任务"); + } + + setAutomationJobSaving(true); + try { + const pendingLaunch = pendingServiceSkillAutomation; + let request = payload.request; + let automationContentId = currentContentId; + + if (pendingLaunch && request.payload.kind === "agent_turn") { + if (!automationContentId) { + const createdContent = await createServiceSkillSeededContent( + pendingLaunch.skill, + request.workspace_id, + ); + automationContentId = createdContent?.id ?? null; + } + + request = { + ...request, + payload: { + ...request.payload, + ...buildServiceSkillAutomationAgentTurnPayloadContext({ + skill: pendingLaunch.skill, + slotValues: pendingLaunch.slotValues, + userInput: pendingLaunch.userInput, + contentId: automationContentId, + }), + }, + }; + } + + const createdJob = await createAutomationJob(request); + toast.success(`本地自动化任务已创建:${createdJob.name}`); + + setAutomationDialogOpen(false); + setAutomationDialogInitialValues(null); + setPendingServiceSkillAutomation(null); + + if (!pendingLaunch) { + return; + } + + recordServiceSkillAutomationLink({ + skillId: pendingLaunch.usage.skillId, + jobId: createdJob.id, + jobName: createdJob.name, + }); + recordServiceSkillUsage(pendingLaunch.usage); + + let workspacePayload: HomeShellEnterWorkspacePayload; + try { + workspacePayload = await prepareServiceSkillWorkspacePayload( + pendingLaunch.skill, + pendingLaunch.prompt, + { + contentId: automationContentId, + projectId: request.workspace_id, + }, + ); + } catch (error) { + toast.error( + `自动化任务已创建,但准备工作区失败:${getErrorMessage(error)}`, + ); + return; + } + + const entered = navigateToServiceSkillWorkspace(workspacePayload); + if (!entered) { + toast.error("自动化任务已创建,但进入工作区失败,请稍后手动打开。"); + } + } catch (error) { + toast.error(`创建本地自动化任务失败:${getErrorMessage(error)}`); + throw error; + } finally { + setAutomationJobSaving(false); + } + }, + [ + createServiceSkillSeededContent, + currentContentId, + navigateToServiceSkillWorkspace, + pendingServiceSkillAutomation, + prepareServiceSkillWorkspacePayload, + recordServiceSkillUsage, + ], + ); + + return { + selectedServiceSkill, + serviceSkillDialogOpen, + automationDialogOpen, + automationDialogInitialValues, + automationWorkspaces, + automationJobSaving, + handleServiceSkillSelect, + handleServiceSkillDialogOpenChange, + handleServiceSkillLaunch, + handleServiceSkillAutomationSetup, + handleAutomationDialogOpenChange, + handleAutomationDialogSubmit, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts index 99bf97e7b..63c922a25 100644 --- a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts +++ b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts @@ -27,6 +27,7 @@ import { isThemeWorkbenchPrimaryDocumentArtifact, resolveTaskFileType, } from "./themeWorkbenchHelpers"; +import type { GeneralArtifactSyncResult } from "./useWorkspaceGeneralResourceSync"; interface ThemeWorkbenchActiveQueueSummary { run_id?: string | null; @@ -55,7 +56,7 @@ interface UseWorkspaceWriteFileActionParams { syncGeneralArtifactToResource: (input: { rawFilePath: string; preferredName?: string; - }) => Promise; + }) => Promise; upsertGeneralArtifact: (artifact: Artifact) => void; setSelectedArtifactId: (artifactId: string | null) => void; setArtifactViewMode: Dispatch>; @@ -346,7 +347,9 @@ export function useWorkspaceWriteFileAction({ } setTaskFiles((previous) => { - const existingIndex = previous.findIndex((file) => file.name === fileName); + const existingIndex = previous.findIndex( + (file) => file.name === fileName, + ); if (existingIndex >= 0) { const existing = previous[existingIndex]; diff --git a/src/components/agent/chat/workspace/workbenchPreview.tsx b/src/components/agent/chat/workspace/workbenchPreview.tsx index 43c2d6b55..36ed794ed 100644 --- a/src/components/agent/chat/workspace/workbenchPreview.tsx +++ b/src/components/agent/chat/workspace/workbenchPreview.tsx @@ -24,7 +24,9 @@ interface ArtifactWorkbenchPreviewProps { artifact: Artifact; currentCanvasArtifact: Artifact | null; displayedCanvasArtifact: Artifact | null; - artifactOverlay: ComponentProps["overlay"] | null; + artifactOverlay: + | ComponentProps["overlay"] + | null; showPreviousVersionBadge: boolean; artifactViewMode: ComponentProps["viewMode"]; onArtifactViewModeChange: NonNullable< @@ -44,6 +46,10 @@ interface ArtifactWorkbenchPreviewProps { onJumpToTimelineItem?: (itemId: string) => void; onCloseCanvas: () => void; stackedWorkbenchTrigger?: ReactNode; + renderToolbarActions?: (params: { + artifact: Artifact; + document: ArtifactDocumentV1 | null; + }) => ReactNode; } export function ArtifactWorkbenchPreview({ @@ -63,6 +69,7 @@ export function ArtifactWorkbenchPreview({ onJumpToTimelineItem, onCloseCanvas, stackedWorkbenchTrigger, + renderToolbarActions, }: ArtifactWorkbenchPreviewProps) { const isLiveSelectedArtifact = currentCanvasArtifact?.id === artifact.id && @@ -78,16 +85,25 @@ export function ArtifactWorkbenchPreview({ const isBrowserAssistArtifact = previewArtifact.type === "browser_assist"; const isArtifactStreaming = Boolean( isLiveSelectedArtifact && - currentCanvasArtifact && - displayedCanvasArtifact && - currentCanvasArtifact.id === displayedCanvasArtifact.id && - currentCanvasArtifact.id === previewArtifact.id && - currentCanvasArtifact.status === "streaming", + currentCanvasArtifact && + displayedCanvasArtifact && + currentCanvasArtifact.id === displayedCanvasArtifact.id && + currentCanvasArtifact.id === previewArtifact.id && + currentCanvasArtifact.status === "streaming", ); const artifactDocument = resolveArtifactProtocolDocumentPayload({ content: previewArtifact.content, metadata: previewArtifact.meta, }); + const combinedActionsSlot = ( + <> + {renderToolbarActions?.({ + artifact: previewArtifact, + document: artifactDocument, + })} + {stackedWorkbenchTrigger} + + ); if (isBrowserAssistArtifact) { return wrapPreviewWithWorkbenchTrigger( @@ -114,7 +130,9 @@ export function ArtifactWorkbenchPreview({ artifact={previewArtifact} artifactOverlay={isLiveSelectedArtifact ? artifactOverlay : null} isStreaming={isArtifactStreaming} - showPreviousVersionBadge={isLiveSelectedArtifact && showPreviousVersionBadge} + showPreviousVersionBadge={ + isLiveSelectedArtifact && showPreviousVersionBadge + } viewMode={artifactViewMode} onViewModeChange={onArtifactViewModeChange} previewSize={artifactPreviewSize} @@ -125,7 +143,7 @@ export function ArtifactWorkbenchPreview({ blockFocusRequestKey={blockFocusRequestKey} onJumpToTimelineItem={onJumpToTimelineItem} onCloseCanvas={onCloseCanvas} - actionsSlot={stackedWorkbenchTrigger} + actionsSlot={combinedActionsSlot} /> ); } @@ -137,7 +155,8 @@ export function ArtifactWorkbenchPreview({ artifact={toolbarArtifact} onClose={onCloseCanvas} isStreaming={Boolean( - isLiveSelectedArtifact && currentCanvasArtifact?.status === "streaming", + isLiveSelectedArtifact && + currentCanvasArtifact?.status === "streaming", )} viewMode={artifactViewMode} onViewModeChange={onArtifactViewModeChange} @@ -149,7 +168,7 @@ export function ArtifactWorkbenchPreview({ ? "预览上一版本" : undefined } - actionsSlot={stackedWorkbenchTrigger} + actionsSlot={combinedActionsSlot} />
> +>; + +export interface ContextWorkspaceSummary { + enabled: boolean; + prepareActiveContextPrompt: () => Promise; +} + +export interface EnsureBrowserAssistCanvasOptions { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; +} + +interface BuildWorkspaceSendTextOptions { + sourceText: string; + contextWorkspace: ContextWorkspaceSummary; + mentionedCharacters: Character[]; + runtimeStyleMessagePrompt: string; + sendOptions?: HandleSendOptions; +} + +interface PrimeBrowserAssistBeforeSendOptions { + activeTheme: string; + sourceText: string; + browserRequirementMatch?: { + requirement: "optional" | "required" | "required_with_user_step"; + reason: string; + launchUrl: string; + } | null; + ensureBrowserAssistCanvas: ( + target: string, + options?: EnsureBrowserAssistCanvasOptions, + ) => Promise; +} + +interface BuildWorkspaceRequestMetadataOptions { + workspaceRequestMetadataBase?: Record; + sendOptions?: HandleSendOptions; + effectiveToolPreferences: ChatToolPreferences; + mappedTheme: ThemeType; + isThemeWorkbench: boolean; + currentGateKey: string; + themeWorkbenchActiveQueueTitle?: string; + contentId?: string | null; + browserRequirementMatch?: { + requirement: "optional" | "required" | "required_with_user_step"; + reason: string; + launchUrl: string; + } | null; + preferredTeamPresetId?: string | null; + selectedTeam?: TeamDefinition | null; + selectedTeamLabel?: string; + selectedTeamSummary?: string; +} + +function applyActiveContextPrompt( + text: string, + activeContextPrompt: string, +): string { + if (!activeContextPrompt.trim()) { + return text; + } + + const slashCommandMatch = text.match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/); + if (slashCommandMatch) { + const [, skillName, skillArgs] = slashCommandMatch; + const mergedArgs = [activeContextPrompt, skillArgs.trim()] + .filter((part) => part.length > 0) + .join("\n\n"); + return `/${skillName} ${mergedArgs}`.trim(); + } + + return `${activeContextPrompt}\n\n${text}`; +} + +function applyMentionedCharacterContext( + text: string, + mentionedCharacters: Character[], +): string { + if (mentionedCharacters.length === 0) { + return text; + } + + const characterContext = mentionedCharacters + .map((char) => { + let context = `角色:${char.name}`; + if (char.description) context += `\n简介:${char.description}`; + if (char.personality) context += `\n性格:${char.personality}`; + if (char.background) context += `\n背景:${char.background}`; + return context; + }) + .join("\n\n"); + + return `[角色上下文]\n${characterContext}\n\n[用户输入]\n${text}`; +} + +function applyRuntimeStyleMessagePrompt( + text: string, + runtimeStyleMessagePrompt: string, + sendOptions?: HandleSendOptions, +): string { + if (sendOptions?.purpose || !runtimeStyleMessagePrompt.trim()) { + return text; + } + + return `[本次任务风格要求]\n${runtimeStyleMessagePrompt}\n\n[用户输入]\n${text}`; +} + +export async function buildWorkspaceSendText( + options: BuildWorkspaceSendTextOptions, +): Promise { + const { + sourceText, + contextWorkspace, + mentionedCharacters, + runtimeStyleMessagePrompt, + sendOptions, + } = options; + + let text = sourceText; + const preparedActiveContextPrompt = contextWorkspace.enabled + ? await contextWorkspace.prepareActiveContextPrompt() + : ""; + if (contextWorkspace.enabled && preparedActiveContextPrompt) { + text = applyActiveContextPrompt(text, preparedActiveContextPrompt); + } + + text = applyMentionedCharacterContext(text, mentionedCharacters); + return applyRuntimeStyleMessagePrompt( + text, + runtimeStyleMessagePrompt, + sendOptions, + ); +} + +export function primeBrowserAssistBeforeSend( + options: PrimeBrowserAssistBeforeSendOptions, +): void { + const { + activeTheme, + sourceText, + browserRequirementMatch, + ensureBrowserAssistCanvas, + } = options; + + if (browserRequirementMatch) { + void ensureBrowserAssistCanvas( + browserRequirementMatch.launchUrl || sourceText, + { + silent: true, + navigationMode: + browserRequirementMatch.launchUrl && + browserRequirementMatch.launchUrl !== sourceText + ? "explicit-url" + : "best-effort", + }, + ).catch((error) => { + console.warn( + "[AgentChatPage] 强浏览器任务发送前准备浏览器失败,继续由主流程处理:", + error, + ); + }); + return; + } + + preheatBrowserAssistInBackground({ + activeTheme, + sourceText, + ensureBrowserAssistCanvas, + onError: (error) => { + console.warn( + "[AgentChatPage] 发送前预热浏览器协助失败,继续发送消息:", + error, + ); + }, + }); +} + +export function buildWorkspaceRequestMetadata( + options: BuildWorkspaceRequestMetadataOptions, +): Record { + const { + workspaceRequestMetadataBase, + sendOptions, + effectiveToolPreferences, + mappedTheme, + isThemeWorkbench, + currentGateKey, + themeWorkbenchActiveQueueTitle, + contentId, + browserRequirementMatch, + preferredTeamPresetId, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, + } = options; + + const existingHarnessMetadata = extractExistingHarnessMetadata({ + ...(workspaceRequestMetadataBase || {}), + ...(sendOptions?.requestMetadata || {}), + }); + + return { + ...(workspaceRequestMetadataBase || {}), + ...(sendOptions?.requestMetadata || {}), + harness: buildHarnessRequestMetadata({ + base: existingHarnessMetadata, + theme: mappedTheme, + turnPurpose: sendOptions?.purpose, + preferences: { + webSearch: effectiveToolPreferences.webSearch, + thinking: effectiveToolPreferences.thinking, + task: effectiveToolPreferences.task, + subagent: effectiveToolPreferences.subagent, + }, + sessionMode: isThemeWorkbench ? "theme_workbench" : "default", + gateKey: isThemeWorkbench ? currentGateKey : undefined, + runTitle: themeWorkbenchActiveQueueTitle?.trim() || undefined, + contentId: contentId || undefined, + browserRequirement: browserRequirementMatch?.requirement, + browserRequirementReason: browserRequirementMatch?.reason, + browserLaunchUrl: browserRequirementMatch?.launchUrl, + browserAssistProfileKey: + mappedTheme === "general" + ? GENERAL_BROWSER_ASSIST_PROFILE_KEY + : undefined, + preferredTeamPresetId, + selectedTeamId: selectedTeam?.id, + selectedTeamSource: selectedTeam?.source, + selectedTeamLabel, + selectedTeamDescription: selectedTeam?.description, + selectedTeamSummary, + selectedTeamRoles: selectedTeam?.roles, + }), + }; +} + +export function buildRuntimeTeamDispatchPreview( + preparedRuntimeTeamState: PreparedRuntimeTeamState, + sourceText: string, + images: MessageImage[], + messagesCount: number, +): RuntimeTeamDispatchPreviewSnapshot { + return { + key: preparedRuntimeTeamState.requestId, + prompt: sourceText, + images, + baseMessageCount: messagesCount, + status: preparedRuntimeTeamState.status, + formationState: preparedRuntimeTeamState, + failureMessage: preparedRuntimeTeamState.errorMessage?.trim() || null, + }; +} diff --git a/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts b/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts index f2d8340ac..22c6db9a8 100644 --- a/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts +++ b/src/components/settings-v2/general/hotkeys/hotkeyCatalog.test.ts @@ -10,6 +10,9 @@ describe("hotkey catalog", () => { enabled: true, shortcut: "CommandOrControl+Shift+4", }, + webmcp: { + enabled: false, + }, }, voiceConfig: { enabled: true, @@ -37,7 +40,9 @@ describe("hotkey catalog", () => { attention: 0, globalReady: 3, }); - expect(catalog.sections.find((section) => section.scene === "terminal")?.hotkeys).toHaveLength(10); + expect( + catalog.sections.find((section) => section.scene === "terminal")?.hotkeys, + ).toHaveLength(10); expect( catalog.sections .find((section) => section.scene === "terminal") @@ -53,6 +58,9 @@ describe("hotkey catalog", () => { enabled: false, shortcut: "", }, + webmcp: { + enabled: false, + }, }, voiceConfig: { enabled: true, diff --git a/src/components/settings-v2/general/hotkeys/index.test.tsx b/src/components/settings-v2/general/hotkeys/index.test.tsx index ede71963b..49651d3cf 100644 --- a/src/components/settings-v2/general/hotkeys/index.test.tsx +++ b/src/components/settings-v2/general/hotkeys/index.test.tsx @@ -105,6 +105,9 @@ beforeEach(() => { enabled: true, shortcut: "CommandOrControl+Shift+4", }, + webmcp: { + enabled: false, + }, }); mockGetVoiceInputConfig.mockResolvedValue({ @@ -189,6 +192,9 @@ describe("HotkeysSettings", () => { enabled: true, shortcut: "CommandOrControl+Shift+4", }, + webmcp: { + enabled: false, + }, }); const container = renderComponent(); @@ -209,6 +215,9 @@ describe("HotkeysSettings", () => { enabled: false, shortcut: "", }, + webmcp: { + enabled: false, + }, }); mockGetVoiceInputConfig.mockResolvedValue({ enabled: true, diff --git a/src/components/settings-v2/system/automation/index.test.tsx b/src/components/settings-v2/system/automation/index.test.tsx index ba3b612d3..d5caa622a 100644 --- a/src/components/settings-v2/system/automation/index.test.tsx +++ b/src/components/settings-v2/system/automation/index.test.tsx @@ -601,4 +601,166 @@ describe("AutomationSettings", () => { 15, ); }); + + it("服务型技能自动化任务应展示参数摘要与主稿绑定", async () => { + mockGetAutomationJobs.mockResolvedValueOnce([ + { + id: "job-service-skill-1", + name: "每日趋势摘要|定时执行", + description: "围绕指定平台与关键词输出趋势摘要。", + enabled: true, + workspace_id: "workspace-default", + execution_mode: "skill", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" }, + payload: { + kind: "agent_turn", + prompt: "[服务型技能] 每日趋势摘要", + system_prompt: null, + web_search: false, + content_id: "content-service-skill-1", + request_metadata: { + service_skill: { + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + execution_location: "client_default", + source: "cloud_catalog", + slot_values: [ + { + key: "platform", + label: "监测平台", + value: "X / Twitter", + }, + { + key: "industry_keywords", + label: "行业关键词", + value: "AI Agent,创作者工具", + }, + ], + user_input: "重点关注新增热点与异常波动。", + }, + harness: { + theme: "social-media", + content_id: "content-service-skill-1", + }, + }, + }, + delivery: { + mode: "none", + channel: null, + target: null, + best_effort: true, + output_schema: "text", + output_format: "text", + }, + timeout_secs: 120, + max_retries: 2, + next_run_at: "2026-03-16T09:00:00Z", + last_status: "error", + last_error: "模型返回空结果", + last_run_at: "2026-03-16T08:59:00Z", + last_finished_at: "2026-03-16T09:00:10Z", + running_started_at: null, + consecutive_failures: 1, + last_retry_count: 0, + auto_disabled_until: null, + last_delivery: null, + created_at: "2026-03-16T00:00:00Z", + updated_at: "2026-03-16T00:00:00Z", + }, + ]); + mockGetAutomationRunHistory.mockResolvedValueOnce([ + { + id: "run-service-skill-1", + source: "automation", + source_ref: "job-service-skill-1", + session_id: "session-service-skill-1", + status: "error", + started_at: "2026-03-16T08:59:00Z", + finished_at: "2026-03-16T09:00:10Z", + duration_ms: 70_000, + error_code: "empty_result", + error_message: "模型返回空结果", + metadata: JSON.stringify({ + service_skill: { + id: "daily-trend-briefing", + title: "每日趋势摘要", + runner_type: "scheduled", + execution_location: "client_default", + source: "cloud_catalog", + slot_values: [ + { + key: "platform", + label: "监测平台", + value: "小红书", + }, + { + key: "industry_keywords", + label: "行业关键词", + value: "AI 短视频", + }, + ], + user_input: "优先记录增速最快的话题。", + }, + content_id: "content-service-skill-run-1", + harness: { + theme: "social-media", + }, + }), + created_at: "2026-03-16T08:59:00Z", + updated_at: "2026-03-16T09:00:10Z", + }, + ]); + + const container = await renderSettings({ + mode: "workspace", + initialSelectedJobId: "job-service-skill-1", + }); + const serviceSkillSummary = container.querySelector( + "[data-testid='automation-job-service-skill-summary-job-service-skill-1']", + ); + const runWindow = container.querySelector( + "[data-testid='automation-job-run-window-job-service-skill-1']", + ); + const runServiceSkillSummary = container.querySelector( + "[data-testid='automation-run-service-skill-summary-run-service-skill-1']", + ); + + expect(serviceSkillSummary?.textContent).toContain("服务技能"); + expect(serviceSkillSummary?.textContent).toContain("定时任务"); + expect(serviceSkillSummary?.textContent).toContain("客户端执行"); + expect(serviceSkillSummary?.textContent).toContain("云目录"); + expect(serviceSkillSummary?.textContent).toContain("服务项: 每日趋势摘要"); + expect(serviceSkillSummary?.textContent).toContain( + "参数摘要: 监测平台: X / Twitter · 行业关键词: AI Agent,创作者工具", + ); + expect(runWindow?.textContent).toContain("下次:"); + expect(runWindow?.textContent).toContain("最近:"); + expect(runServiceSkillSummary?.textContent).toContain("服务技能运行上下文"); + expect(runServiceSkillSummary?.textContent).toContain("定时任务"); + expect(runServiceSkillSummary?.textContent).toContain("客户端执行"); + expect(runServiceSkillSummary?.textContent).toContain("服务项: 每日趋势摘要"); + expect(runServiceSkillSummary?.textContent).toContain( + "参数摘要: 监测平台: 小红书 · 行业关键词: AI 短视频", + ); + expect(runServiceSkillSummary?.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("工作主题: social-media"); + expect(container.textContent).toContain("主稿绑定: content-service-skill-1"); + expect(container.textContent).toContain("参数摘要"); + expect(container.textContent).toContain("监测平台: X / Twitter"); + expect(container.textContent).toContain( + "行业关键词: AI Agent,创作者工具", + ); + expect(container.textContent).toContain("补充要求"); + expect(container.textContent).toContain("重点关注新增热点与异常波动。"); + expect(container.textContent).toContain("失败原因"); + expect(container.textContent).toContain("模型返回空结果"); + }); }); diff --git a/src/components/settings-v2/system/automation/index.tsx b/src/components/settings-v2/system/automation/index.tsx index 81da88acf..9c63e369c 100644 --- a/src/components/settings-v2/system/automation/index.tsx +++ b/src/components/settings-v2/system/automation/index.tsx @@ -67,6 +67,12 @@ import { AutomationJobDialogSubmit, type AutomationJobDialogInitialValues, } from "./AutomationJobDialog"; +import { + mergeAutomationServiceSkillContexts, + resolveServiceSkillAutomationContext, + resolveServiceSkillContextFromMetadataRecord, + type AutomationServiceSkillContext, +} from "./serviceSkillContext"; import type { AutomationWorkspaceTab } from "@/types/page"; function formatTime(value?: string | null): string { @@ -432,6 +438,45 @@ function deliveryToneClass( : "border-rose-200 bg-rose-50/80 text-rose-700"; } +function describeServiceSkillTaskLine( + serviceSkillContext: AutomationServiceSkillContext, +): string { + return `服务项: ${serviceSkillContext.title}`; +} + +function describeServiceSkillSlotPreview( + serviceSkillContext: AutomationServiceSkillContext, + limit: number = 2, +): string | null { + const preview = serviceSkillContext.slotSummary + .slice(0, limit) + .map((item) => `${item.label}: ${item.value}`); + if (preview.length > 0) { + const suffix = + serviceSkillContext.slotSummary.length > limit + ? ` 等 ${serviceSkillContext.slotSummary.length} 项` + : ""; + return `${preview.join(" · ")}${suffix}`; + } + + if (serviceSkillContext.userInput) { + return serviceSkillContext.userInput; + } + + return null; +} + +function resolveRunServiceSkillContext( + run: AgentRun, + fallbackContext: AutomationServiceSkillContext | null, +): AutomationServiceSkillContext | null { + const metadata = parseRunMetadata(run); + const runContext = metadata + ? resolveServiceSkillContextFromMetadataRecord(metadata) + : null; + return mergeAutomationServiceSkillContexts(runContext, fallbackContext); +} + type AutomationWorkspaceTemplate = { id: string; tag: string; @@ -566,6 +611,21 @@ export function AutomationSettings({ () => jobs.find((job) => job.id === selectedJobId) ?? null, [jobs, selectedJobId], ); + const serviceSkillContextByJobId = useMemo(() => { + const mapping = new Map(); + jobs.forEach((job) => { + const context = resolveServiceSkillAutomationContext(job.payload); + if (context) { + mapping.set(job.id, context); + } + }); + return mapping; + }, [jobs]); + const selectedServiceSkillContext = useMemo( + () => + selectedJobId ? serviceSkillContextByJobId.get(selectedJobId) ?? null : null, + [selectedJobId, serviceSkillContextByJobId], + ); const selectedBrowserRun = useMemo( () => selectedJob?.payload.kind === "browser_session" @@ -1189,13 +1249,23 @@ export function AutomationSettings({ 调度 模式 状态 - 下次执行 + 执行窗口 操作 {jobs.map((job) => { const jobDetailMessage = riskyJobMessageMap.get(job.id); + const serviceSkillContext = + serviceSkillContextByJobId.get(job.id) ?? null; + const serviceSkillTaskLine = serviceSkillContext + ? describeServiceSkillTaskLine(serviceSkillContext) + : null; + const serviceSkillSlotPreview = serviceSkillContext + ? describeServiceSkillSlotPreview( + serviceSkillContext, + ) + : null; return ( {job.description || "未填写任务描述"}
+ {serviceSkillContext ? ( +
+
+ + 服务技能 + + + {serviceSkillContext.runnerLabel} + + + { + serviceSkillContext.executionLocationLabel + } + + + {serviceSkillContext.sourceLabel} + +
+ {serviceSkillTaskLine ? ( +
+ {serviceSkillTaskLine} +
+ ) : null} + {serviceSkillSlotPreview ? ( +
+ 参数摘要: {serviceSkillSlotPreview} +
+ ) : null} +
+ ) : null} @@ -1250,7 +1356,15 @@ export function AutomationSettings({ - {formatTime(job.next_run_at)} +
+
下次: {formatTime(job.next_run_at)}
+
+ 最近: {formatTime(job.last_run_at)} +
+
@@ -1384,6 +1498,71 @@ export function AutomationSettings({
) : null} + {selectedServiceSkillContext ? ( +
+
+
+ 服务型技能上下文 +
+
+ + {selectedServiceSkillContext.runnerLabel} + + + { + selectedServiceSkillContext.executionLocationLabel + } + +
+
+
+
+ 服务项: {selectedServiceSkillContext.title} +
+
+ 目录来源:{" "} + {selectedServiceSkillContext.sourceLabel} +
+
+ 工作主题:{" "} + {selectedServiceSkillContext.theme || "-"} +
+
+ 主稿绑定:{" "} + {selectedServiceSkillContext.contentId || "-"} +
+
+ {selectedServiceSkillContext.slotSummary.length ? ( +
+
+ 参数摘要 +
+
+ {selectedServiceSkillContext.slotSummary.map( + (item) => ( +
+ + {item.label} + + : {item.value} +
+ ), + )} +
+
+ ) : null} + {selectedServiceSkillContext.userInput ? ( +
+
+ 补充要求 +
+
+ {selectedServiceSkillContext.userInput} +
+
+ ) : null} +
+ ) : null}
@@ -1610,6 +1789,23 @@ export function AutomationSettings({ jobRuns.map((run) => { const infoMessage = resolveRunInfoMessage(run); const delivery = resolveRunDelivery(run); + const runServiceSkillContext = + resolveRunServiceSkillContext( + run, + selectedServiceSkillContext, + ); + const runServiceSkillTaskLine = + runServiceSkillContext + ? describeServiceSkillTaskLine( + runServiceSkillContext, + ) + : null; + const runServiceSkillSlotPreview = + runServiceSkillContext + ? describeServiceSkillSlotPreview( + runServiceSkillContext, + ) + : null; return (
) : null} + {runServiceSkillContext ? ( +
+
+
+ 服务技能运行上下文 +
+ + {runServiceSkillContext.runnerLabel} + + + { + runServiceSkillContext.executionLocationLabel + } + +
+ {runServiceSkillTaskLine ? ( +
+ {runServiceSkillTaskLine} +
+ ) : null} + {runServiceSkillSlotPreview ? ( +
+ 参数摘要: {runServiceSkillSlotPreview} +
+ ) : null} + {runServiceSkillContext.userInput ? ( +
+ 补充要求: {runServiceSkillContext.userInput} +
+ ) : null} +
+ ) : null} {delivery ? (
- {run.error_message} +
失败原因
+
+ {run.error_message} +
) : null}
diff --git a/src/components/settings-v2/system/automation/serviceSkillContext.ts b/src/components/settings-v2/system/automation/serviceSkillContext.ts new file mode 100644 index 000000000..428876816 --- /dev/null +++ b/src/components/settings-v2/system/automation/serviceSkillContext.ts @@ -0,0 +1,243 @@ +import type { AutomationPayload } from "@/lib/api/automation"; + +export interface AutomationServiceSkillSummaryItem { + key: string; + label: string; + value: string; +} + +export interface AutomationServiceSkillContext { + id: string | null; + title: string; + runnerLabel: string; + executionLocationLabel: string; + sourceLabel: string; + theme: string | null; + contentId: string | null; + slotSummary: AutomationServiceSkillSummaryItem[]; + userInput: string | null; +} + +const DEFAULT_SERVICE_SKILL_TITLE = "服务型技能任务"; +const UNKNOWN_SERVICE_SKILL_LABEL = "未标记"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeOptionalText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function resolveRunnerLabel(value: unknown): string { + switch (value) { + case "instant": + return "一次性交付"; + case "scheduled": + return "定时任务"; + case "managed": + return "持续跟踪"; + default: + return UNKNOWN_SERVICE_SKILL_LABEL; + } +} + +function resolveExecutionLocationLabel(value: unknown): string { + switch (value) { + case "client_default": + return "客户端执行"; + case "cloud_required": + return "云端执行"; + default: + return UNKNOWN_SERVICE_SKILL_LABEL; + } +} + +function resolveSourceLabel(value: unknown): string { + switch (value) { + case "cloud_catalog": + return "云目录"; + case "local_custom": + return "本地自定义"; + default: + return UNKNOWN_SERVICE_SKILL_LABEL; + } +} + +function parseSlotSummaryEntries( + value: unknown, +): AutomationServiceSkillSummaryItem[] { + if (Array.isArray(value)) { + const structured = value + .map((item) => { + if (!isRecord(item)) { + return null; + } + + const key = normalizeOptionalText(item.key); + const label = normalizeOptionalText(item.label); + const summaryValue = normalizeOptionalText(item.value); + if (!label || !summaryValue) { + return null; + } + + return { + key: key || label, + label, + value: summaryValue, + }; + }) + .filter((item): item is AutomationServiceSkillSummaryItem => Boolean(item)); + + if (structured.length > 0) { + return structured; + } + } + + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item, index) => { + const summaryLine = normalizeOptionalText(item); + if (!summaryLine) { + return null; + } + + const separatorIndex = summaryLine.search(/[::]/); + if (separatorIndex <= 0) { + return { + key: `slot-${index + 1}`, + label: `参数 ${index + 1}`, + value: summaryLine, + }; + } + + return { + key: `slot-${index + 1}`, + label: summaryLine.slice(0, separatorIndex).trim(), + value: summaryLine.slice(separatorIndex + 1).trim(), + }; + }) + .filter((item): item is AutomationServiceSkillSummaryItem => Boolean(item)); +} + +function resolveServiceSkillContextFromRecord( + record: Record, + explicitContentId?: string | null, +): AutomationServiceSkillContext | null { + const serviceSkillValue = record.service_skill ?? record.serviceSkill; + if (!isRecord(serviceSkillValue)) { + return null; + } + + const harnessValue = isRecord(record.harness) ? record.harness : null; + const id = normalizeOptionalText(serviceSkillValue.id); + const title = + normalizeOptionalText(serviceSkillValue.title) || + id || + DEFAULT_SERVICE_SKILL_TITLE; + + return { + id, + title, + runnerLabel: resolveRunnerLabel(serviceSkillValue.runner_type), + executionLocationLabel: resolveExecutionLocationLabel( + serviceSkillValue.execution_location, + ), + sourceLabel: resolveSourceLabel(serviceSkillValue.source), + theme: normalizeOptionalText(harnessValue?.theme), + contentId: + normalizeOptionalText(explicitContentId) || + normalizeOptionalText(record.content_id) || + normalizeOptionalText(harnessValue?.content_id), + slotSummary: parseSlotSummaryEntries( + serviceSkillValue.slot_values ?? serviceSkillValue.slot_summary, + ), + userInput: + normalizeOptionalText(serviceSkillValue.user_input) || + normalizeOptionalText(serviceSkillValue.userInput), + }; +} + +function shouldUseFallbackLabel(value: string): boolean { + return value === UNKNOWN_SERVICE_SKILL_LABEL; +} + +function shouldUseFallbackTitle(value: string): boolean { + return value === DEFAULT_SERVICE_SKILL_TITLE; +} + +export function mergeAutomationServiceSkillContexts( + primary: AutomationServiceSkillContext | null, + fallback: AutomationServiceSkillContext | null, +): AutomationServiceSkillContext | null { + if (!primary) { + return fallback; + } + if (!fallback) { + return primary; + } + + return { + id: primary.id || fallback.id, + title: shouldUseFallbackTitle(primary.title) ? fallback.title : primary.title, + runnerLabel: shouldUseFallbackLabel(primary.runnerLabel) + ? fallback.runnerLabel + : primary.runnerLabel, + executionLocationLabel: shouldUseFallbackLabel( + primary.executionLocationLabel, + ) + ? fallback.executionLocationLabel + : primary.executionLocationLabel, + sourceLabel: shouldUseFallbackLabel(primary.sourceLabel) + ? fallback.sourceLabel + : primary.sourceLabel, + theme: primary.theme || fallback.theme, + contentId: primary.contentId || fallback.contentId, + slotSummary: primary.slotSummary.length + ? primary.slotSummary + : fallback.slotSummary, + userInput: primary.userInput || fallback.userInput, + }; +} + +export function resolveServiceSkillContextFromMetadataRecord( + metadata: Record, + options?: { + contentId?: string | null; + }, +): AutomationServiceSkillContext | null { + const nestedRequestMetadata = isRecord(metadata.request_metadata) + ? metadata.request_metadata + : null; + const explicitContentId = normalizeOptionalText(options?.contentId); + + return ( + resolveServiceSkillContextFromRecord(metadata, explicitContentId) || + (nestedRequestMetadata + ? resolveServiceSkillContextFromRecord( + nestedRequestMetadata, + explicitContentId, + ) + : null) + ); +} + +export function resolveServiceSkillAutomationContext( + payload: AutomationPayload, +): AutomationServiceSkillContext | null { + if (payload.kind !== "agent_turn" || !isRecord(payload.request_metadata)) { + return null; + } + return resolveServiceSkillContextFromRecord( + payload.request_metadata, + payload.content_id, + ); +} diff --git a/src/components/settings-v2/system/developer/index.test.tsx b/src/components/settings-v2/system/developer/index.test.tsx index 626011f97..415acd684 100644 --- a/src/components/settings-v2/system/developer/index.test.tsx +++ b/src/components/settings-v2/system/developer/index.test.tsx @@ -67,6 +67,25 @@ const { mockExtractServiceSkillCatalogFromBootstrapPayload: vi.fn(), })); +const { + mockClearSiteAdapterCatalogCache, + mockEmitSiteAdapterCatalogBootstrap, + mockExtractSiteAdapterCatalogFromBootstrapPayload, + mockSubscribeSiteAdapterCatalogChanged, +} = vi.hoisted(() => ({ + mockClearSiteAdapterCatalogCache: vi.fn(), + mockEmitSiteAdapterCatalogBootstrap: vi.fn(), + mockExtractSiteAdapterCatalogFromBootstrapPayload: vi.fn(), + mockSubscribeSiteAdapterCatalogChanged: vi.fn(), +})); + +const { mockSiteGetAdapterCatalogStatus, mockSiteListAdapters } = vi.hoisted( + () => ({ + mockSiteGetAdapterCatalogStatus: vi.fn(), + mockSiteListAdapters: vi.fn(), + }), +); + vi.mock("@/contexts/ComponentDebugContext", () => ({ useComponentDebug: mockUseComponentDebug, })); @@ -104,7 +123,8 @@ vi.mock("@/lib/crashDiagnostic", () => ({ exportCrashDiagnosticToJson: mockExportCrashDiagnosticToJson, isClipboardPermissionDeniedError: mockIsClipboardPermissionDeniedError, normalizeCrashReportingConfig: mockNormalizeCrashReportingConfig, - openCrashDiagnosticDownloadDirectory: mockOpenCrashDiagnosticDownloadDirectory, + openCrashDiagnosticDownloadDirectory: + mockOpenCrashDiagnosticDownloadDirectory, })); vi.mock("@/lib/serviceSkillCatalogBootstrap", () => ({ @@ -113,6 +133,19 @@ vi.mock("@/lib/serviceSkillCatalogBootstrap", () => ({ mockExtractServiceSkillCatalogFromBootstrapPayload, })); +vi.mock("@/lib/siteAdapterCatalogBootstrap", () => ({ + clearSiteAdapterCatalogCache: mockClearSiteAdapterCatalogCache, + emitSiteAdapterCatalogBootstrap: mockEmitSiteAdapterCatalogBootstrap, + extractSiteAdapterCatalogFromBootstrapPayload: + mockExtractSiteAdapterCatalogFromBootstrapPayload, + subscribeSiteAdapterCatalogChanged: mockSubscribeSiteAdapterCatalogChanged, +})); + +vi.mock("@/lib/webview-api", () => ({ + siteGetAdapterCatalogStatus: mockSiteGetAdapterCatalogStatus, + siteListAdapters: mockSiteListAdapters, +})); + vi.mock("../shared/ClipboardPermissionGuideCard", () => ({ ClipboardPermissionGuideCard: () =>
剪贴板权限卡片占位
, })); @@ -156,6 +189,39 @@ const seededCatalog = { ], }; +const siteCatalogStatus = { + exists: false, + source_kind: "bundled" as const, + registry_version: 1, + directory: "/tmp/lime/site-adapters/server-synced", + adapter_count: 2, +}; + +const siteAdapters = [ + { + name: "github/search", + domain: "github.com", + description: "GitHub 搜索", + read_only: true, + capabilities: ["search"], + input_schema: { type: "object" }, + example_args: {}, + example: 'github/search {"query":"lime"}', + source_kind: "bundled" as const, + }, + { + name: "zhihu/hot", + domain: "www.zhihu.com", + description: "知乎热榜", + read_only: true, + capabilities: ["hot"], + input_schema: { type: "object" }, + example_args: {}, + example: 'zhihu/hot {"limit":10}', + source_kind: "bundled" as const, + }, +]; + const mounted: Mounted[] = []; function renderComponent(): HTMLDivElement { @@ -187,7 +253,10 @@ function findButton(container: HTMLElement, text: string): HTMLButtonElement { return button as HTMLButtonElement; } -function findSwitch(container: HTMLElement, ariaLabel: string): HTMLButtonElement { +function findSwitch( + container: HTMLElement, + ariaLabel: string, +): HTMLButtonElement { const button = container.querySelector( `button[aria-label="${ariaLabel}"]`, ); @@ -259,7 +328,9 @@ beforeEach(() => { }, }); mockGetLogs.mockResolvedValue([{ level: "error", message: "boom" }]); - mockGetPersistedLogsTail.mockResolvedValue([{ level: "info", message: "ok" }]); + mockGetPersistedLogsTail.mockResolvedValue([ + { level: "info", message: "ok" }, + ]); mockGetServerDiagnostics.mockResolvedValue({ ok: true }); mockGetLogStorageDiagnostics.mockResolvedValue({ ok: true }); mockGetWindowsStartupDiagnostics.mockResolvedValue({ ok: true }); @@ -290,6 +361,20 @@ beforeEach(() => { mockExtractServiceSkillCatalogFromBootstrapPayload.mockReturnValue( remoteCatalog, ); + mockSiteGetAdapterCatalogStatus.mockResolvedValue(siteCatalogStatus); + mockSiteListAdapters.mockResolvedValue(siteAdapters); + mockClearSiteAdapterCatalogCache.mockResolvedValue(siteCatalogStatus); + mockSubscribeSiteAdapterCatalogChanged.mockImplementation(() => vi.fn()); + mockExtractSiteAdapterCatalogFromBootstrapPayload.mockImplementation( + (payload) => + ( + payload as { + siteAdapterCatalog?: { + adapters?: unknown[]; + }; + } + ).siteAdapterCatalog ?? null, + ); }); afterEach(() => { @@ -316,6 +401,7 @@ describe("DeveloperSettings", () => { const text = container.textContent ?? ""; expect(text).toContain("DEVELOPER DESK"); expect(text).toContain("服务型技能目录联调"); + expect(text).toContain("站点脚本目录联调"); expect(text).toContain("组件视图调试"); expect(text).toContain("崩溃诊断日志(开发协作)"); expect(text).toContain("诊断建议"); @@ -376,12 +462,13 @@ describe("DeveloperSettings", () => { await flushEffects(); const textarea = findTextarea(container, "服务型技能目录调试输入"); - expect(textarea.value).toContain("\"tenantId\": \"tenant-demo\""); + expect(textarea.value).toContain('"tenantId": "tenant-demo"'); expect(container.textContent).toContain("已把当前目录写入调试编辑器"); }); it("输入 JSON 后通过事件注入应调用 bootstrap 桥接", async () => { const container = renderComponent(); + await flushEffects(); const textarea = findTextarea(container, "服务型技能目录调试输入"); await inputTextarea( @@ -397,7 +484,9 @@ describe("DeveloperSettings", () => { await clickButton(findButton(container, "通过事件注入")); await flushEffects(); - expect(mockExtractServiceSkillCatalogFromBootstrapPayload).toHaveBeenCalledWith( + expect( + mockExtractServiceSkillCatalogFromBootstrapPayload, + ).toHaveBeenCalledWith( expect.objectContaining({ serviceSkillCatalog: expect.objectContaining({ tenantId: "tenant-demo", @@ -411,7 +500,9 @@ describe("DeveloperSettings", () => { }), }), ); - expect(container.textContent).toContain("已通过 bootstrap 事件注入目录:2 项"); + expect(container.textContent).toContain( + "已通过 bootstrap 事件注入目录:2 项", + ); }); it("清空目录缓存后应回退 seeded 目录并展示提示", async () => { @@ -419,6 +510,7 @@ describe("DeveloperSettings", () => { mockGetServiceSkillCatalog.mockResolvedValueOnce(seededCatalog); const container = renderComponent(); + await flushEffects(); await clickButton(findButton(container, "清空目录缓存")); await flushEffects(); @@ -428,4 +520,120 @@ describe("DeveloperSettings", () => { "已清空远端目录缓存,当前回退到 seeded:1 项", ); }); + + it("应展示站点脚本目录摘要", async () => { + const container = renderComponent(); + await flushEffects(); + + expect(container.textContent).toContain("站点脚本目录联调"); + expect(container.textContent).toContain("应用内置"); + expect(container.textContent).toContain("github/search"); + expect(container.textContent).toContain("zhihu/hot"); + }); + + it("输入 JSON 后注入站点脚本目录应调用 bootstrap 桥接", async () => { + const container = renderComponent(); + await flushEffects(); + const textarea = findTextarea(container, "站点脚本目录调试输入"); + + await inputTextarea( + textarea, + JSON.stringify( + { + siteAdapterCatalog: { + adapters: [{ name: "github/search" }, { name: "zhihu/hot" }], + }, + }, + null, + 2, + ), + ); + await clickButton(findButton(container, "注入站点目录")); + await flushEffects(); + + expect( + mockExtractSiteAdapterCatalogFromBootstrapPayload, + ).toHaveBeenCalledWith( + expect.objectContaining({ + siteAdapterCatalog: expect.objectContaining({ + adapters: expect.any(Array), + }), + }), + ); + expect(mockEmitSiteAdapterCatalogBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + siteAdapterCatalog: expect.objectContaining({ + adapters: expect.any(Array), + }), + }), + ); + expect(container.textContent).toContain( + "已通过 bootstrap 事件注入站点脚本目录:2 项", + ); + }); + + it("清空站点脚本目录缓存后应提示回退到应用内置", async () => { + const container = renderComponent(); + await flushEffects(); + + await clickButton(findButton(container, "清空站点目录缓存")); + await flushEffects(); + + expect(mockClearSiteAdapterCatalogCache).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain( + "已清空站点脚本目录缓存,当前回退到应用内置:2 项", + ); + }); + + it("站点目录变更事件后应自动刷新开发页摘要", async () => { + const container = renderComponent(); + await flushEffects(); + + expect(mockSiteGetAdapterCatalogStatus).toHaveBeenCalledTimes(1); + expect(mockSiteListAdapters).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("应用内置"); + + mockSiteGetAdapterCatalogStatus.mockResolvedValueOnce({ + exists: true, + source_kind: "server_synced", + registry_version: 2, + directory: "/tmp/lime/site-adapters/server-synced", + catalog_version: "tenant-site-2026-03-27", + tenant_id: "tenant-demo", + synced_at: "2026-03-27T08:00:00.000Z", + adapter_count: 1, + }); + mockSiteListAdapters.mockResolvedValueOnce([ + { + name: "bilibili/hot", + domain: "www.bilibili.com", + description: "B 站热榜", + read_only: true, + capabilities: ["hot"], + input_schema: { type: "object" }, + example_args: {}, + example: 'bilibili/hot {"limit":10}', + source_kind: "server_synced" as const, + }, + ]); + + const changedListener = + mockSubscribeSiteAdapterCatalogChanged.mock.calls[0]?.[0]; + expect(changedListener).toBeTypeOf("function"); + + await act(async () => { + changedListener?.({ + exists: true, + source_kind: "server_synced", + adapter_count: 1, + }); + await flushEffects(); + }); + + expect(mockSiteGetAdapterCatalogStatus).toHaveBeenCalledTimes(2); + expect(mockSiteListAdapters).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain("服务端同步"); + expect(container.textContent).toContain("bilibili/hot"); + expect(container.textContent).toContain("tenant-site-2026-03-27"); + }); }); diff --git a/src/components/settings-v2/system/developer/index.tsx b/src/components/settings-v2/system/developer/index.tsx index 1f32e2039..6f951751b 100644 --- a/src/components/settings-v2/system/developer/index.tsx +++ b/src/components/settings-v2/system/developer/index.tsx @@ -1,10 +1,18 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { AlertCircle, Bug, DatabaseZap, Code2, Eye, + Globe, + RefreshCw, ScrollText, ShieldAlert, Sparkles, @@ -43,6 +51,18 @@ import { emitServiceSkillCatalogBootstrap, extractServiceSkillCatalogFromBootstrapPayload, } from "@/lib/serviceSkillCatalogBootstrap"; +import { + clearSiteAdapterCatalogCache, + emitSiteAdapterCatalogBootstrap, + extractSiteAdapterCatalogFromBootstrapPayload, + subscribeSiteAdapterCatalogChanged, +} from "@/lib/siteAdapterCatalogBootstrap"; +import { + siteGetAdapterCatalogStatus, + siteListAdapters, + type SiteAdapterCatalogStatus, + type SiteAdapterDefinition, +} from "@/lib/webview-api"; import { cn } from "@/lib/utils"; import { Textarea } from "@/components/ui/textarea"; import { ClipboardPermissionGuideCard } from "../shared/ClipboardPermissionGuideCard"; @@ -67,6 +87,44 @@ const SECONDARY_BUTTON_CLASS_NAME = const DANGER_BUTTON_CLASS_NAME = "inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-medium text-rose-700 transition hover:border-rose-300 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50"; +const DEFAULT_SITE_ADAPTER_CATALOG_EDITOR_VALUE = JSON.stringify( + { + siteAdapterCatalog: { + catalogVersion: "tenant-site-2026-03-26", + tenantId: "tenant-demo", + syncedAt: "2026-03-26T12:00:00.000Z", + adapters: [ + { + name: "github/search", + domain: "github.com", + description: "服务端下发的 GitHub 搜索脚本", + read_only: true, + capabilities: ["search", "research"], + args: [ + { + name: "query", + description: "搜索关键词", + required: true, + arg_type: "string", + example: "model context protocol", + }, + ], + example: 'github/search {"query":"model context protocol"}', + entry: { + kind: "fixed_url", + url: "https://github.com/search", + }, + script: + "async ({ query }) => ({ items: [{ title: query, url: location.href }] })", + sourceVersion: "tenant-site-2026-03-26", + }, + ], + }, + }, + null, + 2, +); + function SurfacePanel({ icon: Icon, title, @@ -135,10 +193,14 @@ export function DeveloperSettings() { const { enabled, setEnabled } = useComponentDebug(); const [diagnosticBusy, setDiagnosticBusy] = useState(false); const [serviceCatalogBusy, setServiceCatalogBusy] = useState(false); + const [siteCatalogBusy, setSiteCatalogBusy] = useState(false); const [catalogEditorValue, setCatalogEditorValue] = useState(""); - const [serviceCatalog, setServiceCatalog] = useState( - null, - ); + const [siteCatalogEditorValue, setSiteCatalogEditorValue] = useState(""); + const [serviceCatalog, setServiceCatalog] = + useState(null); + const [siteCatalogStatus, setSiteCatalogStatus] = + useState(null); + const [siteAdapters, setSiteAdapters] = useState([]); const [message, setMessage] = useState<{ type: "success" | "error"; text: string; @@ -151,16 +213,36 @@ export function DeveloperSettings() { return catalog; }, []); + const loadSiteAdapterCatalog = useCallback(async () => { + const [status, adapters] = await Promise.all([ + siteGetAdapterCatalogStatus(), + siteListAdapters(), + ]); + setSiteCatalogStatus(status); + setSiteAdapters(adapters); + return { status, adapters }; + }, []); + useEffect(() => { void loadServiceSkillCatalog(); }, [loadServiceSkillCatalog]); + useEffect(() => { + void loadSiteAdapterCatalog(); + }, [loadSiteAdapterCatalog]); + useEffect(() => { return subscribeServiceSkillCatalogChanged(() => { void loadServiceSkillCatalog(); }); }, [loadServiceSkillCatalog]); + useEffect(() => { + return subscribeSiteAdapterCatalogChanged(() => { + void loadSiteAdapterCatalog(); + }); + }, [loadSiteAdapterCatalog]); + const buildDiagnosticPayload = useCallback(async () => { const configPromise = getConfig(); const runtimeSnapshotPromise = configPromise.then((config) => @@ -427,6 +509,111 @@ export function DeveloperSettings() { } }, [loadServiceSkillCatalog]); + const handleHydrateSiteCatalogEditorWithTemplate = useCallback(() => { + setSiteCatalogEditorValue(DEFAULT_SITE_ADAPTER_CATALOG_EDITOR_VALUE); + setMessage({ + type: "success", + text: "已写入站点脚本目录示例 Payload", + }); + setTimeout(() => setMessage(null), 2500); + }, []); + + const handleRefreshSiteCatalog = useCallback(async () => { + setSiteCatalogBusy(true); + setMessage(null); + try { + const { adapters } = await loadSiteAdapterCatalog(); + setMessage({ + type: "success", + text: `已刷新站点脚本目录状态:${adapters.length} 项生效适配器`, + }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + console.error("刷新站点脚本目录状态失败:", error); + setMessage({ + type: "error", + text: + error instanceof Error ? error.message : "刷新站点脚本目录状态失败", + }); + } finally { + setSiteCatalogBusy(false); + } + }, [loadSiteAdapterCatalog]); + + const handleApplySiteCatalogPayload = useCallback(async () => { + const raw = siteCatalogEditorValue.trim(); + if (!raw) { + setMessage({ + type: "error", + text: "请先输入 siteAdapterCatalog JSON", + }); + return; + } + + setSiteCatalogBusy(true); + setMessage(null); + try { + const parsed = JSON.parse(raw) as unknown; + const previewCatalog = + extractSiteAdapterCatalogFromBootstrapPayload(parsed); + if (!previewCatalog) { + throw new Error( + "JSON 中未找到合法的 siteAdapterCatalog,可传目录本体或 { siteAdapterCatalog: ... }", + ); + } + + const adapterCount = Array.isArray( + (previewCatalog as { adapters?: unknown }).adapters, + ) + ? ((previewCatalog as { adapters: unknown[] }).adapters?.length ?? 0) + : 0; + emitSiteAdapterCatalogBootstrap(parsed); + setMessage({ + type: "success", + text: `已通过 bootstrap 事件注入站点脚本目录:${adapterCount} 项`, + }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + console.error("注入站点脚本目录失败:", error); + setMessage({ + type: "error", + text: error instanceof Error ? error.message : "注入站点脚本目录失败", + }); + } finally { + setSiteCatalogBusy(false); + } + }, [siteCatalogEditorValue]); + + const handleClearSiteCatalog = useCallback(async () => { + setSiteCatalogBusy(true); + setMessage(null); + try { + await clearSiteAdapterCatalogCache(); + const { adapters } = await loadSiteAdapterCatalog(); + setMessage({ + type: "success", + text: `已清空站点脚本目录缓存,当前回退到应用内置:${adapters.length} 项`, + }); + setTimeout(() => setMessage(null), 2500); + } catch (error) { + console.error("清空站点脚本目录缓存失败:", error); + setMessage({ + type: "error", + text: + error instanceof Error ? error.message : "清空站点脚本目录缓存失败", + }); + } finally { + setSiteCatalogBusy(false); + } + }, [loadSiteAdapterCatalog]); + + const siteCatalogSourceLabel = siteCatalogStatus + ? siteCatalogStatus.exists || + siteCatalogStatus.source_kind === "server_synced" + ? "服务端同步" + : "应用内置" + : "加载中"; + const summary = useMemo( () => ({ diagnosticActionCount: 5, @@ -435,8 +622,9 @@ export function DeveloperSettings() { serviceCatalogLabel: serviceCatalog ? `${serviceCatalog.items.length} 项` : "加载中", + siteAdapterCatalogLabel: `${siteAdapters.length} 项`, }), - [enabled, serviceCatalog, showClipboardGuide], + [enabled, serviceCatalog, showClipboardGuide, siteAdapters.length], ); return ( @@ -509,6 +697,11 @@ export function DeveloperSettings() { value={summary.serviceCatalogLabel} description="显示当前生效的服务型技能目录项数,便于联调 bootstrap 下发。" /> +
@@ -674,7 +867,9 @@ export function DeveloperSettings() {