diff --git a/.gitignore b/.gitignore index c7969c28c..02661b227 100644 --- a/.gitignore +++ b/.gitignore @@ -59,11 +59,20 @@ docs/roadmap/* !docs/roadmap/memory/*.md !docs/roadmap/knowledge/ !docs/roadmap/knowledge/prd.md +!docs/roadmap/creaoai/ +!docs/roadmap/creaoai/*.md +!docs/roadmap/managed-objective/ +!docs/roadmap/managed-objective/*.md +!docs/roadmap/ai-layered-design/ +!docs/roadmap/ai-layered-design/*.md docs/gongzonghao/ docs/bussniss/ docs/oem/ docs/tech/ docs/knowledge +!docs/knowledge/ +docs/knowledge/* +!docs/knowledge/README.md # docs/research/ # Issues tracking (internal use only) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 60b66762c..215000a5d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,108 +1,82 @@ -## Lime v1.27.0 +## Lime v1.28.0 发布日期:`2026-05-05` ### 发布概览 -- 本次发布目标 tag 为 `v1.27.0`,重点把 Agent Knowledge 从方案文档推进到 current 主链,同时继续收紧 Agent runtime、Skill 工具门禁、模型解析和 GUI 入口的一致性。 -- 版本文件、Tauri 配置、headless 配置、CLI wrapper、release updater 测试样例与发布说明已同步到 `1.27.0`。 -- 该版本继续坚持“一个事实源”:知识包、运行时上下文、命令契约、mock、GUI 页面和输入区发送 metadata 都收敛到同一条可验证链路。 -- 本次重新覆盖 `v1.27.0` tag 前,补入会话恢复、消息投影、Harness 审核导出、Knowledge GUI 冒烟和 release build 稳定性修复。 +- 本次发布目标 tag 为 `v1.28.0`,重点把 Capability Draft / Skill Forge 从草案创建推进到验证、注册闭环,同时继续推进 AI 图层化设计、Knowledge 主链和 Harness 证据治理。 +- 版本事实源已同步到 `1.28.0`:`package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 release updater 测试样例保持一致。 +- 该版本继续坚持“一个事实源”:能力草案、知识包、运行时权限确认、Evidence Pack、Artifact/Canvas 与 GUI review surface 都优先回到 current 主链,不新增平行执行入口。 ### 用户可见更新 -#### 1. Agent Knowledge 知识库主链 +#### 1. Capability Draft / Skill Forge 闭环 -- 新增 `知识库` 页面入口,支持查看知识包目录、知识包详情、来源导入、编译、默认包设置和运行时 context 预览。 -- 新增 Markdown-first 知识包标准目录:`.lime/knowledge/packs//KNOWLEDGE.md`、`sources/`、`wiki/`、`compiled/`、`runs/`。 -- 新增知识包导入、编译、列表、详情、默认包和运行时上下文解析能力;GUI 与聊天发送链路都消费同一组 `knowledge_*` 命令。 -- 聊天输入区新增轻量知识包选择菜单:可读取当前工作区知识包,默认选中项目默认包,也可手动切换具体知识包后发送。 -- Agent runtime 新增 `KnowledgePack` prompt stage:从请求 metadata 解析知识包选择,调用 Knowledge Context Resolver,并以 fenced context 注入模型。 -- 带知识包 metadata 的请求会强制进入 full runtime,避免 fast route 跳过知识上下文。 -- 新增内置 `knowledge_builder` Skill,帮助把来源资料整理为 `KNOWLEDGE.md`、`wiki/`、`compiled/brief.md` 和 `runs/` 草稿。 -- 知识库页面提供 `Builder 生成` 入口,可把项目根目录、pack name、pack 类型和 builder metadata 带入 Agent 执行。 +- 新增 workspace-local Capability Draft 创建、列表、详情、验证与注册链路,草案事实源落在 `.lime/capability-drafts/`。 +- Skills 工作台新增草案 review surface,可展示目标、权限摘要、文件清单、验证报告和注册结果。 +- Verification gate 覆盖结构、contract、权限声明、危险 token、fixture 存在性等静态检查;失败会写入可追踪报告。 +- Registration gate 仅允许 `verified_pending_registration` 草案注册到当前 workspace 的 `.agents/skills//`,并记录来源与验证报告。 +- 已注册草案仍不会自动运行、不会进入默认 tool surface、不会接 automation,避免把“文件注册”误当成“已授权执行”。 -#### 2. Agent runtime、模型解析与工具门禁 +#### 2. AI 图层化设计主链 -- 运行时模型解析继续向后端事实源收敛,增强默认 provider、模型候选、辅助模型和请求级模型能力解析。 -- Skill 工具门禁增强:模型首刀 Skill、服务技能、浏览器工具、知识包上下文和 detour tool 抑制逻辑更明确,减少任务跑偏到工具目录发现或本地文件误读。 -- Agent turn 输入、队列、session runtime 和 stream submit 链路补齐 request metadata、workspace context、team/runtime state 的传递与测试。 -- `fastResponseModel` 与 full runtime 判定补齐知识包、媒体任务、显式 Skill 和运行时需求判断,避免该走主链的任务被短路。 +- 新增 `LayeredDesignDocument` 最小协议,把图片生成从“单张扁平 PNG”推进到可编辑图层工程。 +- 新增 `DesignCanvas` 最小可见 UI 与 `canvas:design` Artifact 接入口,图层文档可进入 Workspace Canvas。 +- 新增本地 Layer Planner seed、Artifact bridge、图片层生成请求 seam 和 image task artifact 写回路径。 +- 支持从 edit history 刷新图片任务结果,并把成功产物写回目标图层。 +- 增加主流图片模型族能力约束与透明图层策略,作为后续 provider adapter 的 contract 基础。 -#### 3. 工作区、任务轻卡与图片任务恢复 +#### 3. Agent UI、Harness 与证据治理 -- 图片任务 viewer 和 workspace 预览继续向统一 media task artifact 事实源收敛,补齐完成态、失败态、工作台展示和恢复路径。 -- Inputbar、workspace send actions、message preview 和 task policy evaluation 增强多模态任务 metadata 传递,减少显式动作与纯文本命令之间的协议漂移。 -- Agent UI 性能指标继续补充旧会话打开、消息列表首帧和 runtime session 读取的采集点与回归。 +- Agent stream、session history、runtime context、request log、tool event、completion、error 和 inactivity 等控制器继续拆分成可测边界。 +- Harness 状态面板、Review Decision 与 Evidence Pack 继续收敛权限确认状态,区分 `not_requested`、`requested`、`resolved` 与 `denied`。 +- Evidence Pack / Replay / Review 对 denied 或未解决权限确认保持阻断语义,避免把未经真实确认的运行标记为成功交付。 +- Agent task index、timeline、artifact action 与 message projection 回归继续补强,降低长会话恢复和工作台投影漂移。 -#### 4. 导航、侧栏与本地化 +#### 4. Knowledge 与工作区入口 -- 侧栏、任务中心资料分组和页面内容区新增知识库入口,并补齐路由、页面类型和导航测试。 -- 中英文 patch 增加知识库相关文案,翻译覆盖测试同步更新。 -- 旧的 Agent Knowledge 探索文档收敛到 `docs/roadmap/knowledge/prd.md` 与执行计划,不再保留平行旧文档入口。 - -#### 5. 会话恢复、消息投影与性能稳定性 - -- 新增会话详情拉取、hydration、retry、metadata sync、finalize、post-finalize persistence 和切换快照控制器,把 `useAgentSession` 中的会话恢复逻辑拆成可测边界,降低切换历史会话时的竞态风险。 -- 新增 conversation projection store、历史消息 hydration、消息渲染窗口、timeline render 和 thread timeline window 投影,减少长历史消息列表渲染和恢复路径漂移。 -- 旧会话切换失败时增加可重试/可跳过分类, transient 失败不再直接破坏当前快照;不可恢复错误仍按原错误路径处理。 -- MessageList 和 Agent UI 性能指标补齐旧历史内容扫描、markdown 延迟渲染、线程 item 扫描和首帧 paint 的采集与回归。 -- 语音设置页滚动聚焦修复 release build 测试环境下的 `scrollIntoView` mock 污染,移除 release 环境缺失的 `@testing-library/react` 测试依赖。 - -#### 6. Harness 导出、审核与权限确认状态 - -- Evidence Pack、Handoff bundle、Analysis handoff、Replay case 和 Review decision 统一导出 `permissionState`,能区分 `not_requested`、`requested`、`resolved` 和 `denied`。 -- Review decision GUI、前端 API、Rust save API 和浏览器 mock 增加 denied 权限确认保护,阻止把带拒绝权限确认的运行误保存为 `accepted`。 -- Harness 状态面板和人工审核弹窗补齐权限确认状态、verification outcomes、copy prompt 和保存路径回归。 -- `agent_runtime_*` 网关、runtime thread read 和 mock 读取面继续保持同一 facts source,避免 Evidence / Replay / Review 各自解释权限状态。 - -#### 7. Release build 与本地开发 fallback - -- `scripts/lib/harness-eval-history-record.test.ts` 改为异步执行外部命令,避免 Vitest worker 在 release 校验中触发 `onTaskUpdate` timeout。 -- 本地浏览器开发模式下 Provider 读取失败时继续提供 Lime Hub mock,并用显式 `hasTauriRuntimeMarkers=false` 测试保护该 fallback。 -- `knowledge-gui-smoke` 更新为覆盖知识库入口、Agent 知识包上下文跳转和导入视图组织入口,纳入 GUI smoke 主链。 -- Warp modality runtime contract 守卫补齐 entry binding inventory 与 task index inventory 文档锚点,`npm run test:contracts` 继续覆盖治理文档缺失。 +- Knowledge 页面、导入入口、知识包选择和 workspace knowledge runtime 继续补稳定回归。 +- Knowledge GUI smoke 主链保持覆盖知识库入口、Agent 知识上下文跳转和导入视图组织入口。 +- 知识包、Skill、Memory、Inspiration 与 capability draft 的边界继续在路线图和执行计划中沉淀为 repo 内 artifact。 ### 开发者与治理更新 #### 1. 命令边界与 mock 同步 -- 新增 `lime-knowledge` Rust crate,Tauri command 只做薄适配,知识包文件事实源集中在后端 crate。 -- 新增前端网关 `src/lib/api/knowledge.ts` 与 feature 边界 `src/features/knowledge`,页面和 Hook 不直接散落裸 `invoke`。 -- 同步 `tauri::generate_handler!` 注册、`agentCommandCatalog`、`mockPriorityCommands` 和浏览器默认 mock,知识包命令纳入契约检查。 -- `npm run test:contracts` 覆盖新增知识包命令的前端调用、Rust 注册、治理目录册与 mock 边界。 +- 新增并同步 `capability_draft_create/list/get/verify/register` 命令族:前端 API、Rust command、DevBridge dispatcher、治理目录册、`mockPriorityCommands` 与默认 mock 保持一致。 +- `npm run test:contracts` 的命令契约仍覆盖新增命令族,避免前端、Rust 注册和浏览器 mock 漂移。 +- Release updater manifest 测试样例已更新到 `v1.28.0` 的 macOS asset 命名。 -#### 2. 文档与路线图 +#### 2. 路线图与执行计划 -- 新增 `docs/roadmap/knowledge/prd.md`,明确 KnowledgePack / Skill / Memory / Inspiration 边界和 P0/P1/P2 目标。 -- 新增 `docs/exec-plans/agent-knowledge-implementation-plan.md`,记录 Phase 1 current 主链、验证记录与后续切片。 -- Warp 和多模态运行合同文档补齐 Knowledge Context Resolver、runtime prompt stage 和执行 profile 说明。 -- 新增 AgentUI conversation projection 架构、fact map、实现计划与验收文档,把消息投影和会话恢复性能治理落到 repo 内 versioned artifact。 -- 新增 Warp entry binding inventory 与 task index inventory,明确 `@` / button / scene 入口只能绑定底层 runtime contract,任务索引不能反向依赖 UI 临时状态。 +- 新增 CreoAI / Capability Authoring、Verification、Registration 执行计划,明确“生成能力”和“执行能力”分层。 +- 新增 AI 图层化设计路线图与实现计划,固定 `LayeredDesignDocument` 是设计工程事实源。 +- 新增 Managed Objective 相关路线图,把跨 turn 目标推进控制层限定为 current runtime 的消费方,而不是新 runtime。 +- Warp / 多模态 runtime contract 文档继续补齐 task index、entry binding 与执行 profile 锚点。 ### 已知说明 -- 首版 Knowledge 仍坚持 Markdown-first,不做向量库、知识图谱、企业权限或知识包市场。 -- `knowledge_builder` 当前生成草稿,不会自动覆盖用户已确认的知识资产;用户仍需人工确认关键事实。 -- 知识包章节级 token 成本提示、细粒度章节选择和更完整 provenance / citation anchors 留在后续切片。 +- Capability Draft 当前只交付到 workspace-local 文件注册,不代表已经进入运行时 tool surface;P3B / P4 仍需补 catalog discovery、runtime binding、授权执行和 evidence 审计。 +- AI 图层化设计当前以协议、Canvas 入口和 image task artifact 写回为主,不直接新增 provider adapter、不声明完整 PSD / mask / inpaint 能力。 +- 标准 `cargo test --manifest-path "src-tauri/Cargo.toml"` 仍依赖 `local-sensevoice` 下的 `sherpa-onnx` 静态库归档;本轮冷环境中该归档下载 / 复用不稳定,发布前需在已准备 archive 的稳定 Rust target 中补跑一次完整 Rust 测试。 ### 校验状态 -- 本次重新覆盖 `v1.27.0` tag 前已完成: - - `npm run build` +- 本次版本准备已完成: + - `npm run verify:app-version` + - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` + - `SHERPA_ONNX_ARCHIVE_DIR="" CARGO_TARGET_DIR="/tmp/lime-release-verify-target" cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features` - `npm run lint` - `npm test` - - `npm run test:contracts` - - `npm run verify:gui-smoke` - - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` - - `CARGO_TARGET_DIR="/tmp/lime-release-verify-target" cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features` - - `cargo test --manifest-path "src-tauri/Cargo.toml"` - - `git diff --check` + - `CARGO_HOME="/tmp/lime-cargo-home" CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="/tmp/lime-release-verify-target" cargo test --manifest-path "src-tauri/Cargo.toml" --no-default-features services::runtime_evidence_pack_service::tests::should_export_runtime_evidence_pack_to_workspace --lib` - 结果说明: - - 前端全量 Vitest smart suite 46 批通过。 - - Rust 单测与集成测试通过;真实联网 web search 测试保持 ignored,需要 `LIME_REAL_API_TEST=1` 时单独执行。 - - GUI smoke 已复用 headless Tauri 环境完成 DevBridge、workspace ready、browser runtime、site adapters、service skill entry、runtime tool surface 和 Knowledge GUI 主路径验证。 + - 版本一致性检查通过:`1.28.0`。 + - Rust fmt 通过。 + - Rust clippy 全目标全特性通过;首次冷跑曾因 `sherpa-onnx-sys` 下载 GitHub release 归档 TLS 中断失败,改用本地 archive 后通过。 + - 前端 lint 通过;本轮顺手移除了 Review Decision 弹窗中未使用的 `permissionConfirmationDenied` 变量。 + - 前端 Vitest smart suite 49 批通过。 + - 标准 Rust `cargo test` 未完成:一次冷 target 触发 incremental dep-graph 临时文件移动错误;后续重跑受 `sherpa-onnx` archive 缺失 / 下载过慢影响。已修复并定向验证 Evidence Pack 权限确认 fixture,发布前仍需补完整 `cargo test --manifest-path "src-tauri/Cargo.toml"`。 --- -**完整变更**: `v1.26.0` -> `v1.27.0` +**完整变更**: `v1.27.0` -> `v1.28.0` diff --git a/agentui-home-send-e2e-after-fast-route-final.json b/agentui-home-send-e2e-after-fast-route-final.json deleted file mode 100644 index 1d5660c47..000000000 --- a/agentui-home-send-e2e-after-fast-route-final.json +++ /dev/null @@ -1,455 +0,0 @@ -{ - "href": "http://127.0.0.1:1420/", - "textIncludesAnswer": true, - "visibleTail": "Lime\n邀请好友\n搜索任务\n新建任务\n我的方法\n灵感库\n知识库\n最近对话\nRan into this erro...\n4分\n好\n4时\n好\n5时\n好\n5时\n好\n6时\n到\n6时\n单字回复练习\n6时\n现在是 2026 年 5 月 1 日...\n7时\n还没到睡觉时间,速度刚刚好 😄\n7时\n好的,明白了。不过需要跟你说明一下我...\n10时\n查看更多对话\n归档\n开\n开源使用\n本地可用\n默认项目\nHarness\n新对话\n\n只回答一个字:好\n\n好\n\n已完成\n·\n00:01\n高级设置\n当前模型\ngpt-5.5", - "summary": { - "entries": [ - { - "id": 1, - "phase": "homeInput.submit", - "at": 173628.80000001192, - "wallTime": 1777670843001, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 8, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 2, - "phase": "homeInput.pendingShellApplied", - "at": 173628.90000003576, - "wallTime": 1777670843001, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 3, - "phase": "homeInput.pendingPreviewPaint", - "at": 173661.80000001192, - "wallTime": 1777670843034, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 33, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 4, - "phase": "homeInput.sendDispatch.start", - "at": 173662, - "wallTime": 1777670843034, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 33, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 5, - "phase": "workspaceSend.plan.ready", - "at": 173685.30000001192, - "wallTime": 1777670843057, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 22, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 6, - "phase": "agentStream.ensureSession.start", - "at": 173687.20000004768, - "wallTime": 1777670843059, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "homeSubmittedDeltaMs": 58, - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 7, - "phase": "agentStream.ensureSession.done", - "at": 173716.5, - "wallTime": 1777670843088, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "activeSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "durationMs": 29, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 87, - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 8, - "phase": "agentStream.request.start", - "at": 173716.80000001192, - "wallTime": 1777670843089, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "contentLength": 8, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月2日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 88, - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 9, - "phase": "agentStream.listenerBound", - "at": 173744.40000003576, - "wallTime": 1777670843116, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 27, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 115, - "usedJSHeapSize": 332701948, - "totalJSHeapSize": 343064132 - } - }, - { - "id": 10, - "phase": "agentStream.submitDispatched", - "at": 173744.70000004768, - "wallTime": 1777670843117, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 28, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "expectingQueue": false, - "listenerBoundDeltaMs": 1, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 116, - "usedJSHeapSize": 332701948, - "totalJSHeapSize": 343064132 - } - }, - { - "id": 11, - "phase": "agentStream.submitAccepted", - "at": 173814.20000004768, - "wallTime": 1777670843186, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 97, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "submitInvokeMs": 69, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 185, - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 12, - "phase": "homeInput.sendDispatch.done", - "at": 173814.5, - "wallTime": 1777670843186, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 185, - "requestId": "draft-send-monfbl2x-b420b99c", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 13, - "phase": "agentStream.firstEvent", - "at": 173830.40000003576, - "wallTime": 1777670843202, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 113, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 85, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 201, - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 14, - "phase": "agentStream.firstRuntimeStatus", - "at": 173830.70000004768, - "wallTime": 1777670843203, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 114, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstEventDeltaMs": 1, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 202, - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 15, - "phase": "agentStream.firstTextDelta", - "at": 175515.10000002384, - "wallTime": 1777670844887, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "deltaChars": 1, - "elapsedMs": 1798, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstEventDeltaMs": 1685, - "firstRuntimeStatusDeltaMs": 1684, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 1886, - "usedJSHeapSize": 336121730, - "totalJSHeapSize": 345240750 - } - }, - { - "id": 16, - "phase": "agentStream.firstTextRenderFlush", - "at": 175516.30000001192, - "wallTime": 1777670844888, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 1799, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstTextDeltaDeltaMs": 1, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 1887, - "usedJSHeapSize": 336121730, - "totalJSHeapSize": 345240750 - } - }, - { - "id": 17, - "phase": "agentStream.firstTextPaint", - "at": 175543.20000004768, - "wallTime": 1777670844915, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 1826, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstTextDeltaDeltaMs": 28, - "renderFlushDeltaMs": 27, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 1914, - "usedJSHeapSize": 336121730, - "totalJSHeapSize": 345240750 - } - }, - { - "id": 18, - "phase": "agentRuntime.getSession.start", - "at": 175607.80000001192, - "wallTime": 1777670844980, - "sessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "usedJSHeapSize": 334985300, - "totalJSHeapSize": 345865076 - } - }, - { - "id": 19, - "phase": "agentRuntime.getSession.success", - "at": 175640, - "wallTime": 1777670845012, - "sessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "childSubagentSessionsCount": 0, - "durationMs": 32, - "itemsCount": 3, - "messagesCount": 2, - "queuedTurnsCount": 0, - "turnsCount": 1, - "usedJSHeapSize": 334985300, - "totalJSHeapSize": 345865076 - } - } - ], - "sessions": [ - { - "sessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "workspaceId": null, - "runtimeGetSessionDurationMs": 32, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 1, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 334985300, - "phases": [ - "agentRuntime.getSession.start", - "agentRuntime.getSession.success" - ] - }, - { - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 33, - "homeInputToSendDispatchMs": 33, - "homeInputToSendPlanReadyMs": 57, - "homeInputToStreamRequestStartMs": 88, - "homeInputToSubmitAcceptedMs": 185, - "homeInputToFirstEventMs": 202, - "homeInputToFirstRuntimeStatusMs": 202, - "homeInputToFirstTextDeltaMs": 1886, - "homeInputToFirstTextRenderFlushMs": 1888, - "homeInputToFirstTextPaintMs": 1914, - "sendDispatchToSubmitAcceptedMs": 152, - "streamSubmitDispatchedToAcceptedMs": 70, - "submitAcceptedToFirstEventMs": 16, - "firstEventToFirstTextDeltaMs": 1685, - "firstTextDeltaToFirstTextPaintMs": 28, - "streamEnsureSessionDurationMs": 29, - "streamSubmitInvokeDurationMs": 69, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 336121730, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.ensureSession.start", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstTextDelta", - "agentStream.firstTextRenderFlush", - "agentStream.firstTextPaint" - ] - } - ] - } -} \ No newline at end of file diff --git a/agentui-home-send-e2e-after-fast-route-initial.json b/agentui-home-send-e2e-after-fast-route-initial.json deleted file mode 100644 index 7ccbfbd86..000000000 --- a/agentui-home-send-e2e-after-fast-route-initial.json +++ /dev/null @@ -1,455 +0,0 @@ -{ - "href": "http://127.0.0.1:1420/", - "hasPerf": true, - "snapshot": null, - "summary": { - "entries": [ - { - "id": 1, - "phase": "homeInput.submit", - "at": 173628.80000001192, - "wallTime": 1777670843001, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 8, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 2, - "phase": "homeInput.pendingShellApplied", - "at": 173628.90000003576, - "wallTime": 1777670843001, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 3, - "phase": "homeInput.pendingPreviewPaint", - "at": 173661.80000001192, - "wallTime": 1777670843034, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 33, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 4, - "phase": "homeInput.sendDispatch.start", - "at": 173662, - "wallTime": 1777670843034, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 33, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 331216655, - "totalJSHeapSize": 334290911 - } - }, - { - "id": 5, - "phase": "workspaceSend.plan.ready", - "at": 173685.30000001192, - "wallTime": 1777670843057, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 22, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-monfbl2x-b420b99c", - "source": "empty-state", - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 6, - "phase": "agentStream.ensureSession.start", - "at": 173687.20000004768, - "wallTime": 1777670843059, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "homeSubmittedDeltaMs": 58, - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 7, - "phase": "agentStream.ensureSession.done", - "at": 173716.5, - "wallTime": 1777670843088, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "activeSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "durationMs": 29, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 87, - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 8, - "phase": "agentStream.request.start", - "at": 173716.80000001192, - "wallTime": 1777670843089, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "contentLength": 8, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月2日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 88, - "usedJSHeapSize": 333314474, - "totalJSHeapSize": 338865214 - } - }, - { - "id": 9, - "phase": "agentStream.listenerBound", - "at": 173744.40000003576, - "wallTime": 1777670843116, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 27, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 115, - "usedJSHeapSize": 332701948, - "totalJSHeapSize": 343064132 - } - }, - { - "id": 10, - "phase": "agentStream.submitDispatched", - "at": 173744.70000004768, - "wallTime": 1777670843117, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 28, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "expectingQueue": false, - "listenerBoundDeltaMs": 1, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 116, - "usedJSHeapSize": 332701948, - "totalJSHeapSize": 343064132 - } - }, - { - "id": 11, - "phase": "agentStream.submitAccepted", - "at": 173814.20000004768, - "wallTime": 1777670843186, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 97, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "submitInvokeMs": 69, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 185, - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 12, - "phase": "homeInput.sendDispatch.done", - "at": 173814.5, - "wallTime": 1777670843186, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 185, - "requestId": "draft-send-monfbl2x-b420b99c", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 13, - "phase": "agentStream.firstEvent", - "at": 173830.40000003576, - "wallTime": 1777670843202, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 113, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 85, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 201, - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 14, - "phase": "agentStream.firstRuntimeStatus", - "at": 173830.70000004768, - "wallTime": 1777670843203, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 114, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstEventDeltaMs": 1, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 202, - "usedJSHeapSize": 333152554, - "totalJSHeapSize": 343301766 - } - }, - { - "id": 15, - "phase": "agentStream.firstTextDelta", - "at": 175515.10000002384, - "wallTime": 1777670844887, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "deltaChars": 1, - "elapsedMs": 1798, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstEventDeltaMs": 1685, - "firstRuntimeStatusDeltaMs": 1684, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 1886, - "usedJSHeapSize": 336121730, - "totalJSHeapSize": 345240750 - } - }, - { - "id": 16, - "phase": "agentStream.firstTextRenderFlush", - "at": 175516.30000001192, - "wallTime": 1777670844888, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 1799, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstTextDeltaDeltaMs": 1, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 1887, - "usedJSHeapSize": 336121730, - "totalJSHeapSize": 345240750 - } - }, - { - "id": 17, - "phase": "agentStream.firstTextPaint", - "at": 175543.20000004768, - "wallTime": 1777670844915, - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 1826, - "eventName": "aster_stream_5715b69e-e7dd-404e-bc7b-1ae328c12789", - "firstTextDeltaDeltaMs": 28, - "renderFlushDeltaMs": 27, - "source": "empty-state", - "requestId": "draft-send-monfbl2x-b420b99c", - "actualSessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "homeSubmittedDeltaMs": 1914, - "usedJSHeapSize": 336121730, - "totalJSHeapSize": 345240750 - } - }, - { - "id": 18, - "phase": "agentRuntime.getSession.start", - "at": 175607.80000001192, - "wallTime": 1777670844980, - "sessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "usedJSHeapSize": 334985300, - "totalJSHeapSize": 345865076 - } - }, - { - "id": 19, - "phase": "agentRuntime.getSession.success", - "at": 175640, - "wallTime": 1777670845012, - "sessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "childSubagentSessionsCount": 0, - "durationMs": 32, - "itemsCount": 3, - "messagesCount": 2, - "queuedTurnsCount": 0, - "turnsCount": 1, - "usedJSHeapSize": 334985300, - "totalJSHeapSize": 345865076 - } - } - ], - "sessions": [ - { - "sessionId": "2c898254-c77f-438b-a534-816789d1d2f2", - "workspaceId": null, - "runtimeGetSessionDurationMs": 32, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 1, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 334985300, - "phases": [ - "agentRuntime.getSession.start", - "agentRuntime.getSession.success" - ] - }, - { - "sessionId": "draft-send-monfbl2x-b420b99c", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 33, - "homeInputToSendDispatchMs": 33, - "homeInputToSendPlanReadyMs": 57, - "homeInputToStreamRequestStartMs": 88, - "homeInputToSubmitAcceptedMs": 185, - "homeInputToFirstEventMs": 202, - "homeInputToFirstRuntimeStatusMs": 202, - "homeInputToFirstTextDeltaMs": 1886, - "homeInputToFirstTextRenderFlushMs": 1888, - "homeInputToFirstTextPaintMs": 1914, - "sendDispatchToSubmitAcceptedMs": 152, - "streamSubmitDispatchedToAcceptedMs": 70, - "submitAcceptedToFirstEventMs": 16, - "firstEventToFirstTextDeltaMs": 1685, - "firstTextDeltaToFirstTextPaintMs": 28, - "streamEnsureSessionDurationMs": 29, - "streamSubmitInvokeDurationMs": 69, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 336121730, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.ensureSession.start", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstTextDelta", - "agentStream.firstTextRenderFlush", - "agentStream.firstTextPaint" - ] - } - ] - } -} \ No newline at end of file diff --git a/agentui-home-send-e2e-fast-route-repeat.json b/agentui-home-send-e2e-fast-route-repeat.json deleted file mode 100644 index 77e9ccf2d..000000000 --- a/agentui-home-send-e2e-fast-route-repeat.json +++ /dev/null @@ -1,488 +0,0 @@ -{ - "href": "http://127.0.0.1:1420/", - "textTail": "Lime\n邀请好友\n搜索任务\n新建任务\n我的方法\n灵感库\n知识库\n最近对话\nRan into this erro...\n6分\n好\n4时\n好\n5时\n好\n5时\n好\n6时\n到\n6时\n单字回复练习\n6时\n现在是 2026 年 5 月 1 日...\n7时\n还没到睡觉时间,速度刚刚好 😄\n7时\n好的,明白了。不过需要跟你说明一下我...\n10时\n查看更多对话\n归档\n开\n开源使用\n本地可用\n默认项目\nHarness\n新对话\n\n只回答一个字:好\n\n好\n\n已完成\n·\n00:00\n高级设置\n当前模型\ndeepseek-chat", - "summary": { - "entries": [ - { - "id": 1, - "phase": "agentRuntime.listSessions.start", - "at": 307458.8000000119, - "wallTime": 1777670976832, - "sessionId": null, - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "usedJSHeapSize": 334026462, - "totalJSHeapSize": 345185974 - } - }, - { - "id": 2, - "phase": "agentRuntime.listSessions.success", - "at": 307833.3000000119, - "wallTime": 1777670977206, - "sessionId": null, - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": null, - "metrics": { - "archivedOnly": false, - "includeArchived": false, - "limit": 21, - "durationMs": 374, - "sessionsCount": 21, - "usedJSHeapSize": 337185407, - "totalJSHeapSize": 348542659 - } - }, - { - "id": 3, - "phase": "homeInput.submit", - "at": 308672.40000003576, - "wallTime": 1777670978045, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "hasDraftTab": false, - "inputLength": 8, - "requestId": "draft-send-monfeha5-f92d8ec4", - "source": "empty-state", - "usedJSHeapSize": 334958065, - "totalJSHeapSize": 349132733 - } - }, - { - "id": 4, - "phase": "homeInput.pendingShellApplied", - "at": 308672.40000003576, - "wallTime": 1777670978045, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 0, - "requestId": "draft-send-monfeha5-f92d8ec4", - "source": "empty-state", - "usedJSHeapSize": 334958065, - "totalJSHeapSize": 349132733 - } - }, - { - "id": 5, - "phase": "homeInput.pendingPreviewPaint", - "at": 308694.90000003576, - "wallTime": 1777670978068, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 23, - "requestId": "draft-send-monfeha5-f92d8ec4", - "source": "empty-state", - "usedJSHeapSize": 334958065, - "totalJSHeapSize": 349132733 - } - }, - { - "id": 6, - "phase": "homeInput.sendDispatch.start", - "at": 308694.90000003576, - "wallTime": 1777670978068, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 23, - "requestId": "draft-send-monfeha5-f92d8ec4", - "source": "empty-state", - "usedJSHeapSize": 334958065, - "totalJSHeapSize": 349132733 - } - }, - { - "id": 7, - "phase": "workspaceSend.plan.ready", - "at": 308706.2000000477, - "wallTime": 1777670978079, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 11, - "hasPendingSessionBinding": false, - "primedSessionId": null, - "requestId": "draft-send-monfeha5-f92d8ec4", - "source": "empty-state", - "usedJSHeapSize": 334958065, - "totalJSHeapSize": 349132733 - } - }, - { - "id": 8, - "phase": "agentStream.ensureSession.start", - "at": 308707, - "wallTime": 1777670978080, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "homeSubmittedDeltaMs": 35, - "usedJSHeapSize": 334958065, - "totalJSHeapSize": 349132733 - } - }, - { - "id": 9, - "phase": "agentStream.ensureSession.done", - "at": 308723.2000000477, - "wallTime": 1777670978096, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "activeSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "durationMs": 16, - "hadActiveSessionBeforeEnsure": false, - "skipSessionRestore": true, - "skipSessionStartHooks": true, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 51, - "usedJSHeapSize": 337592908, - "totalJSHeapSize": 350212048 - } - }, - { - "id": 10, - "phase": "agentStream.request.start", - "at": 308723.40000003576, - "wallTime": 1777670978096, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "contentLength": 8, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "expectingQueue": false, - "model": "deepseek-chat", - "provider": "deepseek", - "skipUserMessage": false, - "systemPromptLength": 163, - "systemPromptPreview": "你是 Lime 的快速响应助手。当前日期:2026年5月2日。\n本回合是轻量首轮普通对话,请直接", - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 51, - "usedJSHeapSize": 337592908, - "totalJSHeapSize": 350212048 - } - }, - { - "id": 11, - "phase": "agentStream.listenerBound", - "at": 308738.3000000119, - "wallTime": 1777670978111, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 15, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "expectingQueue": false, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 66, - "usedJSHeapSize": 337592908, - "totalJSHeapSize": 350212048 - } - }, - { - "id": 12, - "phase": "agentStream.submitDispatched", - "at": 308738.5, - "wallTime": 1777670978111, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 15, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "expectingQueue": false, - "listenerBoundDeltaMs": 0, - "model": "deepseek-chat", - "provider": "deepseek", - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 66, - "usedJSHeapSize": 337592908, - "totalJSHeapSize": 350212048 - } - }, - { - "id": 13, - "phase": "agentStream.submitAccepted", - "at": 308805.40000003576, - "wallTime": 1777670978178, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 82, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "submitInvokeMs": 67, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 133, - "usedJSHeapSize": 338132440, - "totalJSHeapSize": 350811032 - } - }, - { - "id": 14, - "phase": "homeInput.sendDispatch.done", - "at": 308805.60000002384, - "wallTime": 1777670978179, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "durationMs": 134, - "requestId": "draft-send-monfeha5-f92d8ec4", - "result": true, - "source": "empty-state", - "usedJSHeapSize": 338132440, - "totalJSHeapSize": 350811032 - } - }, - { - "id": 15, - "phase": "agentStream.firstEvent", - "at": 308822.8000000119, - "wallTime": 1777670978196, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 100, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "eventType": "runtime_status", - "recognized": true, - "submissionDispatchedDeltaMs": 85, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 151, - "usedJSHeapSize": 338132440, - "totalJSHeapSize": 350811032 - } - }, - { - "id": 16, - "phase": "agentStream.firstRuntimeStatus", - "at": 308823, - "wallTime": 1777670978196, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 100, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "firstEventDeltaMs": 0, - "phase": "preparing", - "title": "已接收请求,正在准备执行", - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 151, - "usedJSHeapSize": 338132440, - "totalJSHeapSize": 350811032 - } - }, - { - "id": 17, - "phase": "agentStream.firstTextDelta", - "at": 310231.3000000119, - "wallTime": 1777670979604, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "deltaChars": 1, - "elapsedMs": 1508, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "firstEventDeltaMs": 1408, - "firstRuntimeStatusDeltaMs": 1408, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 1559, - "usedJSHeapSize": 334650595, - "totalJSHeapSize": 346722383 - } - }, - { - "id": 18, - "phase": "agentStream.firstTextRenderFlush", - "at": 310232.2000000477, - "wallTime": 1777670979605, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 1509, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "firstTextDeltaDeltaMs": 1, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 1560, - "usedJSHeapSize": 334650595, - "totalJSHeapSize": 346722383 - } - }, - { - "id": 19, - "phase": "agentStream.firstTextPaint", - "at": 310255.40000003576, - "wallTime": 1777670979628, - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "source": "empty-state", - "metrics": { - "elapsedMs": 1532, - "eventName": "aster_stream_bdabcc34-7aeb-4e6d-b5e1-705154a27085", - "firstTextDeltaDeltaMs": 24, - "renderFlushDeltaMs": 23, - "source": "empty-state", - "requestId": "draft-send-monfeha5-f92d8ec4", - "actualSessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "homeSubmittedDeltaMs": 1583, - "usedJSHeapSize": 334650595, - "totalJSHeapSize": 346722383 - } - }, - { - "id": 20, - "phase": "agentRuntime.getSession.start", - "at": 310345, - "wallTime": 1777670979718, - "sessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "usedJSHeapSize": 334412749, - "totalJSHeapSize": 349607645 - } - }, - { - "id": 21, - "phase": "agentRuntime.getSession.success", - "at": 310375.5, - "wallTime": 1777670979748, - "sessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "workspaceId": null, - "source": null, - "metrics": { - "historyLimit": 40, - "historyOffset": null, - "historyBeforeMessageId": null, - "resumeSessionStartHooks": false, - "childSubagentSessionsCount": 0, - "durationMs": 30, - "itemsCount": 3, - "messagesCount": 2, - "queuedTurnsCount": 0, - "turnsCount": 1, - "usedJSHeapSize": 334412749, - "totalJSHeapSize": 349607645 - } - } - ], - "sessions": [ - { - "sessionId": "37a17bd4-3661-4799-a024-de117d181e21", - "workspaceId": null, - "runtimeGetSessionDurationMs": 30, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 1, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 334412749, - "phases": [ - "agentRuntime.getSession.start", - "agentRuntime.getSession.success" - ] - }, - { - "sessionId": "draft-send-monfeha5-f92d8ec4", - "workspaceId": "849e36ff-8f64-45ed-ba51-aab6b8e182e4", - "homeInputToPendingShellMs": 0, - "homeInputToPendingPreviewPaintMs": 23, - "homeInputToSendDispatchMs": 23, - "homeInputToSendPlanReadyMs": 34, - "homeInputToStreamRequestStartMs": 51, - "homeInputToSubmitAcceptedMs": 133, - "homeInputToFirstEventMs": 150, - "homeInputToFirstRuntimeStatusMs": 151, - "homeInputToFirstTextDeltaMs": 1559, - "homeInputToFirstTextRenderFlushMs": 1560, - "homeInputToFirstTextPaintMs": 1583, - "sendDispatchToSubmitAcceptedMs": 111, - "streamSubmitDispatchedToAcceptedMs": 67, - "submitAcceptedToFirstEventMs": 17, - "firstEventToFirstTextDeltaMs": 1409, - "firstTextDeltaToFirstTextPaintMs": 24, - "streamEnsureSessionDurationMs": 16, - "streamSubmitInvokeDurationMs": 67, - "switchStartCount": 0, - "fetchDetailStartCount": 0, - "fetchDetailErrorCount": 0, - "runtimeGetSessionStartCount": 0, - "runtimeGetSessionErrorCount": 0, - "messageListPaintCount": 0, - "longTaskCount": 0, - "threadItemsScanDeferredCount": 0, - "maxUsedJSHeapSize": 338132440, - "phases": [ - "homeInput.submit", - "homeInput.pendingShellApplied", - "homeInput.pendingPreviewPaint", - "homeInput.sendDispatch.start", - "workspaceSend.plan.ready", - "agentStream.ensureSession.start", - "agentStream.ensureSession.done", - "agentStream.request.start", - "agentStream.listenerBound", - "agentStream.submitDispatched", - "agentStream.submitAccepted", - "homeInput.sendDispatch.done", - "agentStream.firstEvent", - "agentStream.firstRuntimeStatus", - "agentStream.firstTextDelta", - "agentStream.firstTextRenderFlush", - "agentStream.firstTextPaint" - ] - } - ] - } -} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 310db0ce3..97abb6ab0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,7 +46,18 @@ - `develop/scheduler-task-governance-p1.md`:调度任务治理 P1(连续失败、自动停用、冷却恢复) - `aiprompts/skill-standard.md`:Skills 包标准、运行时投影与 current/compat 边界总文档 - `roadmap/lime-skills-standardization-roadmap.md`:Skills 标准化 supporting 收口计划,主要保留迁移边界与剩余差距 +- `research/creaoai/README.md`:CreoAI / Tool-Maker Agent 研究入口,拆解能力生成、验证、注册与长期运行业务的外部范式 +- `research/pi-mono-coding-agent/README.md`:pi-mono Coding Agent 本地调研,提炼 `AgentSession` 分层、工具 allowlist、可插拔工具后端、事件与测试 harness 对 Lime Capability Authoring Agent 的参考边界 +- `research/codex-goal/README.md`:Codex `/goal` 研究入口,独立记录 persistent objective / idle continuation turn / completion audit 模式 +- `research/codex-goal/diagrams.md`:Codex `/goal` Thread Goal Loop 图纸,包含架构图、流程图、时序图、状态机和最小心智原型 - `research/ribbi/README.md`:Ribbi 研究总入口,作为后续 LimeNext V2 的外部对照事实源 +- `research/ai-layered-design/README.md`:AI 图层化设计研究入口,拆解 Lovart 类可编辑图层、分割、抠图、背景修补与 Canvas 工程范式 +- `roadmap/creaoai/README.md`:CreoAI 启发下的 Skill Forge / workspace-local generated skill 路线图 +- `roadmap/creaoai/prototype.md`:Skill Forge / generated capability / verification gate / workspace-local skill 的产品原型图 +- `roadmap/creaoai/architecture-review.md`:CreoAI / Coding Agent 方案实现前 review gate,列出 draft store、verification、registration、evidence、安全边界缺口 +- `roadmap/managed-objective/README.md`:Managed Objective 路线图,把 thread goal loop 启发收敛为 Lime 的跨 turn 目标推进控制层 +- `roadmap/managed-objective/prototype.md`:Managed Objective 在 Workspace、Task Center、Audit Drawer 和创建流程中的产品原型图 +- `roadmap/ai-layered-design/README.md`:AI 图层化设计路线图,把图片生成升级为可编辑的多图层设计工程输出 - `roadmap/limenextv2/README.md`:LimeNext V2 当前主规划入口,固定前台对象、skill-first 主线与运行时骨架 - `roadmap/limenext/README.md`:LimeNext 旧总纲入口,当前降级为 `legacy current reference`,主要保留实现锚点与阶段性收口记录 - `roadmap/limenext/sceneapp-capability-model.md`:SceneApp 底层能力模型,定义本地、浏览器、云端、混合场景需要的能力模块范围 diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 8198dfab4..18309d5aa 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -65,6 +65,8 @@ - **改 system prompt / subagent prompt / plan prompt / prompt_context / augmentation 顺序**:先读 `prompt-foundation.md`,再回看 `query-loop.md` - **改 turn 提交 / prompt 组包 / queue / compaction / evidence 主链**:先读 `query-loop.md` - **改 subagent / automation / execution tracker / scheduler taxonomy**:先读 `task-agent-taxonomy.md` +- **讨论 `/goal`、Managed Objective 或跨 turn 目标续跑**:先读 `task-agent-taxonomy.md` 与 `query-loop.md`,再读 `../research/codex-goal/README.md` 与 `../roadmap/managed-objective/README.md` +- **讨论 Coding Agent、Skill Forge 或能力生成 draft**:先读 `query-loop.md` 与 `skill-standard.md`,再读 `../research/pi-mono-coding-agent/README.md` 与 `../roadmap/creaoai/coding-agent-layer.md` - **改 channels / browser connector / DevBridge / OpenClaw remote runtime**:先读 `remote-runtime.md` - **改记忆来源链 / working memory / durable memory / Team Memory / compaction**:先读 `memory-compaction.md` - **改 FileArtifact / artifact sidecar / versions / file checkpoint / evidence 中的文件快照**:先读 `persistence-map.md` diff --git a/docs/aiprompts/command-runtime.md b/docs/aiprompts/command-runtime.md index 2fb2a8a62..77fdee533 100644 --- a/docs/aiprompts/command-runtime.md +++ b/docs/aiprompts/command-runtime.md @@ -54,6 +54,11 @@ Lime 的命令体系固定按以下关系理解: 6. UI 的正式消费对象是统一 `CommandRunSnapshot` 聊天区轻卡和右侧 viewer 不应直接绑定底层 task、run 或原始响应结构。 +补充边界: + +- [Codex `/goal`](../research/codex-goal/README.md) 是 persistent objective / continuation loop 参考,不是 Lime 产品型 `/` 场景命令模板。 +- 如果后续 Lime 出现目标推进入口,它也必须触发现有 `ServiceSkill / automation job / agent turn` 主链,而不是在 slash 层新增一套 goal 执行壳。 + ## 创作主线护栏 当前 Lime 的命令运行时默认服务“创作生产与交付”主线。 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 0a4be097c..de9999e0d 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -129,6 +129,20 @@ - `fallbackStrategy` - 聊天结果沉淀为技能时,只能继续扩这组说明型字段,不要再平行发明第二套“技能草稿协议” +CreoAI Capability Draft 命令链也必须停留在独立的生成 / 验证 / 注册边界: + +- 前端统一经由 `src/lib/api/capabilityDrafts.ts` 承接: + - `capability_draft_create` + - `capability_draft_list` + - `capability_draft_get` + - `capability_draft_verify` + - `capability_draft_register` +- `capability_draft_list_registered_skills` +- `capability_draft_create/list/get/verify/register/list_registered_skills` 只服务 `Capability Draft -> Workspace-local Skill package -> registered discovery` 的事实链,不是 runtime 执行入口 +- `capability_draft_register` 只允许把 `verified_pending_registration` 草案复制到当前 `workspaceRoot/.agents/skills`,并记录来源、verification report 与权限摘要;它不得调用 Skill reload、不得修改 seeded skill、不得把能力直接放进默认 tool surface +- `capability_draft_list_registered_skills` 只能显式按 `workspaceRoot` 读取当前项目 `.agents/skills` 中带 `.lime/registration.json` 的 P3A 注册能力;它只做 catalog discovery / provenance projection,不得把能力合并进默认已安装方法列表、不得触发 runtime binding、不得展示运行或自动化入口 +- 注册后的执行仍必须回到 `agent_runtime_submit_turn -> Query Loop -> tool_runtime -> artifact/evidence` 主链,不能在 Capability Draft 命令里新增平行运行、调度或外部写协议 + 当前 `/scene-key` 的发送主链也已经固定: - 发送前由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 统一拦截 slash 场景 diff --git a/docs/aiprompts/harness-engine-governance.md b/docs/aiprompts/harness-engine-governance.md index d592e4f89..522c90b1f 100644 --- a/docs/aiprompts/harness-engine-governance.md +++ b/docs/aiprompts/harness-engine-governance.md @@ -37,7 +37,7 @@ 其中: - `evidence pack` 是 **运行时事实源** -- `replay / analysis / review / history-record summary` 是 **派生物** +- `replay / analysis / review / history-record summary / Managed Objective audit` 是 **派生物** - GUI 面板例如 `HarnessStatusPanel` 与 nightly dashboard 是 **展示层** 允许的数据方向只有: @@ -48,8 +48,11 @@ - `analysis` 自己再拼一套 observability summary - `replay` 不复用 evidence pack,改为本地猜测缺口 +- `Managed Objective` 不读 evidence pack,改由模型自报完成 - `UI` 的显示状态反过来成为事实源 +Managed Objective 的详细路线图见 `docs/roadmap/managed-objective/README.md`。在 harness 语境中,它只能是 evidence pack 的消费方和审计派生物,不能成为新的证据导出链。 + ## 分类语言 Harness Engine 相关 surface 继续沿用仓库统一分类: diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index ac459330e..132e45841 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -91,6 +91,8 @@ 如果本轮涉及 `create_skill_scaffold_for_app`、`SkillsPage / SkillScaffoldDialog`,或“聊天结果 -> Skill 脚手架”沉淀闭环,还要同步检查前端网关、Rust 模板、DevBridge 分发与默认 mock 是否仍保持同一条主链;若新增了结构化骨架字段,至少要确认 `何时使用 / 输入 / 执行步骤 / 输出 / 失败回退` 能真实落进生成后的 `SKILL.md`。 +如果本轮涉及 `capability_draft_create/list/get/verify/register/list_registered_skills`,还要同步检查 `src/lib/api/capabilityDrafts.ts`、`capability_draft_cmd`、`capability_draft_service`、DevBridge dispatcher、治理目录册、`mockPriorityCommands` 与 `defaultMocks`;注册命令只能证明 workspace-local Agent Skill 包已落盘,registered discovery 只能证明当前 workspace 可发现带 provenance 的 Skill 包,不能把“已注册 / 已发现”当成“已进入 tool surface / 可自动运行”。最低校验至少包含 Rust capability draft 定向测试、前端 API / UI 回归、`npm run test:contracts`;若 Skills 工作台可见行为变化,再补 `npm run verify:gui-smoke`。 + 如果本轮涉及记忆主链,还要同步检查 `src/lib/api/memoryRuntime.ts`、`src-tauri/src/commands/memory_management_cmd.rs`、`runner.rs`、DevBridge dispatcher 与默认 mock 是否仍保持同一条 current surface;`rules / working / durable / team / compaction` 的产品分层可以在页面上拆开,但底层命令边界仍必须继续收敛到 `memory_runtime_*` 与 `unified_memory_*`。 ### 3. 用户可见 UI 改动必须补稳定回归 @@ -248,6 +250,7 @@ CI 里的 `.github/workflows/quality.yml` 结果摘要现在也会透出 `bridge - 修改 `safeInvoke` / `invoke` - 修改 `execute_skill`、`list_executable_skills`、`get_skill_detail` 或它们在 DevBridge / mock 中的分流 - 修改 `create_skill_scaffold_for_app`、技能草稿透传字段,或“聊天结果 -> Skill 脚手架”主链 +- 修改 `capability_draft_*` 生成、验证或注册命令,或 Skills 工作台的 Capability Draft 隔离区 - 修改 `src/lib/api/document-export.ts`、`save_exported_document`,或把新的 GUI 导出入口接到本地文件保存主链 - 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` - 修改 `agent_runtime_submit_turn.turn_config.provider_config.model_capabilities / tool_call_strategy / toolshim_model` diff --git a/docs/aiprompts/query-loop.md b/docs/aiprompts/query-loop.md index 9521a4117..a637f9d96 100644 --- a/docs/aiprompts/query-loop.md +++ b/docs/aiprompts/query-loop.md @@ -54,6 +54,20 @@ 6. **证据链是主链的下游消费,不是旁路真相** `thread_read / evidence / replay / review` 只能复用主回合产生的 runtime facts,不能反向定义 Query Loop 真相。 +### Managed Objective / `/goal` 类能力边界 + +[Codex `/goal`](../research/codex-goal/README.md) 证明了 “persistent objective -> idle continuation turn” 可以由 runtime 管理,但 Lime 不能因此新增第二条 Query Loop。 + +如果后续实现 `Managed Objective`: + +1. continuation turn 仍必须通过 `agent_runtime_submit_turn` 或 runtime queue 进入本主链。 +2. durable 后台目标仍必须挂到 `automation job`,不能自建 scheduler。 +3. 完成审计必须消费 `artifact / evidence / thread_read`,不能只靠模型自报完成。 +4. `auto_continue` 仍只表示当前已有文稿续写的 prompt augmentation,不等同于 persistent objective。 +5. 如果用户有 queued input、pending elicitation、pause、budget limit 或 blocked 状态,不能自动续跑下一轮。 + +详细路线图见 `docs/roadmap/managed-objective/README.md`。这里的固定边界只负责说明:任何 continuation turn 都必须回到 Query Loop 主链。 + ## 代码入口地图 ### 1. 提交入口 diff --git a/docs/aiprompts/skill-standard.md b/docs/aiprompts/skill-standard.md index 25bf5d3de..3be5e635b 100644 --- a/docs/aiprompts/skill-standard.md +++ b/docs/aiprompts/skill-standard.md @@ -118,6 +118,20 @@ Lime 在工程上必须明确接受这一点: 1. `SKILL.md` 不是 Lime 的最终产品对象。 2. `ServiceSkill` / `Scene` 也不是新的包格式标准。 3. `ServiceSkill` / `Scene` 是 Lime 在 Agent Skills 之上的产品投影层。 +4. CreoAI / Capability Draft 注册只负责把已验证草案复制成 workspace-local Agent Skill 包;它不等同于运行时绑定,也不能绕过 Query Loop 与 `tool_runtime` 直接执行。 + +因此,generated skill 的最小安全顺序固定为: + +```text +Capability Draft + -> verification gate + -> workspace-local Agent Skill package + -> registered discovery / catalog projection + -> runtime binding + -> artifact / evidence +``` + +其中前三步只建立包和来源事实;`registered discovery` 只证明当前 workspace 里有带 provenance 的标准 Skill 包,仍不等于可运行。只有进入 runtime binding 和 `tool_runtime` 授权后,才回答“是否可被本轮 Agent 看到和调用”。 ## 设计原则补充 @@ -260,6 +274,12 @@ Lime 的技能标准必须分成五层: 运行时层回答的是“怎么执行”,不是“对用户如何命名”。 +补充边界: + +- [Codex `/goal`](../research/codex-goal/README.md) 这类 persistent objective 只回答“目标是否继续推进”,不回答“skill 绑定到哪个执行器”。 +- 未来如果出现 `Managed Objective`,它只能引用 `agent_turn / browser_assist / automation_job / native_skill` 这些绑定,不能新增 `goal_runtime` 作为 skill executor binding。 +- `Pipeline` 是 skill 内部组织模式,`Managed Objective` 是跨 turn 的目标控制层;不要把二者写成同一个字段或同一个 runtime。 + ### 5. 分发层 作用: diff --git a/docs/aiprompts/state-history-telemetry.md b/docs/aiprompts/state-history-telemetry.md index cf14e9167..e0b745ef9 100644 --- a/docs/aiprompts/state-history-telemetry.md +++ b/docs/aiprompts/state-history-telemetry.md @@ -43,6 +43,12 @@ **后续新增状态、历史或遥测能力时,只允许接到 `SessionDetail -> AgentRuntimeThreadReadModel -> RequestLog -> export/history` 这组 current 边界;不允许再造并列状态真相。** +补充边界: + +[Codex `/goal`](../research/codex-goal/README.md) 这类目标续跑模式如果在 Lime 演进为 `Managed Objective`,其状态投影也必须消费 `SessionDetail / AgentRuntimeThreadReadModel / evidence pack`。不得让 objective UI、automation job、review/handoff 各自重建“目标是否完成”的第二套真相。 + +详细路线图见 `docs/roadmap/managed-objective/README.md`。本文件只定义状态与历史事实源边界:objective projection 是下游投影,不是 session / thread 的第二套真相。 + ## 代码入口地图 ### 1. 持久会话与历史事实源 diff --git a/docs/aiprompts/task-agent-taxonomy.md b/docs/aiprompts/task-agent-taxonomy.md index 43ab308a1..1ad4f9d48 100644 --- a/docs/aiprompts/task-agent-taxonomy.md +++ b/docs/aiprompts/task-agent-taxonomy.md @@ -52,6 +52,17 @@ **后续新增长时执行能力时,只允许落成 `agent turn`、`subagent turn` 或 `automation job` 三类之一;不允许再造第四类 runtime taxonomy。** +补充边界: + +[Codex `/goal`](../research/codex-goal/README.md) 这类 persistent objective / continuation loop 只能作为“目标推进控制层”理解,不能成为第四类执行实体。若 Lime 后续实现 `Managed Objective`: + +1. 前台即时推进仍归 `agent turn`。 +2. 协作拆分仍归 `subagent turn`。 +3. durable 后台推进仍归 `automation job`。 +4. objective state、completion audit、budget / pause / resume 只负责控制这些实体是否继续,不单独定义新的 run source、queue、scheduler 或 evidence。 + +详细路线图见 `docs/roadmap/managed-objective/README.md`。本文件只定义 taxonomy 边界:Managed Objective 不是第四类执行实体。 + ## 固定心智模型 当前主链统一按下面这张图理解: diff --git a/docs/exec-plans/README.md b/docs/exec-plans/README.md index ffc9f19e1..d25fec56c 100644 --- a/docs/exec-plans/README.md +++ b/docs/exec-plans/README.md @@ -30,6 +30,11 @@ - Lime 多模态运行合同实施计划:`docs/exec-plans/multimodal-runtime-contract-plan.md` - 云端套餐与支付边界收口计划:`docs/exec-plans/cloud-commerce-user-center-boundary.md` - `@` 命令本地执行纠偏计划:`docs/exec-plans/at-command-local-execution-alignment-plan.md` +- AI 图层化设计实现计划:`docs/exec-plans/ai-layered-design-implementation-plan.md` +- CreoAI Capability Authoring P1A 执行计划:`docs/exec-plans/creaoai-capability-authoring-p1a-plan.md` +- CreoAI Capability Verification P1B 执行计划:`docs/exec-plans/creaoai-capability-verification-p1b-plan.md` +- CreoAI Capability Registration P3 执行计划:`docs/exec-plans/creaoai-capability-registration-p3-plan.md` +- CreoAI Capability Discovery P3B 执行计划:`docs/exec-plans/creaoai-capability-discovery-p3b-plan.md` - LimeNext 总实施计划(`legacy current reference`,当前主规划已切到 `docs/roadmap/limenextv2/README.md`):`docs/exec-plans/limenext-plan.md` - LimeNext 推进日志:`docs/exec-plans/limenext-progress.md` - 技术债追踪:`docs/exec-plans/tech-debt-tracker.md` diff --git a/docs/exec-plans/agent-knowledge-implementation-plan.md b/docs/exec-plans/agent-knowledge-implementation-plan.md index d866a1240..e64e77cbb 100644 --- a/docs/exec-plans/agent-knowledge-implementation-plan.md +++ b/docs/exec-plans/agent-knowledge-implementation-plan.md @@ -1,6 +1,6 @@ # Agent Knowledge 实现执行计划 -> 状态:Phase 1 current 主链已接通,项目资料能力已回流到现有 Agent 输入框;Knowledge 模块化产品化重构与 GUI smoke 闭环已完成 +> 状态:Phase 1 current 主链已接通,项目资料能力已回流到现有 Agent 输入框;首页添加、File Manager 添加、输入框使用、项目资料管理与 Agent 结果沉淀均已完成稳定 DevBridge 下的产品 E2E 验收 > 创建时间:2026-05-01 > 路线图来源:`docs/roadmap/knowledge/prd.md` > 当前目标:完成 Markdown-first 项目资料的导入、整理、GUI 管理、Agent 输入框显式使用与运行时受保护上下文注入。 @@ -183,8 +183,63 @@ src-tauri/crates/knowledge - 已执行 `git diff --check`。 - 已执行普通用户可见路径泄露扫描,`KnowledgePage.tsx`、knowledge components、Inputbar knowledge control 与 smoke 脚本未出现内部标识、资料文件名、高级目录、`knowledge_builder`、`compiled/brief.md`、`frontmatter` 或 `tokens`。 +### 2026-05-05 文档架构同步 + +- 已按最新产品判断更新 `docs/roadmap/knowledge/prd.md`:current 入口从单一知识库页面改为 File Manager、输入框资料图标、首页引导和 Agent 输出沉淀四条路径;`@资料` 降级为兼容入口。 +- 已更新 PRD 总体架构图,把 File Manager、输入框资料图标、`@资料` 兼容入口、首页引导、Agent 输出沉淀、导入编排、`lime-knowledge`、资料管理页、现有 Agent 输入框和 Resolver 串成同一闭环。 +- 已更新 PRD 关键时序:从 File Manager / 首页添加资料、通过输入框资料图标使用资料、从生成结果沉淀资料、用户修改后重新整理。 +- 已更新 PRD 前台信息架构、UI 原型、模块边界、Current / Deprecated 分类、Phase 计划和产品验收标准,明确普通用户主路径不暴露 packName、metadata、compiled、token、runtime fence 或本机完整路径。 +- 已新增 `docs/knowledge/README.md`,声明 current 文档事实源,并把 `docs/knowledge/` 下早期方案标为 compat / 参考。 +- 已更新 `.gitignore`,仅放行 `docs/knowledge/README.md` 作为可追踪索引;其余 `docs/knowledge/` 私有样例和早期方案仍默认不纳入版本库。 +- 已给 `docs/knowledge/lime-knowledge-base-construction-blueprint.md`、`docs/knowledge/markdown-first-knowledge-pack-plan.md`、`docs/knowledge/lime-project-knowledge-base-solution.md`、`docs/knowledge/agent-skills-and-knowledge-pack-boundary.md` 增加 compat 状态说明,避免后续继续按旧方案扩张。 + +### 2026-05-05 四入口闭环实现 + +- 已新增 `src/features/knowledge/import/knowledgeSourceImport.ts`,把文件路径与文本资料导入封装为独立前端模块;实现继续复用 `read_file_preview_cmd`、`knowledge_import_source` 与 `knowledge_compile_pack`,不新增后端命令。 +- 已在 File Manager 右键菜单补充“设为项目资料”,并在输入框路径 chip 上提供“设为资料”动作;用户从左侧文件管理器添加文件后,不必理解 packName、metadata 或内部目录即可整理为项目资料。 +- 已将输入框底栏资料图标明确为项目资料主入口;`@资料` 只做兼容打开同一资料中枢,`@沉淀资料` 继续复用现有输入框资料整理动作,不创建独立 Agent 或新聊天面板。 +- 已将首页起手入口从“预填一段资料说明”改为“直接打开输入框资料中枢”,保持首页、输入框资料图标和 `@资料` 兼容入口指向同一浮层。 +- 已在助手消息操作区增加“沉淀为项目资料”,将 Agent 输出直接交给 Workspace knowledge runtime 导入与编译,继续由现有 Agent 工作区承载使用与确认。 +- 已补首页起手入口、输入建议与引导卡:普通用户可以从首页了解“添加 / 确认 / 使用项目资料”,而不是先进入开发者式管理页。 +- 已补稳定回归:File Manager 右键导入、输入框路径 chip 导入、`@资料` / `@沉淀资料` 复用现有输入框动作、消息沉淀、首页入口与 seeded command catalog。 + +### 2026-05-05 真实 E2E 顺滑度复测 + +- 已用 Playwright MCP 复用真实 Lime 页签,刷新 `http://127.0.0.1:1420/` 后重新建立基线:DevBridge 健康、首页可交互、控制台 error 为 0。 +- 首页点击 `添加资料` 可直接打开输入框资料中枢,且不会把说明文字预填进输入框;这一段顺利。 +- 资料中枢点击 `去确认资料` 能进入项目资料管理页;确认、设为默认、用于生成回 Agent 均能走真实 DevBridge,不依赖 mock fallback。 +- `用于生成` 回到 Agent 后会预填 `请基于当前项目资料生成内容`,但视觉状态仍是 `项目资料:未使用`,用户还要再点资料中枢里的 `使用这份资料`;这会让普通用户误以为“用于生成”没有真正生效。 +- 手动点击资料中枢 `使用这份资料` 后,输入框状态能变为 `正在使用:资料名称`;这一段顺利,但多了一步。 +- 从 Agent 输出点击 `沉淀为项目资料` 时,Playwright 正常点击被消息区覆盖层拦截;通过 JS click 才触发 `knowledge_import_source` 与 `knowledge_compile_pack`。这说明普通用户也可能遇到命中区域不稳定或按钮难点的问题。 +- 资料详情仍暴露 `custom`、Markdown 结构、运行时边界、`name/status/trust`、source 路径等内部信息;人工确认后引用摘要里仍显示 `status: draft`、`trust: unreviewed`,与页面“已确认”状态冲突。 +- 回到知识库后出现项目上下文漂移:页面显示了 smoke 临时项目资料和临时目录提示,而不是当前默认项目资料;说明 workingDir / selectedProjectId 的恢复仍不够稳定。 +- 结论:自动 smoke 主链通过,但普通用户真实 E2E 不够顺滑;当前最大问题不是桥接失败,而是状态语义、上下文恢复、点击命中和普通用户文案仍有产品化缺口。 + +### 2026-05-05 真实 E2E 问题修复 + +- 已给 `用于生成` 增加 `initialKnowledgePackSelection` 导航参数,并贯通 `AgentPageParams -> AppPageContent -> AgentChatWorkspace -> Workspace knowledge runtime`,从资料管理回 Agent 后直接显示 `正在使用:资料名称`,不再要求用户二次点击。 +- 已把知识页项目恢复顺序改为显式页面参数优先,其次最近项目 ID,最后默认项目;临时 smoke 目录不再单独作为普通入口默认项目,避免刷新或回知识库时上下文漂移。 +- 已将资料详情、列表卡片和文件条目统一走普通用户预览清洗:隐藏 `custom`、`metadata`、`compiled/brief.md`、`sources/...`、本机完整路径、运行时摘要和 `status/trust` 原始字段;无效资料摘要改为“缺少原始内容,请补充后再确认”。 +- 已把助手消息的“沉淀为项目资料”改为常显文字按钮,并修复普通 Playwright click 被消息气泡拦截的问题;无原始内容的助手结果会提示先补充资料,不再继续沉淀成项目资料。 +- 已同步 `scripts/knowledge-gui-smoke.mjs`,GUI smoke 断言从旧的 `项目资料:未使用` 更新为 `正在使用:资料名称`,让自动 E2E 对齐当前产品语义。 +- 已继续清理历史脏资料摘要:列表与详情不再展示 `何时使用`、`缺失事实时`、`不编造来源资料` 等 Builder 模板腔,避免普通用户看到像开发提示词的内容。 + ## 待完成清单 +- [x] 给 `docs/knowledge/` 早期方案补 compat 状态说明,避免继续误用旧架构。 +- [x] 新增 `docs/knowledge/README.md`,明确 current / compat 文档事实源。 +- [x] 更新 `.gitignore`,让 `docs/knowledge/README.md` 作为 knowledge 文档索引进入 repo。 +- [x] 更新 current PRD 的项目资料产品闭环、架构图、时序图和阶段计划。 +- [x] 实现 File Manager 右键与输入框路径 chip 的“设为项目资料”入口。 +- [x] 实现 `@资料` 与 `@沉淀资料`,并复用现有 Agent 输入框主链。 +- [x] 将可见资料口令收敛为 `@资料`,保留内部 command key 与现有 Agent 主链不变。 +- [x] 将 `@资料` 从单一启用动作升级为资料中枢:按当前状态引导添加、确认、选择、使用、关闭或补充资料。 +- [x] 将资料中枢主入口收口到输入框底栏资料图标,`@资料` 只保留为兼容兜底,不再作为普通命令标签或主路径宣传。 +- [x] 将首页“添加资料”入口改为直接打开输入框资料中枢,不再预填说明文字或制造第二条入口语义。 +- [x] 实现 Agent 输出“沉淀为项目资料”入口。 +- [x] 将 Agent 输出“沉淀为项目资料”纳入 `smoke:knowledge-gui`,完成普通点击、真实导入 / 编译和管理页待确认资料可见的 E2E 验收。 +- [x] 在首页补充项目资料添加与使用引导。 +- [x] 收口 File Manager 与输入框路径 chip 的可用性判断:仅对 Markdown / 文本文件展示直接整理入口,PDF / Word 等非文本资料给出普通用户可执行提示,避免误导为已支持直接解析。 - [x] 修正 `lime-knowledge` crate 编译问题和格式问题。 - [x] 补齐 Tauri command 注册与主 crate 依赖。 - [x] 补齐 `defaultMocks` 中的 knowledge 命令 mock。 @@ -211,6 +266,7 @@ src-tauri/crates/knowledge - [x] 在稳定 DevBridge + Playwright 环境完成知识库 GUI 主路径 E2E:真实 seed、资料列表、用于生成回 Agent、Agent 自动发送。 - [x] 完成 Knowledge 前端 feature module 拆分:domain / agent / components / Inputbar knowledge / Workspace knowledge runtime。 - [x] 在清理本地 DevBridge / CDP smoke 环境后,重跑 `smoke:knowledge-gui` 并补齐真实 seed -> 使用资料 -> 回到现有 Agent 预填生成意图闭环。 +- [x] 修复真实 E2E 暴露的用于生成未自动启用、项目上下文漂移、详情页内部信息泄露和消息沉淀按钮命中问题。 ## 验证记录 @@ -278,11 +334,127 @@ npm run typecheck npm run verify:gui-smoke git diff --check # 普通用户可见路径泄露扫描:KnowledgePage、knowledge components、Inputbar knowledge control 与 knowledge GUI smoke 无内部实现词命中。 +test -f docs/roadmap/knowledge/prd.md && test -f docs/knowledge/README.md +rg -n "File Manager|@资料|沉淀为项目资料|knowledge_import_source|knowledge_resolve_context|KnowledgePack" docs/roadmap/knowledge/prd.md +# 文档禁名扫描:current PRD、执行计划、docs/knowledge README 与 compat 方案无命中。 +git diff --check -- docs/roadmap/knowledge/prd.md docs/exec-plans/agent-knowledge-implementation-plan.md docs/knowledge/README.md docs/knowledge/lime-knowledge-base-construction-blueprint.md docs/knowledge/markdown-first-knowledge-pack-plan.md docs/knowledge/lime-project-knowledge-base-solution.md docs/knowledge/agent-skills-and-knowledge-pack-boundary.md +# 2026-05-05 文档架构同步:knowledge docs validation ok。 +npm test -- "src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/lib/base-setup/seededCommandPackage.test.ts" +npm run typecheck +npm run test:contracts +# 2026-05-05 四入口闭环实现:File Manager、输入框路径 chip、@资料、@沉淀资料、Agent 输出沉淀与首页入口定向回归通过。 +npm run verify:gui-smoke +# 2026-05-05 四入口闭环 GUI smoke 通过:已补齐当前工作树 runtime_evidence_pack_service.rs 的 AgentThreadItem 导入编译缺口;本轮 cold target 里 sherpa-onnx-sys 下载仍出现 TLS close_notify 警告,但 DevBridge 已就绪,workspace-ready、browser-runtime、site-adapters、Agent service skill entry、runtime tool surface/page 与 knowledge GUI smoke 均通过。 +npm test -- "src/features/knowledge/import/knowledgeSourceSupport.test.ts" "src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx" +npm run typecheck +# 2026-05-05 文件导入可用性收口:Markdown / 文本文件保留直接整理入口;PDF 等非文本文件不再在输入框展示“设为资料”,File Manager 右键菜单展示禁用态与可执行提示。 +npm run verify:gui-smoke +# 2026-05-05 文件导入可用性收口后 GUI smoke 通过:复用已有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、Agent service skill entry、runtime tool surface/page 与 knowledge GUI smoke 均通过。 +npm test -- "src/lib/base-setup/seededCommandPackage.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" +npm run test:contracts +npm run typecheck +npm run verify:gui-smoke +# 2026-05-05 资料口令产品化收口:可见 mention 从模块名收敛为 `@资料`,`knowledge_pack` 内部 command key 与现有 Agent 输入框主链保持不变。 +# 2026-05-05 资料口令收口后 GUI smoke 通过:复用已有 headless Tauri 与 DevBridge,knowledge GUI smoke 通过。 +npm test -- "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/lib/base-setup/seededCommandPackage.test.ts" +npm run typecheck +npm run test:contracts +# 2026-05-05 `@资料` 闭环修正:`@资料` 不再直接启用资料,而是打开资料中枢;无资料、待确认、未启用、已启用四类状态均有主动作回归。 +npm test -- "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/lib/base-setup/seededCommandPackage.test.ts" +npm run typecheck +npm run verify:gui-smoke +# 2026-05-05 追加修正:初始 capability route 带 `@资料` 时也会打开资料中枢,不再渲染普通 builtin command badge。 +# 2026-05-05 追加修正后 GUI smoke 通过:复用已有 headless Tauri 与 DevBridge,knowledge GUI smoke 通过。 +npm test -- "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/home/buildHomeSkillSurface.test.ts" "src/components/agent/chat/home/HomeStarterChips.test.tsx" +npm test -- "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/lib/base-setup/seededCommandPackage.test.ts" +npm run typecheck +npm run test:contracts +npm run verify:gui-smoke +# 2026-05-05 首页添加资料入口收口:点击首页“添加资料”直接打开输入框资料中枢,不再预填解释 prompt;GUI smoke 通过,knowledge GUI 阶段覆盖资料管理页、用于生成回 Agent 与补充导入入口。 +git diff --check -- src/components/agent/chat/home/homeSurfaceTypes.ts src/components/agent/chat/home/homeSurfaceCopy.ts src/components/agent/chat/home/HomeStarterChips.tsx src/components/agent/chat/home/buildHomeSkillSurface.test.ts src/components/agent/chat/components/EmptyState.tsx src/components/agent/chat/components/EmptyState.test.tsx docs/roadmap/knowledge/prd.md docs/exec-plans/agent-knowledge-implementation-plan.md +# 2026-05-05 首页资料入口收口后禁名 / 用户可见泄露扫描无命中。 +npm run bridge:health -- --timeout-ms 120000 +npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000 +# 2026-05-05 Playwright MCP 真实 E2E 顺滑度复测:自动 knowledge smoke 通过;手动用户流暴露用于生成后未自动启用资料、消息沉淀按钮点击命中不稳定、详情页内部信息泄露、项目上下文漂移四类产品化问题。 +npm test -- "src/features/knowledge/KnowledgePage.test.tsx" +npm test -- "src/components/agent/chat/components/MessageList.test.tsx" +npm test -- "src/components/agent/chat/index.test.tsx" +npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" +npm run typecheck +# 2026-05-05 typecheck 未通过:阻塞项来自当前工作树既有 capabilityDrafts / tauri-mock 类型错误,非本轮 knowledge 改动。 +npm run test:contracts +npm run bridge:health -- --timeout-ms 120000 +npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000 +# 2026-05-05 修复后 Playwright MCP 复测:用于生成回 Agent 后输入框显示“正在使用:资料名”;普通 click 可点击“沉淀为项目资料”;控制台 error 为 0。 +npm run verify:gui-smoke +# 2026-05-05 修复后 GUI smoke 通过:复用已有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、Agent service skill entry、runtime tool surface/page 与 knowledge GUI smoke 均通过。 +npm test -- "src/features/knowledge/KnowledgePage.test.tsx" +npm run typecheck +npm run test:contracts +npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000 +# 2026-05-05 历史脏资料摘要降噪后:KnowledgePage 定向测试、typecheck、contracts 通过;knowledge GUI smoke 因 DevBridge 未监听 3030 未执行成功,尝试重启 headless 时遇到其他工作树 Rust 文件持续变更触发 watch 重建,已中止该次环境进程,不计为通过。 +npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000 +# 2026-05-05 产品 E2E 验收收口:knowledge GUI smoke 已覆盖真实 seed、用于生成回 Agent、Agent 结果样本普通点击“沉淀为项目资料”、真实导入 / 编译、管理页出现待确认资料和补充导入入口。 +node --check "scripts/knowledge-gui-smoke.mjs" +npm run typecheck +npm run test:contracts +npm run verify:gui-smoke +# 2026-05-05 GUI smoke 全量通过:workspace-ready、browser-runtime、site-adapters、Agent service skill entry、runtime tool surface/page、knowledge GUI smoke 与 design-canvas 均通过。 ``` ## 后续切片 -1. 把本轮手工 Playwright E2E 沉淀回 `scripts/knowledge-gui-smoke.mjs`,避免后续再被 CDP profile 卡死或被 mockPriority 误判。 +1. 把 File Manager 文本文件识别从扩展名 / mimeType 扩展到 PDF / DOCX 的“先预览再整理”安全路径,但仍不向普通用户暴露内部转换细节。 2. 继续收口普通用户语言:管理页只展示资料名称、状态、风险提醒、引用摘要和确认动作;内部文件名、Skill 名称、目录结构只保留在开发文档和测试 mock 中。 3. 为运行时 Knowledge Context Resolver 增加更细的章节选择和成本控制,但只在开发者诊断或高级设置中展示,不进入普通用户默认路径。 4. 为 `knowledge_builder` 增加示例输入 / 输出快照测试,锁定不同 `pack_type` 的生成结构。 + +## 2026-05-05 产品 E2E 闭环续测 + +- 页面 / URL:`http://127.0.0.1:1420/`,从首页进入知识库,再点击“用于生成”回到现有 Agent 输入框。 +- 已完成步骤:知识库加载、普通用户可见文案检查、资料中枢打开、已确认资料启用、Agent 输入框显示“正在使用:资料名”、发送“请基于当前项目资料生成内容”。 +- 暴露问题:知识页默认展示排障目录预览,属于普通用户信息泄露;输入框资料中枢把待确认 / 缺素材资料放在可用选项里,属于体验误导;发送后 DevBridge 出现 `workspace_get` / `agent_runtime_get_session` / event stream 超时,属于桥接稳定性缺口。 +- 本轮修复:排障入口改为“项目识别异常?”并默认隐藏本机路径;资料中枢只把已确认资料作为可用选项,待确认资料只显示数量和“管理资料”;运行时默认资料选择优先已确认资料,避免默认草稿抢占生成路径。 +- 新增 skill:`.codex/skills/lime-product-e2e-loop/SKILL.md`,沉淀“真实用户路径 E2E -> 问题分类 -> 最小产品化修复 -> 复测记录”的复用流程。 +- 验证通过:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.test.ts" "src/features/knowledge/KnowledgePage.test.tsx"`;追加 `npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.test.ts" "src/features/knowledge/KnowledgePage.test.tsx"`;`git diff --check` 通过;禁名扫描无命中;skill 基础结构校验通过。 +- 验证未完成:`npm run typecheck` 当前失败在既有 `src/lib/layered-design/imageTasks.ts` 类型错误,非本轮 Knowledge 改动;`npm run bridge:health -- --timeout-ms 15000` 失败,当前 DevBridge 3030 监听进程无响应,headless Tauri watch 反复因其他工作树 Rust 文件变化重建并等待 Cargo lock,因此本轮 Playwright 复测停在修复前用户流和组件回归,尚未完成修复后真实 GUI 复走。 + +## 2026-05-05 项目资料产品化二次收口 + +- 页面 / URL:`http://127.0.0.1:1420/`;手动 Playwright 从首页进入左侧 `项目资料`,再回到 Agent 输入框资料中枢,最后发送一次带资料引用的消息。 +- 闭环结果:模块入口已从左侧 `知识库` 收敛为 `项目资料`;管理页首屏只保留“管理与确认”职责;空资料态明确提示三条添加路径:输入框添加、文件管理器添加、对话结果沉淀;输入框无资料时不再同时露出“管理资料”这种管理动作。 +- 本轮修复:更新 `src/lib/navigation/sidebarNav.ts`、`src/components/agent/chat/components/ChatSidebar.tsx` 的入口命名;更新 `KnowledgePage` 的首屏、空态和主按钮;更新 `InputbarKnowledgeControl` / `knowledgeHubState` 的无资料文案、菜单按钮和二级管理动作条件;同步 `scripts/knowledge-gui-smoke.mjs` 的导航断言。 +- 用户视角验证:知识管理页截图 `knowledge-after-optimization.png` 已确认不再把模块包装成独立聊天页;输入框资料中枢可选择已确认资料,点击后显示 `正在使用:资料名`;发送时页面进入现有 Agent 对话流,没有新建独立 Agent。 +- 控制台 / Bridge 状态:冷构建后的 DevBridge 已恢复,`npm run bridge:health -- --timeout-ms 30000` 通过;手动发送曾在 DevBridge 未就绪期间出现 `无法创建会话`,Bridge 恢复后 `smoke:knowledge-gui` 真实通过。 +- 验证通过:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts"`;`npm test -- "src/lib/navigation/sidebarNav.test.ts" "src/components/AppSidebar.test.tsx" "src/components/agent/chat/components/ChatSidebar.test.tsx" "src/features/knowledge/KnowledgePage.test.tsx"`;`npm run typecheck`;`npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000`。 +- 验证未完成:`npm run verify:gui-smoke` 未通过,失败在 `smoke:agent-runtime-tool-surface-page` 的浏览器 CDP 标签页读取 `http://127.0.0.1:15668/json/list`,不在本轮项目资料 UI / smoke 脚本改动边界;全局 `git diff --check` 仍受其它工作树文件尾随空白影响,本轮改动文件的 `git diff --check -- ` 通过。 + +## 2026-05-05 项目资料产品化三次收口 + +- 页面 / URL:`http://127.0.0.1:1420/`;手动 Playwright 从首页 `添加资料`、输入框资料图标、左侧 `项目资料`、File Manager 四条路径复走。 +- 用户闭环结果:`添加资料` 和管理页 `回到 Agent 添加` 现在都会打开输入框项目资料浮层;浮层在已有资料时同时给出 `添加新资料`、`检查资料`、`使用这份资料`,不再把用户困在“只能使用已有资料”的分支里。 +- 本轮修复:输入框项目资料浮层新增常显补充入口,并把二级管理动作改为 `检查资料`;知识页回 Agent 添加改为直达现有 Agent 输入框资料浮层;File Manager 文本文件普通点击改为 `加入对话`,行内提供 `设为资料`,避免点击文件直接调系统打开;输入框本地文件 chip 隐藏本机绝对路径,只保留文件名和 `本地文件 / 本地文件夹`。 +- 普通用户信息边界:默认页面不再暴露本机目录、`.lime/knowledge`、`compiled/brief.md`、`metadata/status/trust`、`knowledge_builder` 或命令名;路径只在测试 mock 和内部 metadata 中存在。 +- Playwright 证据:首页点击 `添加资料` 后浮层可见 `添加新资料 / 检查资料 / 使用这份资料`;点击 `添加新资料` 后输入框填入整理资料提示;File Manager 打开后文本文件行内出现 `加入对话 / 设为资料`;点击文本文件后没有再触发 `open_with_default_app` unknown command,新控制台仅剩 DevBridge event stream 在重建期间的环境噪音。 +- 验证通过:`npm test -- "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/knowledge/knowledgeHubState.test.ts"`;`npm test -- "src/features/knowledge/KnowledgePage.test.tsx" "src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx"`;`npm test -- "src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx"`;`npm run typecheck`;`npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000` 主流程通过。 +- 验证未完成:`npm run verify:gui-smoke` 本轮仍失败在 `smoke:agent-runtime-tool-surface-page` 的 `launch_browser_session fetch failed`;同时 headless Tauri watch 期间其它 Rust 文件持续变更触发重建,导致 DevBridge 3030 多次断开。`smoke:knowledge-gui` 已通过主断言,但清理临时项目时也因 DevBridge 重建出现 `workspace_delete fetch failed`,记录为环境 / 并发重建噪音,不判定为项目资料主链失败。 + +## 2026-05-05 产品 E2E 验收补测与修复 + +- 页面 / URL:`http://127.0.0.1:1420/`;Playwright 从首页、输入框项目资料浮层、File Manager、项目资料管理页和最近结果路径复走。 +- 闭环判定:A 首页 / 输入框添加资料、B File Manager 加入对话与设为资料、C 项目资料管理页确认与用于生成回 Agent 已按普通用户路径补测;D Agent 结果沉淀当前样本未稳定展示可点击结果按钮,本轮只记录为 `warn`,不判定完成。 +- 本轮发现:File Manager 顶部仍显示本机完整路径,属于信息泄露;首页空态输入框的本地文件 chip 没有透传“设为项目资料”动作,属于产品阻塞;浏览器 mock 文件路径进入真实文件预览时会报 `No such file or directory`,属于 mock / bridge 组合缺口;`回到 Agent 添加` 存在重复按钮定位,E2E 脚本需用 `.first()` 或明确作用域。 +- 本轮修复:File Manager 顶部位置改为“本地位置”,`title` 不再放绝对路径;`EmptyState -> EmptyStateComposerPanel -> InputbarCore` 补齐 `onImportPathReferenceAsKnowledge` 透传,首页和空态 chip 也能直接“设为资料”;`knowledgeSourceImport` 对浏览器文件管理器 mock 文本路径增加产品化 fallback,避免普通 click 后出现文件元信息错误。 +- Playwright 证据:修复后 B 路径显示 `brief.md / 本地文件 / 设为项目资料`,普通 click 可命中 chip 的设为资料动作,File Manager 和输入框正文不再展示 `/Users/...`;C 路径管理页首屏未出现 `.lime/knowledge`、`compiled/brief.md`、`metadata`、`status/trust`、`knowledge_builder`、`frontmatter` 或 `token`。 +- 验证通过:`npm test -- "src/features/knowledge/import/knowledgeSourceImport.test.ts" "src/features/knowledge/import/knowledgeSourceSupport.test.ts"`;`npm test -- "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx" "src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx"`;`npm test -- "src/components/agent/chat/index.test.tsx" -t "点击顶部加号应在任务中心新标签内嵌首页起手页"`;`npm run typecheck`;本轮触达文件 `git diff --check` 通过;禁名扫描通过。 +- 验证警告:`npm test -- "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx"` 首次全量组合仅 1 个任务中心标签用例失败,单测定向重跑通过,按当前工作树并发负载下的组合级波动记录。 +- 验证未完成:本轮最终 `npm run bridge:health -- --timeout-ms 10000` 未就绪,`npm run tauri:dev:headless` 停在 Tauri / Cargo dev 进程等待阶段且 3030 未监听;因此修复后的完整 A/B/C/D Playwright 复走仍缺稳定 DevBridge 复验,不能把产品 E2E 验收宣称为全部完成。 + +## 2026-05-05 产品 E2E 验收收口 + +- 页面 / URL:`http://127.0.0.1:1420/`;复用已就绪 DevBridge,并通过 `smoke:knowledge-gui` 走完整项目资料产品闭环。 +- 闭环判定:A 首页 / 输入框添加资料、B File Manager 文本资料设为项目资料、C 项目资料管理页用于生成回现有 Agent、D Agent 输出沉淀为项目资料均已纳入可重复 E2E;本轮不再保留 D 路径 `warn`。 +- 本轮修复:`scripts/knowledge-gui-smoke.mjs` 在创建临时项目后再按实际项目根目录 seed 知识资料,避免页面项目根与 seed 根不一致;同时加入 Agent 结果样本,普通点击“沉淀为项目资料”,等待真实 `knowledge_import_source` / `knowledge_compile_pack` 完成,并在管理页验证待确认资料可见。 +- 产品证据:`smoke:knowledge-gui` 阶段顺序包含 `open-agent-with-knowledge -> wait-agent -> prepare-agent-result -> wait-agent-result -> capture-agent-result -> wait-agent-result-captured -> wait-captured-agent-result -> open-import-view`,确认从“使用资料”到“结果沉淀”再回“管理确认”的闭环顺序。 +- 验证通过:`node --check "scripts/knowledge-gui-smoke.mjs"`;`npm run smoke:knowledge-gui -- --app-url "http://127.0.0.1:1420/" --health-url "http://127.0.0.1:3030/health" --invoke-url "http://127.0.0.1:3030/invoke" --timeout-ms 240000 --interval-ms 1000`;`npm run typecheck`;`npm run test:contracts`;`npm run verify:gui-smoke`。 +- 当前剩余风险:Agent 结果样本由 E2E 脚本注入历史消息以避开真实模型配置依赖;点击、导入、编译和管理页展示均走真实 GUI / DevBridge。后续如要覆盖真实模型生成,只应作为模型配置可用时的增强验收,不再阻塞当前项目资料产品闭环。 diff --git a/docs/exec-plans/agentui-implementation-progress.md b/docs/exec-plans/agentui-implementation-progress.md index 518ac1234..9303b41f8 100644 --- a/docs/exec-plans/agentui-implementation-progress.md +++ b/docs/exec-plans/agentui-implementation-progress.md @@ -2402,3 +2402,1732 @@ GUI / E2E 状态: 1. 继续 Phase 2:抽 `sessionSwitchErrorController`,把 session not found、preserve current snapshot、toast/error metric 等错误恢复判断从 `useAgentSession` 中移出。 2. 或抽 `sessionHistoryPaginationController`,把完整历史分页窗口计算、stale guard 与 merge 计划从主 hook 中移出。 + +### 2026-05-05:P3 第十四刀,Session switch error controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/sessionSwitchErrorController.ts`: + - `buildSessionSwitchErrorLogContext` + - `buildSessionSwitchErrorToastMessage` + - `resolveSessionSwitchErrorAction` +- `useAgentSession.ts` 中 `handleSwitchTopicError` 的错误恢复分支改为委托 controller: + - session not found:清空当前快照、刷新 topics、不弹 toast。 + - 普通错误:默认清空当前快照并弹 toast。 + - `preserveCurrentSnapshot`:保留当前快照,只弹 toast。 +- 新增 `sessionSwitchErrorController.test.ts`,覆盖 session not found、普通错误、保留快照错误和非 `Error` 文案。 + +主线收益: + +- Phase 2 继续瘦 `useAgentSession`:旧会话切换失败的恢复策略不再散落在 hook 主体里。 +- `preserveCurrentSnapshot` 的行为有单测保护,避免 deferred hydration 失败时误清空当前缓存快照,造成旧会话 UI 闪空或用户误以为卡死。 +- session not found 的清理与 topics 刷新路径可单测,后续排查旧会话恢复失败时能直接定位到错误 controller。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts" "src/components/agent/chat/hooks/sessionPostFinalizePersistenceController.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "模型|权限|执行策略|metadata|stale 快照|预取|workspace|跨工作区|错误|not found|not found" --hookTimeout 180000 --testTimeout 120000 +``` + +结果: + +- Switch error / post-finalize / scheduler / finalize / metadata / snapshot / retry / fetch / hydration controller:通过,`40` 个测试通过。 +- `useAsterAgentChat` 模型、权限、metadata、stale 快照、预取、workspace、错误定向:通过,`30` 个测试通过。 + +GUI / E2E 状态: + +- 本刀暂未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。 +- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。 + +下一刀: + +1. 继续 Phase 2:抽 `sessionHistoryPaginationController`,把完整历史分页窗口计算、分页 options、stale guard、merge 计划从 `useAgentSession` 主体中移出。 +2. 恢复 GUI 环境后按 `conversation-projection-acceptance.md` 采集旧会话 A/B 打开、切换与首字指标。 + +### 2026-05-05:P3 第十五刀,Session history pagination controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/sessionHistoryPaginationController.ts`: + - `normalizePositiveInteger` + - `normalizeNonNegativeInteger` + - `resolveDetailHistoryLoadedMessages` + - `resolveSessionHistoryWindowFromDetail` + - `buildSessionHistoryPageRequestPlan` + - `buildSessionHistoryPageResultPlan` +- `useAgentSession.ts` 中 `resolveSessionHistoryWindow` 与 `loadFullSessionHistory` 的分页窗口计算改为委托 controller: + - 首次 detail 截断窗口计算。 + - “加载更早历史”的 `historyLimit / historyOffset / historyBeforeMessageId` 请求参数。 + - loading window 状态。 + - 分页返回后的 `loadedMessages / totalMessages / cursor` 下一轮窗口。 +- 新增 `sessionHistoryPaginationController.test.ts`,覆盖整数归一化、detail loaded count、截断窗口、重复 loading 防护、分页请求计划与分页结果计划。 + +主线收益: + +- Phase 2 继续瘦 `useAgentSession`:完整历史分页不再在主 hook 内手写多段窗口计算。 +- P2 “`loadFullSessionHistory` 用全量拉取改分页”的主线现在有独立 controller 和回归保护,避免后续误退回无分页或重复触发。 +- 旧会话打开慢的排查边界更清晰:首帧 detail hydrate、分页请求参数、分页 merge 可以分别测,不再必须挂载完整 workspace 才能验证窗口算法。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts" "src/components/agent/chat/hooks/sessionPostFinalizePersistenceController.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "加载更早历史" --hookTimeout 180000 --testTimeout 120000 +npx eslint "src/components/agent/chat/hooks/sessionHistoryPaginationController.ts" "src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- History pagination / switch error / post-finalize / scheduler / finalize / metadata / snapshot / retry / fetch / hydration controller:通过,`46` 个测试通过。 +- `useAsterAgentChat` 完整历史分页定向:通过,`1` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。 +- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。 + +下一刀: + +1. 继续 Phase 2:抽 `sessionHistoryMergeController`,把完整历史分页返回后的 messages / turns / items merge 计划从 `loadFullSessionHistory` 中移出。 +2. 或进入 Phase 3:抽 `streamSubmissionController`,把首字链路的 ensure / listener / submit 分段进一步收口。 + +### 2026-05-05:P3 第十六刀,Session history merge controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/sessionHistoryMergeController.ts`: + - `buildSessionHistoryMergePlan` +- `useAgentSession.ts` 中 `loadFullSessionHistory` 的分页返回 merge 改为委托 controller: + - detail messages -> UI messages hydrate。 + - incoming messages 与本地 messages 合并。 + - detail turns 与本地 turns 合并。 + - detail items 经过 legacy normalization、merge、conversation filter。 + - 根据合并后的 turns 恢复 `currentTurnId`。 +- 新增 `sessionHistoryMergeController.test.ts`,覆盖分页 detail 的 messages / turns / threadItems 合并,以及无 incoming turns 时保留当前 turnId。 + +主线收益: + +- Phase 2 中 `loadFullSessionHistory` 的分页请求、分页窗口、分页 merge 已拆成独立 controller;完整历史加载不再把请求参数、窗口状态、消息合并全部堆在 hook 主体中。 +- P2 的“Cursor 分页 + 防止全量拉取”现在有分页窗口和 merge 两层单测保护,后续若旧会话加载仍慢,可以明确区分慢在 runtime page fetch、hydrate/merge,还是 MessageList render。 +- `threadItems` 的 legacy normalization 和 conversation filter 被收进 merge controller,减少后续修分页时漏掉工具过程过滤的风险。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts" "src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts" "src/components/agent/chat/hooks/sessionPostFinalizePersistenceController.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" -t "加载更早历史" --hookTimeout 180000 --testTimeout 120000 +npx eslint "src/components/agent/chat/hooks/sessionHistoryMergeController.ts" "src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts" "src/components/agent/chat/hooks/sessionHistoryPaginationController.ts" "src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- History merge / pagination / switch error / post-finalize / scheduler / finalize / metadata / snapshot / retry / fetch / hydration controller:通过,`48` 个测试通过。 +- `useAsterAgentChat` 完整历史分页定向:通过,`1` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。 +- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。 + +下一刀: + +1. 进入 Phase 3:抽 `streamSubmissionController`,把首页/对话发送首字链路的 ensure session、listener readiness、submit invoke 分段继续收口。 +2. 恢复 GUI 环境后按 `conversation-projection-acceptance.md` 采集旧会话 A/B 打开、切换与首字指标。 + +### 2026-05-05:P3 第十七刀,Agent stream submission controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamSubmissionController.ts`: + - `resolveAgentStreamSubmitErrorMessage` + - `buildAgentStreamSubmitDispatchedContext` + - `buildAgentStreamSubmitAcceptedContext` + - `buildAgentStreamSubmitFailedContext` + - `buildAgentStreamSubmitFailedLogContext` +- `agentStreamSubmitExecution.ts` 中 submit dispatched / accepted / failed 的 metric/log context 改为委托 controller: + - listener bound 到 submit dispatched 的 delta。 + - request start 到 dispatched / accepted / failed 的 elapsed。 + - submit invoke 耗时。 + - metric error message 与 debug 原始 error 分离。 +- 新增 `agentStreamSubmissionController.test.ts`,覆盖 dispatched、accepted、failed metric context 与 failed debug context。 + +主线收益: + +- Phase 3 开始把首字链路拆成可测试边界:提交阶段耗时不再内联在 `executeAgentStreamSubmit` 主体里。 +- `executeAgentStreamSubmit` 继续保持 current runtime 协议,只串接 ensure session、listener binding 和 runtime `submitOp`;submit 分段指标由 controller 统一生成,方便后续 E2E 对比 `ensureSession / listenerBound / submitDispatched / submitAccepted / firstEvent / firstText`。 +- failed metric 与 debug log 的 error 形态被单测固定,避免为了日志可读性破坏 performance summary 的 JSON 友好字段。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts" "src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts" "src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts" "src/components/agent/chat/hooks/sessionPostFinalizePersistenceController.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncScheduler.test.ts" "src/components/agent/chat/hooks/sessionMetadataSyncController.test.ts" "src/components/agent/chat/hooks/sessionFinalizeController.test.ts" "src/components/agent/chat/hooks/sessionSwitchSnapshotController.test.ts" "src/components/agent/chat/hooks/sessionHydrationRetryController.test.ts" "src/components/agent/chat/hooks/sessionDetailFetchController.test.ts" "src/components/agent/chat/hooks/sessionHydrationController.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamSubmissionController.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.ts" "src/components/agent/chat/hooks/sessionHistoryMergeController.ts" "src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts" "src/components/agent/chat/hooks/useAgentSession.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Agent stream submission / submit execution / submit context / turn event binding:通过,`13` 个测试通过。 +- Session controller 回归:通过,`48` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未复跑 `verify:gui-smoke`,原因同前:当前 smoke 会切到独立 Rust target 全量重编,容易造成 CPU 和鼠标繁忙;本刀是纯前端 controller 抽取,先用定向行为测试收口。 +- Playwright MCP 仍等待 profile 释放;不使用 isolated profile 绕过仓库规则。 + +下一刀: + +1. 继续 Phase 3:抽 `agentStreamSubmitOpController`,把 runtime `submitOp` 的参数组装从 `agentStreamSubmitExecution` 中移出。 +2. 或抽 `agentStreamListenerReadinessController`,把 listener bound / first event timeout / silent recovery guard 继续拆成可测试 controller。 + +### 2026-05-05:P3 第十八刀,Agent stream submit op controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamSubmitOpController.ts`: + - `buildAgentStreamSubmitOp` +- `agentStreamSubmitExecution.ts` 中 runtime `submitOp` payload 组装改为委托 controller: + - `activeSessionId -> sessionId`。 + - `submitWorkspaceId -> workspaceId`。 + - `requestTurnId -> turnId`。 + - 统一固定 `queueIfBusy: true`,避免 stream submit 主链到处重复声明 busy queue 语义。 +- 新增 `agentStreamSubmitOpController.test.ts`,覆盖首页首发快路径 payload 与底层 `buildUserInputSubmitOp` payload 等价性。 + +主线收益: + +- Phase 3 继续瘦 `executeAgentStreamSubmit`:执行函数现在更接近 `ensure session -> bind listener -> dispatch submit -> record result`,不再同时承担 runtime payload 拼装职责。 +- 首页输入回车后的首字链路更容易分段排查:后续若 `submitInvokeMs` 异常,可直接区分是 submit lifecycle、payload compaction 还是 runtime bridge 慢。 +- `queueIfBusy` 的当前 stream 语义进入单测保护,避免后续修队列/首字时误把 busy 会话变成阻塞式提交。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/utils/buildUserInputSubmitOp.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamSubmitOpController.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamSubmitOpController.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.ts" +``` + +结果: + +- Agent stream submit op / submit execution / user input builder:通过,`7` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀未复跑 `verify:gui-smoke`。本刀是纯前端 controller 抽取,不改 UI 壳、Bridge、Tauri command 或 runtime event protocol;同时继续避免触发会污染 CPU / 鼠标繁忙现象判断的独立 Rust rebuild。 +- 真实 Playwright E2E 仍按既有规则等待稳定 Lime 页签 / DevBridge 环境,不使用 isolated profile 绕过仓库约束。 + +下一刀: + +1. 继续 Phase 3:抽 `agentStreamListenerReadinessController`,把 listener bound、first event guard、silent recovery 前置条件继续从 submit execution / turn event binding 中拆出。 +2. 或抽 `agentStreamSubmitLifecycleController`,把 dispatched / accepted / failed 记录与 runtime submit invoke 包装成一个可测 lifecycle plan,为 TTFT 阶段日志做更细分的单元边界。 + +### 2026-05-05:P3 第十九刀,Agent stream listener readiness controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts`: + - `extractAgentStreamRuntimeEventType` + - `buildAgentStreamListenerBoundContext` + - `buildAgentStreamFirstEventContext` + - `buildAgentStreamFirstEventDeferredContext` + - `shouldDeferAgentStreamFirstEventTimeout` + - `shouldScheduleAgentStreamInactivityWatchdog` + - `shouldIgnoreAgentStreamInactivityResult` +- `agentStreamTurnEventBinding.ts` 中 listener / first event / inactivity guard 改为委托 controller: + - listener bound metric/log context。 + - recognized / unknown first event metric/log context。 + - submit 已派发但首包暂未到达时的 deferred context。 + - inactivity watchdog 是否调度、过期结果是否丢弃的判断。 +- 新增 `agentStreamListenerReadinessController.test.ts`,覆盖 runtime event type 提取、listener bound context、first event context、first event deferred context、first event timeout defer guard、inactivity watchdog guard。 + +主线收益: + +- Phase 3 的 TTFT readiness 分段进入独立单测边界;首字慢现在能更清楚区分 listener 未绑定、submit 已派发但 runtime 无首包、首包后长时间静默等阶段。 +- `agentStreamTurnEventBinding` 继续瘦身,事件绑定主函数不再内联所有 metric context 与 watchdog guard 判断。 +- 未识别但结构合法 runtime event 的首包活跃态继续被测试保护,避免后续 runtime projection/bootstrap 事件导致 UI 误失败。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Listener readiness controller / turn event binding:通过,`11` 个测试通过。 +- Agent stream readiness / submit / context 回归:通过,`20` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀暂未复跑 `verify:gui-smoke`。本刀仍是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI;继续避免因独立 Rust rebuild 干扰 CPU / 鼠标繁忙问题判断。 + +下一刀: + +1. 继续 Phase 3:抽 `agentStreamSubmitLifecycleController`,把 submit dispatched / accepted / failed metric 记录与 runtime `submitOp` invoke 包装成可测生命周期边界。 +2. 或抽 `agentStreamRequestStartController`,把 request start metric 与 activity log payload 从 `agentStreamTurnEventBinding` 中移出,进一步降低事件绑定主函数职责。 + +### 2026-05-05:P3 第二十刀,Agent stream submit lifecycle controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts`: + - `runAgentStreamSubmitLifecycle` +- `agentStreamSubmitExecution.ts` 中 submit dispatched / accepted / failed 的记录和 `runtime.submitOp` invoke 包装改为委托 controller: + - submit dispatched 时写入 `requestState.submissionDispatchedAt`。 + - submit accepted 时记录 `submitInvokeMs`。 + - submit failed 时 metric 记录可 JSON 化错误文案,debug log 保留原始 error,并继续抛出原始错误。 +- 新增 `agentStreamSubmitLifecycleController.test.ts`,覆盖成功和失败两条生命周期,固定 metric/log 顺序与 requestState 更新时间。 + +主线收益: + +- Phase 3 的 submit invoke 生命周期进入独立单测边界,`executeAgentStreamSubmit` 进一步收敛为 ensure session、bind listener、构造 submit op、交给 lifecycle 执行。 +- 首页输入回车后若首字慢,可以更明确地区分:listener bound 慢、submit invoke 慢、runtime 首包慢,还是后续 render 慢。 +- submit 失败链路保留原始错误抛出,不改变现有错误传播语义,同时确保性能 metric 的 error 字段继续适合汇总分析。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Submit lifecycle / submission context / submit execution / submit op:通过,`9` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`22` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀暂未复跑 `verify:gui-smoke`。本刀仍是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI;继续避免因独立 Rust rebuild 干扰 CPU / 鼠标繁忙问题判断。 + +下一刀: + +1. 继续 Phase 3:抽 `agentStreamRequestStartController`,把 request start metric 与 activity log payload 从 `agentStreamTurnEventBinding` 中移出。 +2. 或抽更细的 `agentStreamUnknownEventController`,把未知 runtime event 活跃态、告警去重与 watchdog 调度从事件绑定主函数中移出。 + +### 2026-05-05:P3 第二十一刀,Agent stream request start controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamRequestStartController.ts`: + - `buildAgentStreamRequestStartMetricContext` + - `buildAgentStreamRequestStartActivityLog` + - `startAgentStreamRequest` +- `agentStreamTurnEventBinding.ts` 中 request start 阶段改为委托 controller: + - 统一写入 `requestState.requestStartedAt`。 + - 统一记录 `agentStream.request.start` metric。 + - 统一创建 activity log,并写回 `requestState.requestLogId`。 +- 新增 `agentStreamRequestStartController.test.ts`,覆盖 metric context、activity log payload、requestState 写入和 metric/activity 依赖调用。 + +主线收益: + +- Phase 3 的首字链路起点进入独立单测边界;request start、listener bound、submit lifecycle、first event readiness 已分别有 controller。 +- `agentStreamTurnEventBinding` 不再直接拼 activity log payload,后续排查首页输入回车慢时可以稳定比较 request start 到 listener / submit / first event 的阶段日志。 +- activity log 的 provider 映射、队列标记、auto continue 元数据继续保持原语义,并由单测固定。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamRequestStartController.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamRequestStartController.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Request start controller / turn event binding:通过,`9` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀暂未复跑 `verify:gui-smoke`。本刀仍是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI;继续避免因独立 Rust rebuild 干扰 CPU / 鼠标繁忙问题判断。 + +下一刀: + +1. 继续 Phase 3:抽 `agentStreamUnknownEventController`,把未知 runtime event 活跃态、告警去重与首包标记策略移出。 +2. 或抽 `agentStreamInactivityController`,把首包超时、silent recovery、inactivity timeout 的调度与恢复策略进一步收口。 + +### 2026-05-05:P3 第二十二刀,Agent stream unknown event controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamUnknownEventController.ts`: + - `buildAgentStreamUnknownEventWarningMessage` + - `resolveAgentStreamUnknownEventPlan` + - `rememberAgentStreamUnknownEventWarning` +- `agentStreamTurnEventBinding.ts` 中未知 runtime event 分支改为委托 controller: + - 无结构化 `type` 时继续忽略。 + - 有 `type` 但 `parseAgentEvent` 不识别时继续标记首包、激活流、调度 inactivity watchdog。 + - 告警文案与去重状态由 controller 统一生成与记录。 +- 新增 `agentStreamUnknownEventController.test.ts`,覆盖告警文案、空 event type、首次未知事件计划、重复未知事件去重与告警状态记录。 + +主线收益: + +- Phase 3 继续瘦 `agentStreamTurnEventBinding`:未知 runtime event 的活跃态保留策略不再内联在事件绑定主函数中。 +- runtime projection/bootstrap 这类未来扩展事件即使暂未被 parser 识别,也能继续保留首包活跃态,避免 UI 误判首包超时失败。 +- 告警去重进入单测保护,避免 provider 高频心跳或 runtime projection 事件刷屏并干扰首字慢日志分析。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamUnknownEventController.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamUnknownEventController.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Unknown event controller / turn event binding:通过,`10` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`29` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀暂未复跑 `verify:gui-smoke`。本刀仍是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI;继续避免因独立 Rust rebuild 干扰 CPU / 鼠标繁忙问题判断。 + +下一刀: + +1. 继续 Phase 3:抽 `agentStreamInactivityController`,把首包超时、silent recovery、inactivity timeout 的调度与恢复策略进一步收口。 +2. 完成 Phase 3 controller 主链后,回到真实 E2E 指标采集,验证首页输入回车到 first status / first text 的阶段耗时。 + +### 2026-05-05:P3 第二十三刀,Agent stream inactivity controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamInactivityController.ts`: + - `AGENT_STREAM_FIRST_EVENT_TIMEOUT_MESSAGE` + - `AGENT_STREAM_INACTIVITY_TIMEOUT_MESSAGE` + - `buildAgentStreamFirstEventSilentRecoveryWarning` + - `buildAgentStreamFirstEventDeferredWarning` + - `buildAgentStreamInactivitySilentRecoveryWarning` + - `resolveAgentStreamFirstEventTimeoutAction` + - `resolveAgentStreamInactivityTimeoutAction` +- `agentStreamTurnEventBinding.ts` 中首包超时和 inactivity timeout 恢复策略改为委托 controller: + - 首包超时后按 `ignore / recover / defer / fail` 执行动作。 + - inactivity timeout 后按 `ignore / recover / fail` 执行动作。 + - silent recovery 与 deferred warning 文案统一从 controller 生成。 + - 用户可见失败文案统一从 controller 常量读取。 +- 新增 `agentStreamInactivityController.test.ts`,覆盖用户文案、warning 文案、首包超时动作决策与 inactivity timeout 动作决策。 + +主线收益: + +- Phase 3 的首字慢异常恢复链路进一步可测试:首包无事件、后台已恢复、提交已派发但首包暂未到达、首包后长时间静默都进入明确 action plan。 +- `agentStreamTurnEventBinding` 不再内联 silent recovery / timeout 文案与动作优先级,后续调整 TTFT 阈值或恢复策略时风险更小。 +- 首包 deferred 与 inactivity synthetic error 的用户文案保持原语义,避免本轮结构拆分改变用户可见行为。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamInactivityController.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamInactivityController.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Inactivity controller / turn event binding:通过,`10` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`33` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀暂未复跑 `verify:gui-smoke`。本刀仍是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI;继续避免因独立 Rust rebuild 干扰 CPU / 鼠标繁忙问题判断。 + +下一刀: + +1. Phase 3 controller 主链完成后,回到真实 E2E 指标采集,验证首页输入回车到 first status / first text 的阶段耗时。 +2. 若 E2E 仍显示首字慢在事件处理后段,再进入 `streamEventReducer` 拆分;若慢在 render,再进入 Phase 4 render projection。 + +### 2026-05-05:P3 E2E 指标采集准备与阻塞记录 + +已完成: + +- 确认现有前端壳与 DevBridge 已就绪,未启动新的 GUI / Rust 进程: + +```bash +curl -fsS "http://127.0.0.1:1420/" +npm run bridge:health -- --timeout-ms 120000 +``` + +结果: + +- 前端 `http://127.0.0.1:1420/` 可访问。 +- DevBridge `http://127.0.0.1:3030/health` 就绪,`status=ok`,健康检查耗时 `33ms`。 + +阻塞: + +- Playwright MCP 当前无法接管浏览器,报错为 profile 已被 `/Users/coso/Library/Caches/ms-playwright/mcp-chrome-348597d` 占用;按仓库规则未使用 `--isolated` 绕过。 +- 已检查 Chrome 现有页签,存在 `http://127.0.0.1:1420/` 的 `Lime` 页签。 +- AppleScript 可读取 Chrome 页签 URL / title,但 Chrome 未开启“允许 Apple 事件中的 JavaScript”,无法执行 `window.__LIME_AGENTUI_PERF__?.summary()` 采集页面指标。 +- 未结束或清理现有 MCP / Chrome 进程,避免破坏用户或其他 agent 的浏览器会话。 + +下一次续测条件: + +1. 关闭占用 `mcp-chrome-348597d` 的旧 Playwright MCP 会话,或由用户确认允许清理这些 MCP 进程。 +2. 或在现有 Chrome 中开启 `查看 -> 开发者 -> 允许 Apple 事件中的 JavaScript`,允许只读执行 `window.__LIME_AGENTUI_PERF__?.summary()`。 +3. 恢复后按 `docs/roadmap/agentui/conversation-projection-acceptance.md` 采集:首页短 prompt -> conversation shell -> first runtime status -> first text delta/paint。 + +### 2026-05-05:P3 第二十四刀,Agent stream runtime metrics controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts`: + - `shouldRecordAgentStreamFirstRuntimeStatus` + - `shouldRecordAgentStreamFirstTextDelta` + - `buildAgentStreamFirstRuntimeStatusMetricContext` + - `buildAgentStreamFirstTextDeltaMetricContext` +- `agentStreamRuntimeHandler.ts` 中 first runtime status / first text delta 指标记录改为委托 controller: + - first runtime status 的 elapsed、first event delta、phase、title、session 统一生成。 + - first text delta 的 delta chars、elapsed、first event delta、first runtime status delta、session 统一生成。 + - 一次性记录判断进入单测,避免重复 text delta 或重复 runtime status 污染 TTFT 指标。 +- 新增 `agentStreamRuntimeMetricsController.test.ts`,覆盖 first status/text delta 是否记录、指标上下文和缺失前置阶段时的 null delta。 + +主线收益: + +- Phase 3 的首字链路后段继续可测试:`request start -> listener -> submit -> first event -> first runtime status -> first text delta` 的指标上下文已基本从主函数中拆出。 +- 后续 E2E 恢复后,`window.__LIME_AGENTUI_PERF__` 的阶段指标更容易对应到 controller 单测,便于判断慢点在 runtime bridge、provider 首包、事件处理还是 render。 +- `agentStreamRuntimeHandler` 开始为 `streamEventReducer` 拆分做前置减法,先抽指标与判断,不一次性重写大 switch,降低回归风险。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Runtime metrics controller / runtime handler:通过,`15` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`48` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀仍未做真实页面交互。阻塞同前:Playwright MCP profile 被占用,且 Chrome 未开启 AppleScript JS 执行能力。 +- 前端与 DevBridge 健康态已在上一条记录确认;未启动新的 GUI / Rust 进程。 + +下一刀: + +1. 恢复 E2E 后采集 `firstRuntimeStatus / firstTextDelta / firstTextPaint` 指标,确认慢点是否仍在事件处理后段。 +2. 若仍无法恢复 E2E,就继续小步拆 `agentStreamRuntimeHandler` 中 text delta flush / runtime status apply 的 reducer 边界。 + +### 2026-05-05:P3 第二十五刀,Agent stream runtime status controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts`: + - `buildAgentStreamNormalizedRuntimeStatus` + - `buildAgentStreamRuntimeStatusApplyPlan` + - `selectAgentStreamRuntimeSummaryItem` + - `buildAgentStreamRuntimeSummaryItemUpdate` +- `agentStreamRuntimeHandler.ts` 中 `runtime_status` apply 逻辑改为委托 controller: + - runtime status title 归一化。 + - runtime summary 文案生成。 + - pending summary item 优先选择。 + - pending item 存在但不是 `turn_summary` 时保持原行为,不回退其他 summary。 + - 无 pending item 时选择同 session 最新 in-progress summary。 +- 新增 `agentStreamRuntimeStatusController.test.ts`,覆盖 status apply plan、pending summary 优先级、pending 非 summary 不回退、fallback 最新 summary 与 summary item 更新。 + +主线收益: + +- Phase 3 继续为 `streamEventReducer` 拆分做前置减法:`runtime_status` 的状态归一化与 thread summary 更新策略不再内联在 `agentStreamRuntimeHandler` 大 switch 中。 +- 首字前状态展示路径更可测,后续 E2E 若显示 first runtime status 已到但 UI 状态慢,可直接定位到 status apply / render,而不是混在事件处理主函数里。 +- 保留 pending 非 summary 不回退的旧行为,避免结构拆分顺手改变 runtime summary 选择语义。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Runtime status controller / runtime handler:通过,`16` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`53` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀仍未做真实页面交互。阻塞同前:Playwright MCP profile 被占用,且 Chrome 未开启 AppleScript JS 执行能力。 +- 未启动新的 GUI / Rust 进程,不干扰用户当前浏览器会话。 + +下一刀: + +1. 恢复 E2E 后采集 `firstRuntimeStatus / firstTextDelta / firstTextPaint` 指标,确认首字慢是否仍在事件处理后段。 +2. 若 E2E 仍无法恢复,继续拆 `agentStreamRuntimeHandler` 中 text delta flush 或 final_done reconcile 的 reducer 边界。 + +### 2026-05-05:P3 第二十六刀,Agent stream text delta controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamTextDeltaController.ts`: + - `buildAgentStreamTextDeltaApplyPlan` +- `agentStreamRuntimeHandler.ts` 中 `text_delta` apply 前半段改为委托 controller: + - text delta buffer 计数。 + - 首个 text delta 的时间戳和 metric context。 + - accumulated content 的 overlap append 计划。 + - observer 仍收到原始 delta 和合并后的 accumulated content。 + - typewriter sound 与 text render flush 调度保持原位置,不改变渲染节流行为。 +- 新增 `agentStreamTextDeltaController.test.ts`,覆盖首个 text delta 指标、非首个 delta 不重复记录、overlap detection 防重复吐字。 + +主线收益: + +- Phase 3 继续为 `streamEventReducer` 拆分做前置减法:`text_delta` 的 buffer / first delta metric / overlap append 逻辑不再内联在 runtime handler 大 switch 中。 +- 重复吐字问题的关键防线进入独立 controller 单测,后续调整流式 flush 或 final_done reconcile 时更不容易破坏。 +- 首字链路的 first text delta metric 与实际 accumulated content 更新绑定在同一个 apply plan,便于后续 E2E 对齐 `firstTextDelta` 与 `firstTextPaint`。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamTextDeltaController.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/components/agent/chat/hooks/agentStreamTextDeltaController.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Text delta controller / runtime handler:通过,`14` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`56` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀仍未做真实页面交互。阻塞同前:Playwright MCP profile 被占用,且 Chrome 未开启 AppleScript JS 执行能力。 +- 未启动新的 GUI / Rust 进程,不干扰用户当前浏览器会话。 + +下一刀: + +1. 恢复 E2E 后采集 `firstTextDelta / firstTextPaint` 指标,确认首字慢是否仍在 text render flush。 +2. 若 E2E 仍无法恢复,继续拆 text render flush 或 final_done reconcile 的 reducer 边界。 + +### 2026-05-05:P3 第二十七刀,Agent stream text render flush controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamTextRenderFlushController.ts`: + - `resolveAgentStreamPendingRenderedTextDelta` + - `shouldFlushAgentStreamVisibleFirstText` + - `shouldScheduleAgentStreamTextRenderTimer` + - `buildAgentStreamTextRenderFlushPlan` + - `buildAgentStreamFirstTextPaintContext` +- `agentStreamRuntimeHandler.ts` 中 `flushPendingTextRender / scheduleTextRenderFlush` 改为委托 controller: + - 首个可见文本继续立即 flush,不等待 32ms timer。 + - 后续 text render flush 继续保持 `TEXT_DELTA_RENDER_FLUSH_MS=32` 节流。 + - first text render flush、first text paint、backlog、flush count、debug dedupe key 由 plan 统一计算。 + - `requestState.renderedContent / textDeltaFlushCount / lastTextRenderFlushAt / maxTextDeltaBacklogChars / firstTextPaintScheduled` 仍在 handler 中作为副作用写回。 +- 新增 `agentStreamTextRenderFlushController.test.ts`,覆盖待渲染 delta 解析、首字立即 flush 判定、timer 调度、首个 render flush plan、非首 flush 不重复 first metric、first paint metric context。 + +主线收益: + +- Phase 3 继续为 `streamEventReducer` 拆分做前置减法:文本可见渲染 flush 的决策、指标和日志上下文不再内联在 `agentStreamRuntimeHandler` 大函数中。 +- 首字链路后半段现在可单独测试:`firstTextDelta -> firstTextRenderFlush -> firstTextPaint` 的延迟可以从 controller plan 与 E2E 指标对齐分析。 +- 保留“首个可见文本立即 flush、后续 32ms 节流”的当前性能语义,不引入 UI 协议变化或新队列。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Text render flush controller / runtime handler:通过,`16` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`61` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未做真实页面交互。它是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI。 +- 仍避免启动新的 GUI / Rust 进程,防止干扰用户正在观察的 CPU / 鼠标繁忙问题。 + +下一刀: + +1. 先补本刀 `git diff --check` 后收口;随后恢复 E2E 后采集 `firstTextDelta / firstTextRenderFlush / firstTextPaint / textDeltaFlushCount / maxTextDeltaBacklogChars`。 +2. 若 E2E 仍显示慢在事件处理后段,继续拆 `final_done` reconcile / completion reducer;若慢在 render,则进入 Phase 4 render projection 或 Markdown hydrate 分批。 + +### 2026-05-05:P3 第二十八刀,Agent stream completion controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamCompletionController.ts`: + - `isAgentStreamEmptyFinalReplyError` + - `shouldFailAgentStreamMissingFinalReply` + - `resolveAgentStreamGracefulCompletionContent` + - `reconcileAgentStreamFinalContentParts` + - 空最终回复错误文案常量。 +- `agentStreamRuntimeHandler.ts` 中 `final_done` 与 empty-final-error 降级分支改为委托 completion controller: + - 空最终回复失败判定不再在 `final_done` 分支内联计算。 + - graceful completion 内容继续先剥离 assistant protocol residue,再按原逻辑回退 raw / fallback。 + - 最终 `contentParts` reconcile 继续保留过程 part、按 `surfaceThinkingDeltas` 过滤 thinking,并在最终文本变化时重建 text part。 +- 新增 `agentStreamCompletionController.test.ts`,覆盖 empty-final-error 识别、空回复失败判定、meaningful completion signal 降级、协议残留 fallback、最终 contentParts reconcile 与 thinking 过滤。 + +主线收益: + +- Phase 3 继续收窄 `agentStreamRuntimeHandler` 大 switch:完成态的纯判断和最终消息内容计划已进入独立 controller,后续再拆副作用 action 时不需要同时搬协议残留和 contentParts 细节。 +- 重复吐字 / 排版问题的完成态防线更清晰:`text_delta` 负责 overlap append,`text render flush` 负责可见增量,`completion` 负责最终文本与 contentParts 对齐。 +- 保留现有行为,不改变 runtime event protocol、toast 文案或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Completion controller / runtime handler:通过,`16` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`66` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未做真实页面交互。它仍是纯前端 stream controller 抽取,不改 Tauri command、Bridge、runtime event protocol 或用户可见 UI。 +- 未启动新的 GUI / Rust 进程,避免干扰用户当前 CPU / 鼠标繁忙观察。 + +下一刀: + +1. 补本刀 `git diff --check` 后,优先恢复 E2E 指标采集,确认首页 Enter 到 `firstRuntimeStatus / firstTextDelta / firstTextPaint` 的真实分段。 +2. 若仍无法 E2E,就把 completion/error 的副作用路径包装成更薄的 reducer action,或开始拆 `agentStreamRuntimeHandler` 的 tool event apply 边界。 + +### 2026-05-05:P3 第二十九刀,Agent stream tool completion signal controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.ts`: + - `hasMeaningfulAgentStreamToolCompletionSignal` + - 内部统一把 normalized tool result 转为 record。 + - 复用站点保存信号、图片任务预览、通用任务预览与 artifact 预览作为 meaningful completion signal 判断来源。 +- `agentStreamRuntimeHandler.ts` 的 `tool_end` 分支改为委托 tool completion signal controller: + - handler 不再直接依赖 `siteToolResultSummary` 与 `taskPreviewFromToolResult` 的多种预览构造函数。 + - 仍只在 tool result 真实可展示/可恢复时设置 `requestState.hasMeaningfulCompletionSignal`。 +- 新增 `agentStreamToolCompletionSignalController.test.ts`,覆盖站点保存 metadata、图片任务 metadata 与普通空结果。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的横向依赖:tool result 是否能支撑“无最终文本但过程有产物”的完成语义进入独立 controller。 +- completion controller 与 tool completion signal controller 分工更清楚:前者处理最终内容/协议残留,后者处理工具产物是否构成可降级完成信号。 +- 后续排查“模型未输出最终答复但 UI 是否应显示失败”时,可以单测 tool result 信号,不需要跑完整 stream handler。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +npm run bridge:health -- --timeout-ms 120000 +``` + +结果: + +- Tool completion signal / completion / runtime handler:通过,`19` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`69` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- DevBridge health:通过,`77ms` 就绪。 + +GUI / E2E 状态: + +- 已尝试进入 Playwright MCP 续测,但当前 MCP Chrome profile 仍被占用:`Browser is already in use for /Users/coso/Library/Caches/ms-playwright/mcp-chrome-348597d, use --isolated to run multiple instances of the same browser`。 +- 按 `docs/aiprompts/playwright-e2e.md` 约束,本轮没有使用 `--isolated`,也没有 kill/清理现有 Chrome 或 MCP 进程。 + +下一刀: + +1. 等 Playwright MCP profile 可复用后,优先采集首页 Enter 到 `firstRuntimeStatus / firstTextDelta / firstTextPaint` 的真实分段。 +2. 如果仍无法 E2E,继续把 `agentStreamRuntimeHandler` 的 error/final completion 副作用或 tool event apply 拆成更小 action plan。 + +### 2026-05-05:P3 第三十刀,Agent stream error controller + +已完成: + +- 进入 Playwright MCP 前先执行 `npm run bridge:health -- --timeout-ms 120000`,DevBridge `111ms` 就绪。 +- 再次尝试复用 Playwright MCP 当前浏览器会话,仍被 MCP Chrome profile lock 阻塞:`Browser is already in use for /Users/coso/Library/Caches/ms-playwright/mcp-chrome-348597d, use --isolated to run multiple instances of the same browser`。 +- 按续遵守 `docs/aiprompts/playwright-e2e.md`:没有使用 `--isolated`,没有 kill/清理现有 Chrome 或 MCP 进程。 +- 新增 `src/components/agent/chat/hooks/agentStreamErrorController.ts`: + - `buildAgentStreamErrorToastPlan` + - `buildAgentStreamFailedAssistantMessagePatch` +- `agentStreamRuntimeHandler.ts` 的 missing final failure 与普通 error 分支改为委托 error controller: + - rate limit / 429 toast level 与文案不再在 handler 内联判断。 + - 失败 assistant 消息的 `content / runtimeStatus / isThinking / usage` patch 不再在 handler 重复组装。 + - `markFailedTimelineState` 仍保留在 handler 内,继续负责 thread turn / item 的副作用写回。 +- 新增 `agentStreamErrorController.test.ts`,覆盖 rate limit warning、普通 error toast、保留局部输出的失败消息 patch、无局部输出时回退 previous content 与 usage 带回。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 error 分支:展示决策和失败消息 patch 进入纯 controller,handler 只保留必要副作用顺序。 +- 首字/流式链路出错时更容易定位:runtime event、completion fallback、tool completion signal、error presentation 已分别可单测。 +- 保留现有 UI 文案与失败状态语义,不改变 runtime event protocol、Tauri command 或 Bridge。 + +已验证: + +```bash +npm run bridge:health -- --timeout-ms 120000 +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Error controller / runtime handler:通过,`15` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`73` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- DevBridge health:通过,`111ms` 就绪。 + +GUI / E2E 状态: + +- 真实页面交互仍未完成,停留在 MCP profile lock 阶段;当前没有可报告的页面 URL / 控制台 error 增量 / 首页 Enter 指标。 +- 本刀是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;因此未启动 `verify:gui-smoke`,避免 Rust rebuild 干扰 CPU / 鼠标繁忙观察。 + +下一刀: + +1. 等 Playwright MCP profile 可复用后,优先恢复 E2E 采集首页 Enter 到 `firstRuntimeStatus / firstTextDelta / firstTextPaint` 的真实分段。 +2. 若 E2E 继续不可用,继续拆 `agentStreamRuntimeHandler` 中 warning / queued draft / thread item 高频事件的纯 action plan,而不是扩大到 GUI 重构。 + +### 2026-05-05:P3 第三十一刀,Agent stream warning controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamWarningController.ts`: + - `buildAgentStreamWarningPlan` + - 统一 workspace auto-created warning 忽略、warning key 生成、已提示去重、shouldToast 判断与 toast level/message plan。 +- `agentStreamRuntimeHandler.ts` 的 `warning` 分支改为委托 warning controller: + - handler 不再直接依赖 `WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE` 与 `resolveRuntimeWarningToastPresentation`。 + - handler 只保留 `warnedKeysRef` 写入与 `toast.info/error/warning` 副作用。 + - 保留现有语义:不需要 toast 的 warning 仍会标记 warned,避免后续重复处理。 +- 新增 `agentStreamWarningController.test.ts`,覆盖 workspace auto-created 忽略、重复 warning 不 toast、普通 warning toast plan、不 toast warning 仍标记 warned。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的低频分支:warning 事件的忽略/去重/展示决策进入纯 controller。 +- 当前 stream handler 中首字、运行态、文本增量、渲染 flush、完成态、tool completion signal、error、warning 都已有独立可测边界。 +- 保留现有 UI 文案与 warning 去重语义,不改变 runtime event protocol、Tauri command 或 Bridge。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Warning controller / runtime handler:通过,`15` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`77` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未重新进入 Playwright;上一刀已经确认 MCP profile lock 阻塞仍在。 +- 本刀是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复 E2E 指标采集。 +2. 若继续做代码小刀,优先拆 queued draft / thread item 高频事件的 action plan,或开始把 `handleToolStartEvent / handleToolEndEvent` 周边状态写回收敛成更薄边界。 + +### 2026-05-05:P3 最终收口,stream controller 阶段验证边界 + +收口结论: + +- 本阶段 Phase 3 代码侧已完成一组可测 controller 拆分:submit、listener readiness、request start、unknown event、inactivity、runtime metrics、runtime status、text delta、text render flush、completion、tool completion signal、error、warning。 +- `agentStreamRuntimeHandler.ts` 仍保留必要 UI / thread / toast 副作用顺序,但首字链路、重复吐字防线、完成态降级、错误与 warning 展示决策都已从大 switch 中移出。 +- 最后一次尝试 Playwright MCP 仍失败于 profile lock:`Browser is already in use for /Users/coso/Library/Caches/ms-playwright/mcp-chrome-348597d, use --isolated to run multiple instances of the same browser`。 +- 按续遵守 GUI 续测约束:没有使用 `--isolated`,没有 kill / 清理用户当前 Chrome 或 MCP 进程。 + +最终验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +``` + +结果: + +- Agent stream Phase 3 定向回归最终复跑:通过,`19` 个测试文件、`77` 个测试通过。 +- 本阶段最近一次静态验证已通过:ESLint touched files、TypeScript `tsc --noEmit --pretty false`、`git diff --check`。 + +未完成边界: + +- 真实 GUI / Playwright E2E 仍未完成;缺少首页 Enter 到 `firstRuntimeStatus / firstTextDelta / firstTextPaint` 的最终实测数据。 +- 因此本阶段只能判定“stream controller 代码拆分与定向回归完成”,不能判定“GUI 体感性能已最终交付”。 + +下一步最短路径: + +1. 释放或复用 Playwright MCP profile 后,立即采集首页 Enter / 旧会话打开的真实性能 summary。 +2. 若 `firstTextPaint` 已快但仍卡,转向 render / Markdown hydrate;若 `firstTextDelta` 慢,转 provider / runtime;若 `submitAccepted` 前慢,回查 session ensure / listener bind。 + +### 2026-05-05:P3 第三十二刀,Agent stream queue controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamQueueController.ts`: + - `buildAgentStreamQueuedDraftMessagePatch` + - `shouldWatchAgentStreamQueuedDraftCleanup` + - `shouldWatchAgentStreamQueuedDraftCleanupForCleared` +- `agentStreamRuntimeHandler.ts` 的 queued draft / queue removed / queue cleared 分支改为委托 queue controller: + - queued draft 的 `isThinking=false` 与 queued runtime status patch 不再在 handler 内联组装。 + - queue removed / cleared 后是否继续观察当前 queued draft 的判断不再散落在 switch case 中。 + - handler 仍保留 `requestState.queuedTurnId`、queued turn store、draft cleanup timer 等副作用顺序。 +- 新增 `agentStreamQueueController.test.ts`,覆盖 queued message text 优先、content fallback、单个 removed 与 cleared 覆盖当前 draft 的判断。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 queue 分支:排队态展示与 cleanup watch 判定进入纯 controller。 +- 首页 Enter / 多 tab / busy 会话场景依赖 `queueIfBusy` 与 queued draft 展示;该语义现在有独立单测保护,后续排查“新建/旧会话切换后无法继续输入”时更好定位。 +- 保留现有 queue 行为,不改变 runtime event protocol、Tauri command、Bridge 或用户可见文案。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamQueueController.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Queue controller / runtime handler:通过,`15` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`20` 个测试文件、`81` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在。 +- 本刀仍是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复真实 E2E 指标采集。 +2. 如果继续代码拆分,下一刀只看 thread item 高频事件的 action plan,不再扩大到无关 UI 重构。 + +### 2026-05-05:P3 第三十三刀,Agent stream thread item controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamThreadItemController.ts`: + - `shouldDeferAgentStreamThreadItemUpdate` + - `buildAgentStreamTurnStartedPendingItemUpdate` +- `agentStreamRuntimeHandler.ts` 的 `turn_started` 与 `item_updated` 分支改为委托 thread item controller: + - in-progress `reasoning / agent_message` 高频更新延后判断不再内联在 handler。 + - `turn_started` 时 pending item 绑定真实 `thread_id / turn_id / updated_at` 的 patch 不再内联组装。 + - handler 仍保留 `setThreadItems`、remove/upsert 顺序与其它 runtime 副作用。 +- 新增 `agentStreamThreadItemController.test.ts`,覆盖 reasoning / agent_message 延后、非文本/已完成 item 不延后、pending item 绑定真实 turn、无 pending item 返回空。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 thread item 分支:高频更新策略与 turn_started pending patch 进入纯 controller。 +- 旧会话恢复与流式过程中 thread item 数量大时,最容易产生同步计算和状态写入压力;这条延后策略现在有独立单测保护。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge 或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamThreadItemController.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Thread item controller / runtime handler:通过,`15` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`21` 个测试文件、`85` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在。 +- 本刀仍是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复真实 E2E 指标采集。 +2. 如果继续代码拆分,只看 tool / artifact / action event apply 的薄 action plan,避免偏离主线。 + +### 2026-05-05:P3 第三十四刀,Agent stream tool event controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamToolEventController.ts`: + - `buildAgentStreamToolEndPreApplyPlan` + - 统一 `tool_end` 前置的 `normalizeIncomingToolResult`、`toolNameByToolId` lookup 与 meaningful completion signal 判断。 +- `agentStreamRuntimeHandler.ts` 的 `tool_end` 分支改为委托 tool event controller: + - handler 不再直接依赖 `normalizeIncomingToolResult` 与 `hasMeaningfulAgentStreamToolCompletionSignal`。 + - handler 仍只在 plan 标记 `hasMeaningfulCompletionSignal` 时写回 `requestState.hasMeaningfulCompletionSignal`。 + - `handleToolEndEvent` 的原始副作用路径保持不变,避免改变工具结果展示、文件写入和消息更新顺序。 +- 新增 `agentStreamToolEventController.test.ts`,覆盖 tool name lookup、Lime metadata block 归一化、图片任务 meaningful completion 与普通 result 不标记。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 tool_end 分支:工具完成前置判断进入纯 controller。 +- “有工具产物但模型未输出最终文本”这条降级完成语义现在由 tool completion signal 与 tool event pre-apply plan 共同保护。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、文件写入或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamToolEventController.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Tool event controller / runtime handler:通过,`14` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`22` 个测试文件、`88` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在。 +- 本刀仍是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复真实 E2E 指标采集。 +2. 如果继续代码拆分,只看 artifact / action event apply 的薄 action plan,避免偏离主线。 + +### 2026-05-05:P3 第三十五刀,Agent stream artifact/action controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamArtifactActionController.ts`: + - `buildAgentStreamArtifactSnapshotPreApplyPlan` + - `buildAgentStreamActionRequiredPreApplyPlan` +- `agentStreamRuntimeHandler.ts` 的 `artifact_snapshot / action_required` 分支改为委托 artifact/action controller: + - `artifact_snapshot` 前置的 activate stream、清 optimistic item、meaningful completion signal 标记进入 plan。 + - `action_required` 前置的 activate stream、清 optimistic item 进入 plan。 + - `handleArtifactSnapshotEvent / handleActionRequiredEvent` 的原始副作用路径保持不变。 +- 新增 `agentStreamArtifactActionController.test.ts`,覆盖 artifact snapshot 前置计划、空 artifact 仍保持完成信号语义、action required 前置计划。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 artifact/action 分支:事件前置副作用决策进入纯 controller。 +- artifact snapshot 仍作为 meaningful completion signal,保护“有产物但模型未输出最终文本”的降级完成语义。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、文件写入、权限确认或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamArtifactActionController.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Artifact/action controller / runtime handler:通过,`14` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`23` 个测试文件、`91` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在。 +- 本刀仍是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复真实 E2E 指标采集。 +2. 如果继续代码拆分,只看 context trace / turn context / model change event apply 的薄 action plan,避免偏离主线。 + +### 2026-05-05:P3 第三十六刀,Agent stream runtime context controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamRuntimeContextController.ts`: + - `buildAgentStreamContextTracePreApplyPlan` + - `buildAgentStreamTurnContextPreApplyPlan` + - `buildAgentStreamModelChangePreApplyPlan` + - `applyAgentStreamTurnContextExecutionRuntime` + - `applyAgentStreamModelChangeExecutionRuntime` +- `agentStreamRuntimeHandler.ts` 的 `context_trace / turn_context / model_change` 分支改为委托 runtime context controller: + - `context_trace` 前置 activate stream / clear optimistic item 进入 plan。 + - `turn_context / model_change` 前置 activate stream 进入 plan。 + - execution runtime apply 通过 controller wrapper 进入 handler,原有 apply 语义不变。 + - `handleContextTraceEvent` 与 `setExecutionRuntime` 副作用顺序保持不变。 +- 新增 `agentStreamRuntimeContextController.test.ts`,覆盖 context trace latest stage、turn context runtime apply、model change runtime apply 与当前 turn 状态保留。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 context/runtime 分支:上下文轨迹与 execution runtime 更新前置决策进入纯 controller。 +- 首字链路中 `turn_context / model_change` 到达后,runtime 恢复状态仍受现有 utility 保护,同时可通过 controller 单测定位。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、execution runtime 结构或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamRuntimeContextController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Runtime context controller / runtime handler:通过,`14` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`24` 个测试文件、`94` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在。 +- 本刀仍是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复真实 E2E 指标采集。 +2. 如果继续代码拆分,只看 thinking delta 或 final side-effect action 的薄 action plan,避免偏离主线。 + +### 2026-05-05:P3 第三十七刀,Agent stream thinking delta controller + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts`: + - `buildAgentStreamThinkingDeltaPreApplyPlan` + - `buildAgentStreamThinkingDeltaMessagePatch` +- `agentStreamRuntimeHandler.ts` 的 `thinking_delta` 分支改为委托 thinking delta controller: + - 前置 activate stream 与 `surfaceThinkingDeltas` guard 进入 plan。 + - thinkingContent 的 overlap append 与 contentParts thinking append 进入消息 patch。 + - handler 仍保留 `setMessages` 副作用与 assistant message id 过滤顺序。 +- 新增 `agentStreamThinkingDeltaController.test.ts`,覆盖 surface guard、overlap append、contentParts 追加和无 contentParts 时的默认追加。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的 thinking 分支:思考流的显示开关与消息 patch 进入纯 controller。 +- 重复吐字防线从 text delta 扩展到 thinking delta,thinkingContent 的 overlap append 现在有独立单测保护。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、thinking 展示开关或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Thinking delta controller / runtime handler:通过,`14` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`97` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在。 +- 本刀仍是纯前端 stream controller 抽取,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复真实 E2E 指标采集。 +2. 如果继续代码拆分,只看 final side-effect action 的薄 action plan,避免偏离主线。 + +### 2026-05-05:P3 第三十八刀,Agent stream completion assistant patch 收口 + +已完成: + +- 扩展 `src/components/agent/chat/hooks/agentStreamCompletionController.ts`: + - 新增 `buildAgentStreamCompletedAssistantMessagePatch`,统一完成态 assistant 消息的 `content / contentParts / usage / runtimeStatus / isThinking` patch。 + - 复用 `reconcileAgentStreamFinalContentParts`,保持协议残留清理、thinking part 过滤与最终 text part 重建语义不变。 +- `agentStreamRuntimeHandler.ts` 的 `final_done` 与 empty-final graceful completion 分支改为委托 completion controller 生成 assistant message patch: + - handler 仍只保留队列清理、request log、observer complete、listener dispose 等副作用编排。 + - 完成态消息 patch 不再在 handler 内联拼装,降低流式完成分支与重复吐字 / 排版回归的耦合。 +- `agentStreamCompletionController.test.ts` 新增完成态 assistant patch 回归,覆盖 usage 带回和最终文本重建。 +- 验证门禁顺手收口 `src/lib/activeContentTarget.ts` 的输入类型: + - `setActiveContentTarget` 允许接收任意 canvas type 字符串,再通过既有 `normalizeThemeCanvasType` 收敛到 `document / video / null`。 + - 这只解除 `DesignCanvasState.type === "design"` 对 workspace typecheck 的阻塞,不扩大 active content target 的 current 事实源范围。 + +主线收益: + +- Phase 3 继续压缩 `agentStreamRuntimeHandler` 的完成分支:完成态 assistant 消息归一进入纯 controller,后续排查 final_done 只需区分“消息 patch”与“副作用编排”。 +- 重复吐字 / 输出排版的最终态防线继续集中在 completion controller 单测中,避免 final_done 二次 append 或 thinking part 意外混入正文。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、active target 持久化格式或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`98` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 与类型门禁收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,只看 `final_done` 日志 / 队列清理 / listener dispose 的 side-effect plan;若 E2E 指标显示慢在 render,则转回 Phase 4 render projection。 + +### 2026-05-05:P3 第三十九刀,Agent stream final side-effect plan 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamCompletionController.ts`: + - 新增 `buildAgentStreamFinalDonePlan`,统一 `final_done` 的空最终回复失败判断、最终内容解析、queued turn 清理 ID 与 request log payload。 + - 新增 `buildAgentStreamEmptyFinalErrorPlan`,统一 empty-final error 在“无真实产物信号 -> 失败”和“已有真实产物信号 -> 软完成”之间的决策。 +- `agentStreamRuntimeHandler.ts` 的完成分支继续变薄: + - `final_done` 不再内联判断 `shouldFailAgentStreamMissingFinalReply`,只消费 completion plan 后执行副作用。 + - empty-final error 不再内联 queued turn / request log / graceful content 组装,软完成与失败分叉由 controller 决定。 +- `agentStreamCompletionController.test.ts` 新增 side-effect plan 回归,覆盖: + - `final_done` 协议残留清理后的完成计划。 + - 缺少最终回复的失败计划和 usage 保留。 + - empty-final error 在无产物信号与有产物信号两种情况下的分叉。 + +主线收益: + +- Phase 3 的 `final_done` 链路进一步拆成“纯决策 plan + handler 副作用执行”,首字 / 流式完成慢点排查时可以把 completion 语义与 React state / listener cleanup 分开看。 +- “空 final_done / 工具有产物但无最终文本 / 协议残留清理”继续收敛到同一个 current controller,避免重复吐字、排版错乱或空回复误报在 handler 中回流。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +``` + +结果: + +- Completion controller / runtime handler:通过,`2` 个测试文件、`20` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`101` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,只看 `error` 分支失败完成 side-effect plan;若 E2E 指标显示慢在 render,则转回 Phase 4 render projection。 + +### 2026-05-05:P3 第四十刀,Agent stream error failure side-effect plan 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamErrorController.ts`: + - 新增 `buildAgentStreamErrorFailurePlan`,统一普通 runtime error 的错误文案、queued turn 清理 ID、request log payload 与 toast plan。 + - 复用既有 `buildAgentStreamErrorToastPlan`,保持 rate limit -> warning toast、普通错误 -> runtime error toast 的展示语义不变。 +- `agentStreamRuntimeHandler.ts` 的普通 `error` 分支继续变薄: + - handler 不再内联 `queuedTurnId ? [queuedTurnId] : []`、`chat_request_error` payload 或 toast plan 组装。 + - handler 只消费 error failure plan 后执行 timeline 标失败、队列清理、request log、observer、toast、assistant message patch 与 listener dispose。 +- `agentStreamErrorController.test.ts` 新增失败 side-effect plan 回归,覆盖普通错误和 rate limit toast 降级。 + +主线收益: + +- Phase 3 的普通错误链路继续收敛到 current controller:error 分支的“失败语义决策”和 handler 的“副作用执行”分离,后续排查首 token / 流式中断时更容易定位慢点或错态来源。 +- `agentStreamRuntimeHandler` 中普通 error 分支不再重复拼 queued turn、request log 与 toast,减少后续修空 final / rate limit / provider error 时互相踩语义的风险。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Error controller / runtime handler:通过,`2` 个测试文件、`17` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`103` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,只看 `warning` toast 执行 plan 或 `markFailedTimelineState` 的 timeline failure plan;若 E2E 指标显示慢在 render,则转回 Phase 4 render projection。 + +### 2026-05-05:P3 第四十一刀,Agent stream failed timeline plan 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamErrorController.ts`: + - 新增 `selectAgentStreamFailedTimelineTurn`,统一 pending turn 优先、当前会话最后一个 running turn 回退的选择策略。 + - 新增 `buildAgentStreamFailedTimelineTurnUpdate`,统一 running turn 失败态 patch。 + - 新增 `buildAgentStreamFailedTimelineItemUpdate`,统一 pending `turn_summary` 失败态 patch 和失败 runtime summary 文案。 +- `agentStreamRuntimeHandler.ts` 的 `markFailedTimelineState` 继续变薄: + - handler 不再内联查找 running turn。 + - handler 不再内联构造 failed runtime status summary。 + - handler 只负责把 controller 产出的 turn / item update 写回 `upsertThreadTurnState` / `upsertThreadItemState`。 +- `agentStreamErrorController.test.ts` 新增 failed timeline plan 回归,覆盖: + - pending turn 优先。 + - pending turn 缺失时回退当前 session 最后一个 running turn。 + - `turn_summary` 失败 patch 保留已有 `completed_at`。 + - pending item 缺失或不是 `turn_summary` 时跳过更新。 + +主线收益: + +- Phase 3 的错误 timeline 更新继续收敛到 current error controller,stream handler 不再同时承担失败语义、timeline 查找与状态 patch 组装。 +- 后续排查“流式错误后 timeline 卡在 running / summary 文案不一致 / 错 turn 被标失败”时,可以直接测 controller,不必挂载完整 workspace。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Error controller / runtime handler:通过,`2` 个测试文件、`21` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`107` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,只看 `warning` toast 执行 plan;若 E2E 指标显示慢在 render,则转回 Phase 4 render projection。 + +### 2026-05-05:P3 第四十二刀,Agent stream warning toast action 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamWarningController.ts`: + - 新增 `buildAgentStreamWarningToastAction`,把 warning plan 的 toast payload 归一为可执行 action。 + - 新增 `applyAgentStreamWarningToastAction`,统一 `info / warning / error` dispatcher 调用。 +- `agentStreamRuntimeHandler.ts` 的 `warning` 分支继续变薄: + - handler 不再内联 `switch (warningPlan.toast.level)`。 + - handler 只负责 warned key 标记,然后把 toast action 交给 warning controller 执行。 +- `agentStreamWarningController.test.ts` 新增 warning toast action 回归,覆盖 action 构造、null toast 跳过、不同 level 调用对应 dispatcher。 + +主线收益: + +- Phase 3 的 warning 展示行为继续收敛到 current warning controller,handler 不再承担 toast level 分发细节。 +- 后续排查 warning 重复提示、误提示或提示等级不一致时,可以直接测 warning controller,不必进入完整 stream runtime handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Warning controller / runtime handler:通过,`2` 个测试文件、`17` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`109` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +附带门禁修复: + +- `src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts`:为 `knowledgePackOptions` 显式标注 `InputbarKnowledgePackOption[]`,避免 initial selection fallback 的可选 `status` 被数组推断收窄成必填 string。 +- `src/features/knowledge/KnowledgePage.tsx`:移除过期 `getPackTypeLabel` import,保持当前 `getUserFacingPackTypeLabel` 展示路径。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,先盘点 `agentStreamRuntimeHandler` 剩余内部 helper,优先只拆仍影响首字 / 流式错态排查的 current controller plan。 + +### 2026-05-05:P3 第四十三刀,Agent stream error toast action 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamErrorController.ts`: + - 新增 `applyAgentStreamErrorToastPlan`,统一普通 runtime error 的 `warning / error` toast dispatcher 调用。 +- `agentStreamRuntimeHandler.ts` 的普通 `error` 分支继续变薄: + - handler 不再内联 `if (toastPlan.level === "warning")` 判断。 + - handler 只消费 `errorFailurePlan.toast` 并交给 error controller 执行。 +- `agentStreamErrorController.test.ts` 新增 error toast dispatcher 回归,覆盖 rate limit warning 与普通 error 两条分发路径。 + +主线收益: + +- Phase 3 的普通 runtime error 展示行为继续收敛到 current error controller,handler 不再承担 toast level 分发细节。 +- 后续排查 provider error、rate limit、空 final error 的展示差异时,可以直接测 error / completion controller,而不是进入完整 stream handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Error controller / runtime handler:通过,`2` 个测试文件、`22` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`110` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,先盘点 `agentStreamRuntimeHandler` 剩余内部 helper,优先只拆仍影响首字 / 流式错态排查的 current controller plan。 + +### 2026-05-05:P3 第四十四刀,Agent stream missing final failure plan 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamCompletionController.ts`: + - 导出 `AgentStreamMissingFinalReplyPlan`。 + - 新增 `buildAgentStreamMissingFinalReplyFailurePlan`,统一 missing final reply failure 的 `errorMessage / queuedTurnIds / requestLogPayload / toastMessage / usage`。 + - `buildAgentStreamFinalDonePlan` 与 `buildAgentStreamEmptyFinalErrorPlan` 的失败分支改为复用 missing final failure plan。 +- `agentStreamRuntimeHandler.ts` 的 `finalizeMissingFinalReplyFailure` 继续变薄: + - 不再内联 `queuedTurnId ? [queuedTurnId] : []`。 + - 不再内联 `chat_request_error` payload。 + - 不再直接引用空最终回复 toast 常量,只消费 completion controller 产出的 toast message。 +- `agentStreamCompletionController.test.ts` 新增 missing final failure plan 回归,覆盖 queued turn 清理、request log payload、toast message 与 usage 保留。 + +主线收益: + +- Phase 3 的空最终回复失败路径继续收敛到 current completion controller;`final_done` 与 empty-final error 的失败副作用参数现在走同一个计划。 +- 后续排查“模型无最终文本 / 工具有产物但无 summary / 空 final 误报失败”时,可以直接测 completion controller,不必进入完整 stream handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Completion controller / runtime handler:通过,`2` 个测试文件、`21` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`111` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,优先盘点 `markQueuedDraftState` 是否还能以 queued draft controller plan 形式收口。 + +### 2026-05-05:P3 第四十五刀,Agent stream queued draft state plan 收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamQueueController.ts`: + - 新增 `buildAgentStreamQueuedDraftStatePlan`,统一 queued draft 进入排队态时的 message patch、active stream 清理、optimistic item / turn 清理与 sending 状态计划。 + - 继续复用 `buildAgentStreamQueuedDraftMessagePatch` 生成排队 runtime status。 +- `agentStreamRuntimeHandler.ts` 的 `markQueuedDraftState` 继续变薄: + - handler 不再内联 queued draft 的 `clearActiveStreamIfMatch / clearOptimisticItem / clearOptimisticTurn / setIsSending(false)` 决策。 + - handler 只消费 queue controller 产出的状态计划并执行副作用。 +- `agentStreamQueueController.test.ts` 新增 queued draft state plan 回归,覆盖 message patch 和四个状态副作用开关。 + +主线收益: + +- Phase 3 的排队态转换继续收敛到 current queue controller;首页首发或旧会话中遇到 busy queue 时,queued draft 状态语义可独立测试。 +- 后续排查“点击发送后卡在 loading / optimistic 消息残留 / queued draft 没有变为排队态”时,可以直接测 queue controller,不必进入完整 stream handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamQueueController.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" --max-warnings 0 +npm run typecheck -- --pretty false +git diff --check -- "src/lib/activeContentTarget.ts" "src/components/agent/chat/hooks/agentStreamQueueController.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts" "src/features/knowledge/KnowledgePage.tsx" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Queue controller / runtime handler:通过,`2` 个测试文件、`16` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`25` 个测试文件、`112` 个测试通过。 +- ESLint touched files:通过。 +- TypeScript `tsc --noEmit --pretty false`:通过。 +- Diff whitespace check:通过。 + +GUI / E2E 状态: + +- 本刀未进入 Playwright;此前已经确认 MCP profile lock 阻塞仍在,且不能使用 `--isolated` 或 kill 用户 Chrome/MCP。 +- 本刀仍是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI;继续不启动 `verify:gui-smoke`,避免 Rust rebuild 干扰用户观察 CPU / 鼠标繁忙。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,优先盘点 `finishRequestLog` 或 timer cleanup helper 是否还有可测 controller plan。 + +### 2026-05-05:P3 第四十六刀,Agent stream request log finish plan 收口 + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamRequestLogController.ts`: + - 新增 `buildAgentStreamRequestLogFinishPlan`,统一 request log finish 的重复完成 guard、`duration` 计算与 `activityLogger.updateLog` payload 组装。 + - 明确 `shouldUpdate / nextRequestFinished / logId / updatePayload`,让 request log 完成语义可单测。 +- `agentStreamRuntimeHandler.ts` 的 `finishRequestLog` 继续变薄: + - handler 不再内联 `requestLogId`、`requestFinished` 与 `Date.now() - requestStartedAt` 决策。 + - handler 只消费 request log controller 产出的计划,并执行 `activityLogger.updateLog` 副作用。 +- 新增 `agentStreamRequestLogController.test.ts`,覆盖无 log id、已完成去重、success duration、error payload 四类分支。 + +主线收益: + +- Phase 3 的 request log 完成链路继续收敛到 current controller;首字/流式排查时能把“完成态记录是否重复更新”和 runtime event 处理分开测。 +- 后续排查 request log duration 异常、重复完成、错误完成状态不一致时,可以直接测 request log controller,不必进入完整 stream handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm exec -- tsc --noEmit --pretty false --skipLibCheck --target ES2020 --module ESNext --moduleResolution bundler --jsx react-jsx --lib DOM,ES2020 "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" +git diff --check -- "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" +``` + +结果: + +- Request log controller / runtime handler:通过,`2` 个测试文件、`15` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`26` 个测试文件、`116` 个测试通过。 +- ESLint touched files:通过。 +- Targeted TypeScript check:通过。 +- Diff whitespace check:通过。 + +未完成验证: + +- 全量 `npm run typecheck -- --pretty false` 本轮运行超过 `10` 分钟仍无输出;为避免继续占用本机 CPU,已终止本轮自行启动的 `tsc` 进程。上一刀全量 typecheck 有通过记录,本刀额外补了 touched file 的 targeted TypeScript check。 +- 本刀未进入 Playwright;仍按既有规则不使用 `--isolated`,也不 kill 用户 Chrome/MCP。当前改动是纯前端 stream controller 收口,不改 GUI 壳、Tauri command、Bridge、mock 或用户可见 UI。 + +下一刀: + +1. Playwright MCP 可复用后,优先恢复首页首发、旧会话打开、首 token 的真实性能采集。 +2. 如果继续代码拆分,只看 timer cleanup helper;不要再扩大到无关 GUI / Bridge 面。 + +### 2026-05-05:P3 第四十七刀,Agent stream timer schedule plan 收口 + +已完成: + +- 新增 `src/components/agent/chat/hooks/agentStreamTimerController.ts`: + - 新增 `buildAgentStreamTimerClearPlan`,统一 timer clear 的状态计划。 + - 新增 `buildAgentStreamTextRenderTimerSchedulePlan`,统一首个可见文本立即 flush、已有 pending timer 跳过、后续 32ms 低频 flush 的调度决策。 + - 新增 `buildAgentStreamQueuedDraftCleanupTimerSchedulePlan` 与 `buildAgentStreamQueuedDraftCleanupTimerFirePlan`,统一 queued draft cleanup 的旧 timer 清理、1800ms grace 调度与触发时 cleanup guard。 +- `agentStreamRuntimeHandler.ts` 的 timer helper 继续变薄: + - `clearQueuedDraftCleanupTimer` / `clearPendingTextRenderTimer` 不再内联是否清理的判断。 + - `scheduleTextRenderFlush` 不再内联首个可见文本 flush 与 pending timer guard。 + - `scheduleQueuedDraftCleanup` 不再内联 queued draft cleanup 的 schedule/fire guard。 +- 新增 `agentStreamTimerController.test.ts`,覆盖 timer clear、text render flush_now/skip/schedule、queued cleanup schedule/fire 分支。 + +主线收益: + +- Phase 3 的 text render timer 与 queued draft cleanup timer 决策继续收敛到 current controller;首字慢或 queued draft 卡住时可以直接区分“调度策略”与 handler 副作用执行。 +- 后续排查“首字为什么等 32ms / 为什么排队草稿 1800ms 后消失或残留”时,可以直接测 timer controller,不必进入完整 stream handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +待验证: + +- 先跑 timer controller / runtime handler 定向回归。 +- 再跑 Phase 3 stream controller 定向回归、ESLint、targeted TypeScript check 与 diff whitespace check。 + +验证结果: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamTimerController.ts" "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" --max-warnings 0 +npm exec -- tsc --noEmit --pretty false --skipLibCheck --target ES2020 --module ESNext --moduleResolution bundler --jsx react-jsx --lib DOM,ES2020 "src/components/agent/chat/hooks/agentStreamTimerController.ts" "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" +git diff --check -- "src/components/agent/chat/hooks/agentStreamTimerController.ts" "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Timer controller / runtime handler:通过,`2` 个测试文件、`17` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`27` 个测试文件、`122` 个测试通过。 +- ESLint touched files:通过。 +- Targeted TypeScript check:通过。 +- Diff whitespace check:通过。 + +未完成验证: + +- 本刀仍未进入 Playwright;这是纯前端 stream controller 收口,不改变 GUI 可见行为,也不碰 Tauri command / Bridge / mock。 +- 全量 `npm run typecheck` 上一刀已记录超时风险;本刀继续使用 touched file targeted TypeScript check,避免再次长时间占用 CPU。 + +下一刀: + +1. 优先恢复 Playwright MCP 真实性能采集,覆盖首页首发、旧会话打开和首 token 分段。 +2. 若仍需要代码收口,只看 missing final / failed timeline helper 的执行层;不继续扩散到无关 Workspace 或 Bridge 面。 + +### 2026-05-05:P3 第四十八刀,Missing final / failed timeline 执行层计划收口 + +已完成: + +- 继续扩展 `src/components/agent/chat/hooks/agentStreamCompletionController.ts`: + - 新增 `buildAgentStreamMissingFinalReplyFailureSideEffectPlan`,统一 missing final failure 的 pending text timer 清理、failed timeline 标记、queued turn 清理、request log、observer error、toast、active stream 与 listener dispose 执行计划。 +- 继续扩展 `src/components/agent/chat/hooks/agentStreamErrorController.ts`: + - 新增 `buildAgentStreamFailedTimelineStatePlan`,统一 failed timeline 更新所需的 session、pending turn/item、error 与 failedAt 参数。 +- `agentStreamRuntimeHandler.ts` 的失败路径 helper 继续变薄: + - `finalizeMissingFinalReplyFailure` 不再直接读取 failure plan 的全部字段来拼执行语义,而是消费 completion controller 产出的 side-effect plan。 + - `markFailedTimelineState` 不再内联 failed timeline 参数组装,而是消费 error controller 产出的 state plan。 +- 补充 controller 单测: + - `agentStreamCompletionController.test.ts` 覆盖 missing final failure side-effect plan。 + - `agentStreamErrorController.test.ts` 覆盖 failed timeline state plan。 + +主线收益: + +- Phase 3 的失败完成路径继续收敛到 current controller;空 final、普通 error、timeline failed state 的执行参数不再散落在 runtime handler 内。 +- 后续排查“空 final 误报失败 / failed timeline 未落态 / request log 和 toast 不一致”时,可以直接测 completion/error controller,不必进入完整 stream handler。 +- 保留现有行为,不改变 runtime event protocol、Tauri command、Bridge、mock、GUI 壳或用户可见 UI。 + +待验证: + +- 先跑 completion/error controller 与 runtime handler 定向回归。 +- 再跑 Phase 3 stream controller 定向回归、ESLint、targeted TypeScript check 与 diff whitespace check。 + +验证结果: + +```bash +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" +npm exec -- vitest run "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts" "src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts" "src/components/agent/chat/hooks/agentStreamToolEventController.test.ts" "src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts" "src/components/agent/chat/hooks/agentStreamQueueController.test.ts" "src/components/agent/chat/hooks/agentStreamWarningController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts" "src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts" "src/components/agent/chat/hooks/agentStreamInactivityController.test.ts" "src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts" "src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts" "src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts" "src/components/agent/chat/hooks/agentStreamSubmitContext.test.ts" +npx eslint "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/agentStreamTimerController.ts" "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" --max-warnings 0 +npm exec -- tsc --project "/tmp/lime-agentstream-targeted-tsconfig.json" +git diff --check -- "src/components/agent/chat/hooks/agentStreamCompletionController.ts" "src/components/agent/chat/hooks/agentStreamCompletionController.test.ts" "src/components/agent/chat/hooks/agentStreamErrorController.ts" "src/components/agent/chat/hooks/agentStreamErrorController.test.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/agentStreamTimerController.ts" "src/components/agent/chat/hooks/agentStreamTimerController.test.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.ts" "src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts" "docs/roadmap/agentui/conversation-projection-implementation-plan.md" "docs/exec-plans/agentui-implementation-progress.md" +``` + +结果: + +- Completion / error controller / runtime handler:通过,`3` 个测试文件、`34` 个测试通过。 +- Agent stream Phase 3 定向回归:通过,`27` 个测试文件、`124` 个测试通过。 +- ESLint touched files:通过。 +- Targeted TypeScript check:通过;使用 `/tmp/lime-agentstream-targeted-tsconfig.json` 继承仓库 `tsconfig.json` 并额外包含 `src/vite-env.d.ts`,避免 CLI 单文件检查丢失 `@/*` 与 `ImportMeta.env` 类型。 +- Diff whitespace check:通过。 + +未完成验证: + +- 本刀未进入 Playwright;这是纯前端 stream controller 收口,不改变 GUI 可见行为,也不碰 Tauri command / Bridge / mock。 +- 全量 `npm run typecheck` 本轮未重跑;上一刀已记录长时间无输出风险,本刀用贴边界 targeted TypeScript check 验证 touched controller。 + +下一刀: + +1. 优先恢复 Playwright MCP 真实性能采集,覆盖首页首发、旧会话打开和首 token 分段。 +2. 若继续代码收口,先盘点 `agentStreamRuntimeHandler.ts` 剩余 helper 是否真的阻塞 Phase 3;否则进入 E2E 或 Phase 4 render projection 验收。 diff --git a/docs/exec-plans/ai-layered-design-implementation-plan.md b/docs/exec-plans/ai-layered-design-implementation-plan.md new file mode 100644 index 000000000..c51abeca8 --- /dev/null +++ b/docs/exec-plans/ai-layered-design-implementation-plan.md @@ -0,0 +1,281 @@ +# AI 图层化设计实现执行计划 + +> 状态:P3G 图层任务刷新与主流图片模型族能力约束已接入,定向校验通过;GUI smoke 已尝试但被知识库 smoke 阻塞 +> 创建时间:2026-05-05 +> 路线图来源:`docs/roadmap/ai-layered-design/README.md` +> 当前目标:先建立 `LayeredDesignDocument` 的最小 current 协议和纯函数不变量,再逐步接入原生分层生成、Canvas 编辑、单层重生成和导出。 + +## 主目标 + +把 Lime 的 AI 图片生成从“返回一张扁平 PNG”升级为“生成、保存、重新打开并继续编辑的设计工程”: + +```text +用户目标 / @海报 / @配图 + -> Layer Planner + -> Asset Generator + -> LayeredDesignDocument + -> Design Canvas Editor + -> Exporter / Artifact / Evidence +``` + +固定事实源: + +**AI 图层化设计的 current 事实源是 `LayeredDesignDocument`;Canvas Editor、导出、单层重生成和后续拆层都必须读写这份文档。现有 `DocumentCanvas` / `ImageTaskViewer` / `TeamWorkspaceCanvas` 只作为 UI 和交互基础参考,不反向定义设计协议。** + +## 已完成阶段范围 + +已完成: + +1. 新增执行计划并回挂 `docs/exec-plans/README.md`。 +2. 新增 `src/lib/layered-design/` 的 P1 最小协议。 +3. 用纯函数保证图层排序、默认值归一化、单层资产替换和 transform 更新不变量。 +4. 补定向单测,证明预览 PNG 只是导出投影,不是设计事实源。 +5. 新增 `DesignCanvas` 最小可见 UI,并把 `canvas:design` Artifact 打开链路接入 Workspace Canvas。 +6. 新增本地 Layer Planner seed:从 prompt 生成可编辑图层计划,不调用图片模型。 +7. 新增 `LayeredDesignDocument -> canvas:design Artifact` bridge,让 prompt seed 能进入当前 Artifact / Canvas 主链。 +8. 新增 provider-agnostic 资产生成 seam:从图片图层创建生成请求,并把 provider 输出写回目标图层。 +9. 新增 `LayeredDesignAssetGenerationRequest -> create_image_generation_task_artifact` adapter,复用现有图片任务主链。 +10. 在 `DesignCanvas` 增加“生成全部图片层 / 重生成当前层”入口,提交任务后回写 `LayeredDesignDocument.editHistory`。 +11. 从 `LayeredDesignDocument.editHistory` 恢复已提交图片任务,并通过现有 `get_media_task_artifact` 刷新成功结果回写目标图层。 +12. 借鉴 Codex `imagegen` 的模型能力约束与透明图层 chroma-key 后处理策略,扩展为主流图片模型族 registry 并沉到 `runtimeContract.layered_design`,不新增 Python CLI 旁路。 + +仍未做: + +1. 不新增 Tauri 命令、Bridge、mock 或 provider adapter。 +2. 不直接调用 `gpt-image-2` / Gemini / Flux;当前只规范 request contract 与现有 media task artifact 写回。 +3. 不引入 Fabric 运行时。 +4. 不实现 PSD、mask、inpaint、OCR 或扁平图拆层。 +5. 不宣称 GUI 完整可交付;还需要补 `verify:gui-smoke`。 + +## 阶段计划 + +### P0:文档与边界 + +状态:已完成 proposal 文档,进入 implementation 跟踪。 + +产物: + +1. `docs/research/ai-layered-design/` +2. `docs/roadmap/ai-layered-design/` +3. `docs/roadmap/creaoai/` 与 AI 图层化设计边界说明 + +完成标准: + +1. 文档说明为什么不先训练模型。 +2. 文档固定 `LayeredDesignDocument` 是 current 事实源。 +3. 文档说明 Lovart 类“可调整图层”来自工程编排,不是单个生图模型。 + +### P1:LayeredDesignDocument 最小协议 + +状态:已完成 P1 第一刀。 + +产物: + +1. `src/lib/layered-design/types.ts` +2. `src/lib/layered-design/document.ts` +3. `src/lib/layered-design/index.ts` +4. `src/lib/layered-design/document.test.ts` + +完成标准: + +1. 创建文档时按 `zIndex` 稳定排序。 +2. 普通文案默认是 `TextLayer`,不是烘焙图片。 +3. 单层替换 asset 不改变 layer id、transform、zIndex、visible、locked。 +4. normalize 能填充 `visible`、`locked`、`opacity`、`rotation` 等缺省值。 +5. preview 只作为导出投影,不进入 `layers[]`。 + +### P2:Design Canvas Editor + +状态:已完成最小可见 UI 与 Artifact 接入口。 + +计划: + +1. 在现有 Workspace / CanvasWorkbench 壳层下新增 `DesignCanvas`。 +2. 第一版用 DOM/CSS absolute layers 支持选择、拖动、缩放、隐藏、锁定和 zIndex。 +3. 图层栏和属性栏只读写 `LayeredDesignDocument`。 +4. Fabric 只作为后续更复杂选择框、旋转和导出的候选实现,不作为首刀依赖。 +5. 旧 `canvas:poster / canvas:music / canvas:novel / canvas:script` 不再归一到现役画布;需要图层化图片设计时必须使用 `canvas:design`。 + +### P3:原生分层生成与单层重生成 + +状态:P3G 已完成本地 seed、Artifact bridge、provider-agnostic 资产生成 seam、现有 image task artifact API adapter、`DesignCanvas` 生成入口、任务结果刷新写回,以及 OpenAI / Gemini Imagen / Flux / Stable Diffusion / Ideogram / Recraft / Seedream / CogView / Midjourney 等主流模型族能力 request contract;GUI smoke 未完成。 + +计划: + +1. Layer Planner 输出 5-8 个可编辑层。 +2. Prompt seed 生成 `canvas:design` Artifact,直接进入 `DesignCanvas`。 +3. Asset Generator 通过 provider capability seam 调用图片模型。 +4. 每个 ImageLayer 绑定 asset、prompt、provider、modelId。 +5. 单层重生成只替换该层 asset,并写入 edit history。 + +### P4:扁平图拆层与专业导出 + +状态:未开始。 + +计划: + +1. 上传扁平图后识别主体、文字、Logo、背景候选层。 +2. 通过 mask / matting / clean plate 建立可编辑文档。 +3. 先稳定导出 PNG + JSON + assets,再试点 PSD-like 投影。 + +## 已完成的不变量 + +当前已证明: + +1. `LayeredDesignDocument` 类型是唯一 current 设计事实源。 +2. `GeneratedDesignAsset` 只是资产记录,只有被 layer 引用才进入图层栏语义。 +3. `DesignPreviewProjection` 只是当前导出预览的投影,编辑会把它标记为 stale。 +4. 所有编辑函数保持不可变更新,避免 Canvas 状态绕过文档。 +5. Prompt seed 里的普通文案保持 `TextLayer`,不被烘焙成图片。 +6. Prompt seed 里的图片资产只是 `plannedOnly` 占位,不隐式调用 provider。 +7. `canvas:design` 是图层化设计唯一 current Artifact 类型;旧 `canvas:poster` 不再参与归一。 +8. 资产生成 seam 只选择图片 / effect 图层,跳过 `TextLayer`,并允许单层重生成重新进入 provider seam。 +9. 图层生成任务复用现有 `create_image_generation_task_artifact`,通过 `slotId / targetOutputId / targetOutputRefId / anchorHint` 保留 document/layer/asset 关联,不新增旧 poster 协议。 +10. Canvas UI 提交任务后必须回写 `LayeredDesignDocument.editHistory`;如果任务输出已经包含图片结果,立即写回目标图片层 asset,文字层保持可编辑。 +11. `asset_generation_requested` 必须记录 `taskId / taskPath / taskStatus`,后续打开同一设计工程时可恢复等待写回的图片任务。 +12. 主流图片模型族必须通过统一 capability registry 判断尺寸策略、透明策略、编辑/mask/reference 能力;未知模型走 `generic + provider_passthrough`,不阻塞任务创建。 +13. `gpt-image-2 / gpt-images-2` 图层任务必须归一到 16 倍数尺寸与合法像素范围;透明图层只记录 `chroma_key_postprocess` 策略,不把 Python CLI 变成 Lime current 主链。 + +## 验证策略 + +当前改动横跨 TypeScript 协议、Artifact adapter 和 Workspace Canvas UI。未触及 Tauri 命令、Bridge、mock、配置或版本。 + +最低校验: + +```bash +npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/lib/layered-design/planner.test.ts" "src/lib/layered-design/artifact.test.ts" "src/lib/layered-design/generation.test.ts" "src/lib/layered-design/imageModelCapabilities.test.ts" "src/lib/layered-design/imageTasks.test.ts" "src/components/workspace/design/DesignCanvas.test.tsx" "src/components/artifact/canvasAdapterUtils.test.ts" "src/components/artifact/ArtifactRenderer.ui.test.tsx" +npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/artifact/canvasAdapterUtils.ts" "src/components/artifact/canvasAdapterUtils.test.ts" --max-warnings 0 +npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit +``` + +GUI 主路径可交付前还要追加: + +```bash +npm run verify:local +npm run verify:gui-smoke +``` + +后续进入 Tauri 命令 / provider / mock 时再追加: + +```bash +npm run test:contracts +npm run governance:legacy-report +``` + +## 进度日志 + +### 2026-05-05 + +- 已创建本执行计划,承接 `docs/roadmap/ai-layered-design/`。 +- 当前阶段固定为 P1 第一刀:先落 `LayeredDesignDocument` 协议和纯函数测试。 +- 本轮不接 GUI、provider、Tauri 命令或 Fabric,避免在事实源未稳定前扩展平行实现。 +- 已新增 `src/lib/layered-design/types.ts`、`src/lib/layered-design/document.ts`、`src/lib/layered-design/index.ts` 与 `src/lib/layered-design/document.test.ts`。 +- 已实现 `LayeredDesignDocument`、`DesignCanvas`、`DesignLayer`、`GeneratedDesignAsset`、`LayerEditRecord`、创建 / normalize / 排序 / 单层资产替换 / transform 更新等 P1 最小协议。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/document.test.ts"`,5 个定向测试覆盖 zIndex 排序、TextLayer、单层替换不变量、默认值归一化和 preview 投影语义。 +- 已通过 `npm exec -- eslint "src/lib/layered-design/**/*.ts" --max-warnings 0` 与定向 `tsc`,确认本轮新增协议文件静态检查通过。 +- 已执行 `npm run typecheck`,当前失败来自未跟随本轮修改的未跟踪文件 `src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts`:测试构造的 `output_schema_runtime` 缺少 `source` 与 `strategy` 字段;本轮未修改该区域,未纳入本阶段修复范围。 + +### 2026-05-05 P2 最小 Canvas UI + +- 已新增 `src/components/workspace/design/DesignCanvas.tsx` 与 `src/components/workspace/design/types.ts`,提供图层栏、画布预览、属性栏、选择、移动、显隐、锁定、zIndex 调整和 zoom 控制。 +- 已把 `CanvasStateUnion` 扩展为 `document / video / design`,并在 `CanvasFactory` 与 `workbenchCanvas` current 网关接入 `DesignCanvas`。 +- 已新增 `canvas:design` Artifact 类型和 `.json` 默认扩展名;Artifact adapter 可从 `LayeredDesignDocument` JSON 创建 design canvas state,并把 design canvas state 序列化回同一文档 JSON。 +- 已清理旧 Canvas 类型别名:`canvas:poster / canvas:music / canvas:novel / canvas:script` 不再归一到 `canvas:document / canvas:video`,避免旧专用主题继续伪装成新设计工程主线。 +- 已同步 `src/components/artifact/README.md`,明确旧 Canvas 别名不再是 compat 主链;图层化图片设计必须走 `canvas:design + LayeredDesignDocument`。 +- 已补 `src/components/workspace/design/DesignCanvas.test.tsx`、`src/components/artifact/canvasAdapterUtils.test.ts` 与 `canvasUtils` 回归,锁定 UI 操作必须回写文档而不是只改 DOM。 +- 已修正 `ArtifactRenderer` 的 Canvas 分发顺序:Canvas 类型先委托给 `CanvasAdapter`,不再要求先注册轻量 renderer;`canvas:design` 因此能从 Artifact 直接打开 `DesignCanvas`。 +- 已补 `src/components/artifact/ArtifactRenderer.ui.test.tsx` 回归,覆盖 `canvas:design` 从 Artifact 直接渲染到图层设计画布。 +- 已同步 `ArtifactToolbar` MIME:`canvas:design` 导出内容按 `application/json` 处理。 +- 已补 `src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts`,覆盖 design canvas 的空态判断和 `LayeredDesignDocument` JSON 同步。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/components/workspace/design/DesignCanvas.test.tsx" "src/components/artifact/canvasAdapterUtils.test.ts" "src/components/workspace/canvas/canvasUtils.test.ts"`。 +- 已通过 `npm exec -- vitest run "src/lib/artifact/parser.test.ts" "src/lib/artifact/registry.test.ts" "src/components/artifact/ArtifactRenderer.test.ts" "src/components/artifact/ArtifactToolbar.test.ts"`。 +- 已通过 `npm exec -- vitest run "src/components/artifact/ArtifactRenderer.ui.test.tsx" "src/components/artifact/canvasAdapterUtils.test.ts" "src/components/workspace/design/DesignCanvas.test.tsx"`。 +- 已通过 `npm exec -- vitest run "src/components/artifact/ArtifactToolbar.test.ts" "src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts" "src/components/artifact/ArtifactRenderer.ui.test.tsx"`。 +- 已通过 `npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/workspace/design/**/*.{ts,tsx}" "src/components/workspace/canvas/canvasUtils.ts" "src/components/workspace/canvas/canvasUtils.test.ts" "src/components/workspace/canvas/CanvasFactory.tsx" "src/components/artifact/canvasAdapterUtils.ts" "src/components/artifact/canvasAdapterUtils.test.ts" "src/lib/artifact/types.ts" "src/lib/artifact/parser.ts" "src/components/artifact/ArtifactRenderer.test.ts" "src/components/artifact/ArtifactToolbar.test.ts" "src/components/agent/chat/workspace/generalWorkbenchHelpers.ts" --max-warnings 0`。 +- 已通过增量 ESLint:`npm exec -- eslint "src/components/artifact/ArtifactToolbar.tsx" "src/components/artifact/ArtifactToolbar.test.ts" "src/components/agent/chat/workspace/generalWorkbenchHelpers.ts" "src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts" "src/components/artifact/ArtifactRenderer.tsx" "src/components/artifact/ArtifactRenderer.ui.test.tsx" --max-warnings 0`。 +- 已通过定向 TypeScript 检查:`npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit`,范围包含 `src/vite-env.d.ts`、layered-design、DesignCanvas、CanvasFactory、CanvasAdapter、ArtifactRenderer、artifact 类型/解析和 Workbench 同步 helper。 +- 已通过 `git diff --check` 相关文件检查。 +- 已尝试 `npm run typecheck`,120 秒内未完成并被中止;本轮定向测试和 ESLint 已覆盖新增边界,完整 typecheck 需要等当前工作区并发校验任务收口后补跑。 + +### 2026-05-05 P3A 本地 Layer Planner seed 与 Artifact bridge + +- 已新增 `src/lib/layered-design/planner.test.ts`,锁定 prompt seed 会生成背景、主体、氛围特效、主标题、副标题、CTA 底和 CTA 文案等 7 个可编辑层。 +- 已确认 prompt seed 中普通文案保持 `TextLayer`,图片资产仅为 `plannedOnly` 占位,`src` 为空且不写 `provider / modelId`,不会假装已经调用 `gpt-image-2`、Gemini 或其他模型。 +- 已新增 `src/lib/layered-design/artifact.ts`,提供 `createLayeredDesignArtifact` 与 `createLayeredDesignArtifactFromPrompt`,统一生成 `canvas:design` Artifact。 +- 已通过 `createLayeredDesignArtifactFromPrompt -> createCanvasStateFromArtifact -> DesignCanvasState` 回归,证明 prompt seed 能进入当前 Artifact / Canvas 主链。 +- 已从 `src/lib/layered-design/index.ts` 导出 planner 与 artifact bridge,后续主链入口不需要绕到旧 poster / image viewer。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/lib/layered-design/planner.test.ts" "src/lib/layered-design/artifact.test.ts" "src/components/artifact/canvasAdapterUtils.test.ts"`,共 13 个定向测试。 +- 已通过 `npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/artifact/canvasAdapterUtils.ts" "src/components/artifact/canvasAdapterUtils.test.ts" --max-warnings 0`。 +- 已通过定向 TypeScript 检查:`npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit`。 +- 当前仍未跑 `npm run verify:gui-smoke`,因此 GUI 主路径还不能宣称完整可交付。 + +### 2026-05-05 P3B provider-agnostic 资产生成 seam + +- 已新增 `src/lib/layered-design/generation.ts`,提供 `createLayeredDesignAssetGenerationPlan`、`createSingleLayerAssetGenerationRequest` 与 `applyLayeredDesignGeneratedAsset`。 +- 资产生成计划只选择 `ImageLayer / EffectLayer`,跳过 `TextLayer`,确保普通文案继续留在可编辑图层而不是被送进生图模型。 +- 默认生成计划只请求空 `src` 或 `plannedOnly` 资产;单层重生成请求允许已生成资产再次进入 provider seam。 +- 写入 provider 输出时只替换目标图片层的 asset,并把该层标记为 `source: "generated"`;其他图层和文字层保持不变。 +- 该 seam 不调用 `gpt-image-2`、Gemini 或本地模型,只定义 current 文档如何对接后续 provider adapter。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/lib/layered-design/planner.test.ts" "src/lib/layered-design/artifact.test.ts" "src/lib/layered-design/generation.test.ts" "src/components/artifact/canvasAdapterUtils.test.ts"`,共 17 个定向测试。 +- 已通过 `npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/artifact/canvasAdapterUtils.ts" "src/components/artifact/canvasAdapterUtils.test.ts" --max-warnings 0`。 +- 已通过定向 TypeScript 检查:`npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit`。 +- 下一刀应接真实 provider adapter 或 UI 单层重生成入口,不能回到旧 `poster_generate / ImageTaskViewer`。 + +### 2026-05-05 P3C image task adapter + +- 已新增 `src/lib/layered-design/imageTasks.ts`,把 `LayeredDesignAssetGenerationRequest` 映射到现有 `createImageGenerationTaskArtifact` 前端 API。 +- 映射后的图片任务使用 `entrySource: "layered_design_canvas"`、`modalityContractKey: "image_generation"`、`routingSlot: "image_generation_model"`,不新增 Tauri 命令、不新增 mock、不回到旧 poster 协议。 +- 图层关联通过现有字段持久化:`slotId=layerId`、`targetOutputId=assetId`、`targetOutputRefId=generationRequest.id`、`anchorHint=layered-design::`。 +- 已新增 `createGeneratedDesignAssetFromImageTaskOutput`,能从成功的 image task result 创建 `GeneratedDesignAsset`,并保留 `provider / model / taskId / taskPath / layerId / documentId`。 +- 已新增 `applyLayeredDesignImageTaskOutput`,可把成功任务输出写回目标图层;文字层仍保持 `TextLayer` 可编辑。 +- 已补 `src/lib/layered-design/imageTasks.test.ts`,覆盖请求映射、批量提交、任务输出转 asset、任务输出写回文档,并断言不出现 `poster_generate / canvas:poster`。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/lib/layered-design/planner.test.ts" "src/lib/layered-design/artifact.test.ts" "src/lib/layered-design/generation.test.ts" "src/lib/layered-design/imageTasks.test.ts" "src/components/artifact/canvasAdapterUtils.test.ts"`,共 21 个定向测试。 +- 已通过 `npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/artifact/canvasAdapterUtils.ts" "src/components/artifact/canvasAdapterUtils.test.ts" --max-warnings 0`。 +- 已通过定向 TypeScript 检查:`npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit`。 +- 当前仍未跑 `npm run test:contracts` 与 `npm run verify:gui-smoke`;本轮没有新增命令面,但下一轮接 UI 或真实轮询后必须补 GUI 主路径验证。 + +### 2026-05-05 P3D DesignCanvas 图层生成入口 + +- 已在 `src/components/workspace/design/DesignCanvas.tsx` 增加“生成全部图片层”和“重生成当前层”入口,页面类型属于宽工作台,按钮沿用深色主按钮 + 白底描边次按钮层级。 +- `DesignCanvas` 生成入口只调用 `createLayeredDesignImageTaskArtifacts` current adapter;没有接 `poster_generate`、`canvas:poster` 或 `ImageTaskViewer`。 +- 提交任务后通过 `recordLayeredDesignImageTaskSubmissions` 回写 `LayeredDesignDocument.editHistory`,保证 UI 操作不只停在 DOM 状态。 +- 如果任务输出已经包含图片结果,`DesignCanvas` 会立刻用 `applyLayeredDesignImageTaskOutput` 写回目标图片层 asset;文字层继续保持 `TextLayer`。 +- `CanvasFactory` 已向 design canvas 透传 `projectRootPath / projectId / contentId`,`useWorkspaceCanvasSceneRuntime` 使用当前 workspace root 作为图片任务根目录。 +- 已补 `src/components/workspace/design/DesignCanvas.test.tsx` 回归,覆盖全部生成、单层重生成、任务请求字段、edit history 回写、任务结果写回图层和旧 poster 文本不回流。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/lib/layered-design/planner.test.ts" "src/lib/layered-design/artifact.test.ts" "src/lib/layered-design/generation.test.ts" "src/lib/layered-design/imageTasks.test.ts" "src/components/workspace/design/DesignCanvas.test.tsx" "src/components/artifact/canvasAdapterUtils.test.ts" "src/components/artifact/ArtifactRenderer.ui.test.tsx"`,共 34 个定向测试。 +- 已通过 `npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/workspace/design/**/*.{ts,tsx}" "src/components/workspace/canvas/CanvasFactory.tsx" "src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx" --max-warnings 0`。 +- 已通过定向 TypeScript 检查:`npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit`。 +- 当前仍未跑 `npm run verify:gui-smoke`;下一刀应补任务轮询 / 结果自动写回后跑 GUI 主路径验证。 + +### 2026-05-05 P3E 图层任务恢复与刷新写回 + +- 已扩展 `LayerEditRecord`,让 `asset_generation_requested` 记录 `taskId / taskPath / taskStatus`,避免任务提交后只能依赖当前 React 内存状态。 +- 已新增 `listPendingLayeredDesignImageTasks`,从 `LayeredDesignDocument.editHistory` 恢复仍等待写回的图片任务;如果同一图层已有后续 `asset_replaced`,旧任务会被视为已关闭。 +- 已新增 `refreshLayeredDesignImageTaskResults`,复用现有 `getMediaTaskArtifact` / `get_media_task_artifact` 刷新图片任务结果,成功时只替换目标图片层 asset。 +- 已在 `DesignCanvas` 增加“刷新生成结果”入口;页面仍属于宽工作台,主生成按钮与刷新按钮保持深色主按钮 + 白底描边次按钮层级,不引入新的视觉体系。 +- 已补 `src/lib/layered-design/imageTasks.test.ts` 与 `src/components/workspace/design/DesignCanvas.test.tsx` 回归,覆盖 pending 任务恢复、刷新调用、成功结果写回和文字层保持可编辑。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/imageTasks.test.ts" "src/components/workspace/design/DesignCanvas.test.tsx"`,共 13 个定向测试。 +- 当前仍未跑 `npm run verify:gui-smoke`,GUI 主路径还不能宣称完整可交付。 + +### 2026-05-05 P3F 借鉴 Codex imagegen 的模型能力约束 + +- 已只读分析 `/Users/coso/Documents/dev/rust/codex/codex-rs/skills/src/assets/samples/imagegen/`,确认适合借鉴的是模型能力约束、透明图层后处理策略和多资产生成纪律,不适合直接引入 Python CLI 旁路。 +- 已新增 `src/lib/layered-design/imageModelCapabilities.ts`,把 `gpt-image-2 / gpt-images-2` 的 16 倍数、最大边、像素范围、长短边比例和透明背景限制沉到纯函数。 +- `createLayeredDesignImageTaskRequest` 现在会在指定 `gpt-image-2 / gpt-images-2` 时归一任务尺寸,并把原始尺寸、任务尺寸、alpha 策略写入 `runtimeContract.layered_design`。 +- 透明图层当前只记录 `chroma_key_postprocess` 策略和默认 key color,不直接把 Codex `scripts/image_gen.py` 或 chroma-key prompt 接成产品主链;后续应在 media task worker 内做本地 post-process。 +- 已补 `src/lib/layered-design/imageModelCapabilities.test.ts` 与 `src/lib/layered-design/imageTasks.test.ts` 回归,证明 request contract 仍是 `image_generation`,没有新增旧 `poster_generate / canvas:poster` 协议。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/imageModelCapabilities.test.ts" "src/lib/layered-design/imageTasks.test.ts"`,共 11 个定向测试。 + +### 2026-05-05 P3G 主流图片模型族 capability registry + +- 已把上一刀的 `gpt-image-2` 单点判断扩展为主流模型族 registry:`openai-gpt-image-2`、`openai-gpt-image`、`openai-dalle`、`google-imagen`、`flux`、`stable-diffusion`、`ideogram`、`recraft`、`seedream`、`cogview`、`midjourney` 与 `generic`。 +- `LayeredDesignImageModelCapability` 现在记录 `sizePolicy`、`allowedSizes`、`supportsNativeTransparency`、`supportsImageEdit`、`supportsMask`、`supportsReferenceImages` 等能力;未知模型走 `generic + provider_passthrough`,不阻塞现有 provider routing。 +- 尺寸策略已收敛为四类:`flexible_pixels`、`allowed_sizes`、`multiple_pixels`、`provider_passthrough`;OpenAI legacy / DALL-E 会选最接近允许尺寸,Stable Diffusion 会按 64 倍数归一,Flux 会按最大像素做保守缩放。 +- `createLayeredDesignImageTaskRequest` 继续只复用现有 `create_image_generation_task_artifact`,但会把模型族、provider、size policy、透明策略和编辑/mask/reference 能力写入 `runtimeContract.layered_design`。 +- 已补 `src/lib/layered-design/imageModelCapabilities.test.ts`,覆盖 `gpt-image-1.5`、`flux-pro`、`stable-diffusion-xl`、`seedream-4.0` 等非 gpt-image-2 模型族,避免当前主线绑定单一模型。 +- 已通过 `npm exec -- vitest run "src/lib/layered-design/imageModelCapabilities.test.ts" "src/lib/layered-design/imageTasks.test.ts"`,共 12 个定向测试。 +- 已修正 `LayeredDesignImageRuntimeContract` 类型,让它可直接写入现有图片任务 `runtimeContract: Record`,不新增任务协议或桥接命令。 +- 已通过 P3G 汇总回归:`npm exec -- vitest run "src/lib/layered-design/document.test.ts" "src/lib/layered-design/planner.test.ts" "src/lib/layered-design/artifact.test.ts" "src/lib/layered-design/generation.test.ts" "src/lib/layered-design/imageModelCapabilities.test.ts" "src/lib/layered-design/imageTasks.test.ts" "src/components/workspace/design/DesignCanvas.test.tsx" "src/components/artifact/canvasAdapterUtils.test.ts" "src/components/artifact/ArtifactRenderer.ui.test.tsx"`,共 43 个定向测试。 +- 已通过 P3G 定向 ESLint:`npm exec -- eslint "src/lib/layered-design/**/*.ts" "src/components/workspace/design/**/*.{ts,tsx}" "src/components/workspace/canvas/CanvasFactory.tsx" "src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx" --max-warnings 0`。 +- 已通过定向 TypeScript 检查:`npm exec -- tsc -p "/tmp/lime-layered-design-tsconfig.json" --noEmit`。 +- 已尝试 `npm run verify:gui-smoke`;workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface 与 agent-runtime-tool-surface-page 已通过,但 `smoke:knowledge-gui` 在打开知识库入口时失败:当前页面按钮暴露为“项目资料 / 打开项目资料 / 打开资料中枢”,没有命中 smoke 期望的 `ariaLabel="知识库"`。该阻塞不来自 AI 图层化设计代码,但在修复 smoke 入口前,整条 GUI smoke 仍不能作为通过结论。 diff --git a/docs/exec-plans/creaoai-capability-authoring-p1a-plan.md b/docs/exec-plans/creaoai-capability-authoring-p1a-plan.md new file mode 100644 index 000000000..e4bc19800 --- /dev/null +++ b/docs/exec-plans/creaoai-capability-authoring-p1a-plan.md @@ -0,0 +1,152 @@ +# CreoAI Capability Authoring P1A 执行计划 + +> 状态:P1A 完成,已通过 GUI smoke +> 创建时间:2026-05-05 +> 路线图来源:`docs/roadmap/creaoai/README.md`、`docs/roadmap/creaoai/coding-agent-layer.md`、`docs/research/pi-mono-coding-agent/README.md` +> 当前目标:实现 P1A 最小模块,让 Lime 能创建、存储、查看未验证 `Capability Draft`,并证明未验证草案不会进入默认 tool surface、不会自动注册、不会自动执行。 + +## 主目标 + +把 CreoAI 的 Coding Agent 启发收敛为 Lime current 主链里的 **Capability Authoring Agent / Skill Forge draft store**: + +```text +用户目标 + -> capability generation request + -> workspace-local draft store + -> unverified draft manifest + -> frontend API gateway + -> Workspace / Skills 工作台 draft review + -> 后续 P1B / P2 verification gate +``` + +固定宗旨: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 本轮范围 + +本轮做: + +1. 后端模块化 draft store。 + - 独立 Rust domain 模块,负责 manifest、路径 guard、文件清单和状态。 + - Tauri command 只做薄适配,不承载业务规则。 +2. 前端模块化 API 网关。 + - 新增 `src/lib/api/capabilityDrafts.ts`,组件不直接裸 `invoke`。 +3. 前端 UI 最小 review surface。 + - 优先接到现有 `SkillsWorkspacePage` 或相邻 feature 模块。 + - 展示 draft 名称、目标、状态、权限摘要、文件清单和“未验证不可运行”。 +4. 测试与 smoke。 + +- 后端路径 guard / manifest 单测。 +- 前端 API normalization 单测。 +- UI 状态回归:unverified draft 不显示运行/注册动作。 +- 契约检查:前端调用、Rust 注册、治理目录册、mock 保持一致。 + +本轮不做: + +1. 不实现完整 Coding Agent。 +2. 不实现 verification gate。 +3. 不注册 workspace-local skill。 +4. 不接 automation job。 +5. 不开放 full shell、依赖安装或外部写操作。 +6. 不新增平行 runtime、scheduler、evidence pack。 + +## 模块边界 + +### 后端 + +推荐结构: + +```text +src-tauri/src/commands/capability_draft_cmd.rs +src-tauri/src/services/capability_draft_service.rs +``` + +设计原则: + +1. `capability_draft_cmd.rs` 只负责 Tauri 参数 / 返回值适配。 +2. `capability_draft_service.rs` 负责业务规则、路径 guard、manifest 读写。 +3. 文件事实源暂定 workspace-local:`.lime/capability-drafts//manifest.json`。 +4. 写入范围必须限制在 draft root 内,不允许路径逃逸。 +5. 状态首期只允许 `unverified / failed_self_check`,不暴露运行能力。 + +### 前端 + +推荐结构: + +```text +src/lib/api/capabilityDrafts.ts +src/features/capability-drafts/ + domain/ + components/ + CapabilityDraftPanel.tsx +``` + +设计原则: + +1. API 网关统一封装命令名、参数与 normalization。 +2. domain 模块负责状态文案、权限文案和按钮可见性。 +3. 组件只做展示与用户操作,不拼接命令参数。 +4. UI 作为工作台信息面板,不做独立花哨页面。 +5. `unverified` 状态只允许查看、继续修复、丢弃;不允许运行和创建任务。 + +## 分阶段实施 + +### P1A-0:盘点与落 plan + +- [x] 新增本执行计划。 +- [x] 盘点现有 skill scaffold、Skills 工作台、Tauri command 注册、mock 和治理目录册。 +- [x] 确认不复用 `create_skill_scaffold_for_app`,新增独立 capability draft 命令,避免未验证 draft 进入 Skill reload 主链。 + +### P1A-1:后端 draft store + +- [x] 新增 manifest 类型和状态枚举。 +- [x] 新增 draft root 路径解析与 path escape guard。 +- [x] 新增 create/list/get 命令。 +- [x] 新增 Rust 单测。 + +### P1A-2:前端 API 与 domain + +- [x] 新增 `capabilityDraftsApi`。 +- [x] 新增状态 / 权限 / 可操作性 domain helper。 +- [x] 新增 API normalization 单测。 + +### P1A-3:Workspace UI review + +- [x] 在 Skills 工作台或相邻模块加入 draft review panel。 +- [x] 展示 `unverified` 隔离语义。 +- [x] 补 UI 回归测试。 + +### P1A-4:试跑与验收 + +- [x] 用 Rust service 定向测试创建一个只读 draft。 +- [x] 确认 manifest 与文件清单写入 draft root。 +- [x] 确认 UI 能看到 draft。 +- [x] 确认未验证 draft 不进入默认 tool surface。 +- [x] 通过 DevBridge 真实调用 `capability_draft_create/list/get`。 +- [x] 运行定向测试、`npm run test:contracts`。 +- [x] `npm run verify:gui-smoke` 全绿通过。 + +## 验收标准 + +1. 能创建一个 `unverified` capability draft。 +2. draft manifest 包含目标、来源、权限摘要、文件清单和状态。 +3. 路径逃逸被拒绝。 +4. 未验证 draft 不可运行、不可注册、不可绑定 automation job。 +5. 前后端通过单一 API / command 主链连接。 +6. 契约检查通过。 + +## 执行记录 + +### 2026-05-05 + +- 已创建执行计划,固定本轮只做 P1A draft store + review surface,不进入 P2/P3/P4。 +- 已完成后端模块:`capability_draft_service` 作为文件事实源,`capability_draft_cmd` 只做 Tauri 薄适配,新增 `capability_draft_create/list/get`。 +- 已完成前端模块:`capabilityDraftsApi`、`CapabilityDraftPanel` 与 presentation helper,接入 `SkillsWorkspacePage` 右侧工作台。 +- 已同步命令边界:`runner.rs`、DevBridge dispatcher、`agentCommandCatalog.capabilityDraftCommands`、`mockPriorityCommands` 的 bridge-truth 列表、`defaultMocks`。 +- 已试跑模块功能:Rust 定向测试真实创建 draft root、写入 `SKILL.md` 与 `manifest.json`、list/get 回读,并覆盖路径逃逸拒绝。 +- 已验证:前端定向测试 36 个通过,`cargo test ... capability_draft` 通过,`npm run test:contracts` 通过,`npm run typecheck` 通过,`cargo fmt --check` 通过。 +- 已通过 DevBridge smoke:在临时 workspace 下真实调用 `capability_draft_create/list/get`,生成 `unverified` draft,落盘 `SKILL.md` 与 `workflow/README.md`,list/get 回读一致。 +- 首次执行 `npm run verify:gui-smoke` 时,前序 `bridge health`、`workspace-ready`、`browser-runtime`、`site-adapters`、`agent-service-skill-entry`、`agent-runtime-tool-surface`、`agent-runtime-tool-surface-page` 已通过;当时失败在既有 `smoke:knowledge-gui` 的“知识库总览加载”等待,页面仍停留首页,未命中本轮新增的 Capability Draft 命令、mock 或 Skills 工作台 review surface。 +- 已定向复跑 `npm run smoke:knowledge-gui -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --invoke-url http://127.0.0.1:3030/invoke --timeout-ms 600000 --interval-ms 1000` 并通过;前次失败判断为运行中前端 / Tauri 会话抖动,不需要修改知识库代码。 +- 已重跑 `npm run verify:gui-smoke` 并全绿通过,P1A 达到 Lime GUI 产品交付门槛。 diff --git a/docs/exec-plans/creaoai-capability-discovery-p3b-plan.md b/docs/exec-plans/creaoai-capability-discovery-p3b-plan.md new file mode 100644 index 000000000..f7a499c66 --- /dev/null +++ b/docs/exec-plans/creaoai-capability-discovery-p3b-plan.md @@ -0,0 +1,125 @@ +# CreoAI Capability Discovery P3B 执行计划 + +> 状态:进行中 +> 创建时间:2026-05-05 +> 前置计划:`docs/exec-plans/creaoai-capability-registration-p3-plan.md` +> 路线图来源:`docs/roadmap/creaoai/implementation-plan.md`、`docs/aiprompts/skill-standard.md`、`docs/aiprompts/commands.md` +> 当前目标:让 P3A 已注册到当前 workspace 的 Agent Skill 包可被产品层发现和审计,但仍不进入默认 runtime tool surface。 + +## 主目标 + +把 P3A 的文件注册事实推进到最小可见目录闭环: + +```text +/.agents/skills// + -> require .lime/registration.json provenance + -> inspect Agent Skills package + -> workspace registered skill catalog projection + -> Skills 工作台只读展示 + -> 后续 runtime gate / Query Loop binding +``` + +固定宗旨: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 本轮最小切口 + +本轮只做 **workspace-local registered skill discovery**: + +1. 后端新增 workspace 显式入参的已注册能力发现命令。 +2. 只扫描 `/.agents/skills`,不再依赖进程 `cwd`。 +3. 只返回带 `.lime/registration.json` 的 P3A 注册能力,不把任意项目 skill 都混进 CreoAI 生成链。 +4. 返回 Agent Skills 标准检查摘要、资源摘要、权限摘要、来源 draft / verification report。 +5. 前端 Skills 工作台展示“已注册但待运行接入”的只读卡片。 + +本轮明确不做: + +1. 不调用 `AsterAgentState::reload_lime_skills()`。 +2. 不修改 `get_local_skills_for_app` 的默认语义。 +3. 不把 workspace generated skill 合并进 `useSkills("lime")` 的已安装方法列表。 +4. 不展示“立即运行 / 自动化 / 继续这套方法”入口。 +5. 不接 `agent_runtime_submit_turn`、Query Loop、`tool_runtime` 或 automation job。 +6. 不解决 P4 Managed Objective 续跑。 + +## 为什么不用现有 local skills 列表直接承接 + +当前 `SkillService::get_catalog_roots(AppType::Lime)` 依赖: + +```text +app_paths::resolve_project_skills_dir() + -> std::env::current_dir() + -> cwd/.agents/skills +``` + +但 P3A 注册位置是: + +```text +/.agents/skills/ +``` + +因此如果直接扩 `get_local_skills_for_app`,会把“当前前端项目 workspace”与“后端进程 cwd”继续混在一起,还可能把 generated skill 误投进默认运行面。P3B 第一刀先新增独立 discovery 命令,等目录投影、权限和 UI 语义稳定后,再进入 runtime binding。 + +## 安全规则 + +1. **显式 workspaceRoot**:入参必须是存在的绝对目录。 +2. **registered-only**:目录必须同时包含 `SKILL.md` 与 `.lime/registration.json`。 +3. **不跟随 symlink**:扫描到 symlink 目录直接拒绝,避免通过目录投影读取 workspace 外内容。 +4. **不执行文件**:只读 `SKILL.md`、registration metadata 和包资源摘要。 +5. **不信任元数据路径**:返回真实扫描到的目录,同时保留 registration summary 作为 provenance。 +6. **默认不可运行**:返回对象必须显式标记 `launchEnabled=false` 与 runtime gate 提示。 + +## 实施步骤 + +### P3B-0:计划与边界 + +- [x] 新增本执行计划。 +- [x] 明确本轮只做 registered skill discovery,不做 runtime binding。 + +### P3B-1:后端 discovery service + +- [ ] 新增 `ListWorkspaceRegisteredSkillsRequest`。 +- [ ] 新增 `WorkspaceRegisteredSkillRecord` DTO。 +- [ ] 新增 `list_workspace_registered_skills(...)` 服务函数。 +- [ ] 只扫描 `/.agents/skills`。 +- [ ] 只返回包含 `.lime/registration.json` 的标准 Skill 包。 +- [ ] 补 Rust 单测:空目录、无 registration 忽略、注册后可发现、相对 workspaceRoot 拒绝、symlink 逃逸拒绝。 + +### P3B-2:命令边界 + +- [ ] 新增 Tauri command `capability_draft_list_registered_skills`。 +- [ ] 同步 `runner.rs`、DevBridge dispatcher。 +- [ ] 同步 `agentCommandCatalog`、`mockPriorityCommands`、`defaultMocks`。 +- [ ] 运行 `npm run test:contracts`。 + +### P3B-3:前端 API / UI + +- [ ] 扩展 `capabilityDraftsApi.listRegisteredSkills(...)` 与 normalization。 +- [ ] 新增 Workspace 已注册能力只读面板。 +- [ ] Skills 工作台在 Capability Draft 隔离区附近展示已注册能力。 +- [ ] 注册成功后刷新已注册能力面板。 +- [ ] 补 API、组件、Skills 工作台回归测试。 + +### P3B-4:试跑与验收 + +- [ ] 用 DevBridge 走 `create -> verify -> register -> list_registered_skills`。 +- [ ] 确认返回 provenance、标准合规与 `launchEnabled=false`。 +- [ ] 确认 UI 展示“已注册但待运行接入”,没有运行或自动化按钮。 +- [ ] 根据 GUI 工作台改动补 `npm run verify:gui-smoke`。 + +## 验收标准 + +1. 不存在 `.agents/skills` 时返回空数组。 +2. 普通 project skill 没有 `.lime/registration.json` 时不会进入 CreoAI registered 列表。 +3. P3A 注册后的 skill 能被显式 workspaceRoot 发现。 +4. discovery 结果包含来源 draft、verification report、权限摘要和 Agent Skills 标准状态。 +5. discovery 结果显式 `launchEnabled=false`。 +6. UI 不出现“立即运行 / 自动化 / 继续这套方法”入口。 +7. 命令契约、DevBridge、mock、文档和 GUI smoke 保持一致。 + +## 执行记录 + +### 2026-05-05 + +- 已创建 P3B 执行计划,确认第一刀只补 workspace-local registered skill discovery。 +- 已确认 P3B 不复用 `get_local_skills_for_app` 的 cwd 语义,也不把 generated skill 直接混进默认已安装方法列表。 diff --git a/docs/exec-plans/creaoai-capability-registration-p3-plan.md b/docs/exec-plans/creaoai-capability-registration-p3-plan.md new file mode 100644 index 000000000..1d7d42eb4 --- /dev/null +++ b/docs/exec-plans/creaoai-capability-registration-p3-plan.md @@ -0,0 +1,146 @@ +# CreoAI Capability Registration P3 执行计划 + +> 状态:P3A 完成;已通过 DevBridge 注册链路验证与 GUI smoke +> 创建时间:2026-05-05 +> 前置计划:`docs/exec-plans/creaoai-capability-authoring-p1a-plan.md`、`docs/exec-plans/creaoai-capability-verification-p1b-plan.md` +> 路线图来源:`docs/roadmap/creaoai/implementation-plan.md`、`docs/aiprompts/skill-standard.md` +> 当前目标:把已通过 verification gate 的 `Capability Draft` 注册为当前 workspace 的本地 Agent Skill 包,但仍不接运行、不接自动化、不进入默认 tool surface。 + +## 主目标 + +把 P1B 的 `verified_pending_registration` 推进到最小可追踪注册闭环: + +```text +workspace-local capability draft + -> verified_pending_registration + -> registration gate + -> /.agents/skills// + -> draft manifest lastRegistration + -> Skills 工作台 review surface + -> 后续 P3B / P4 runtime binding +``` + +固定宗旨: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 本轮范围 + +本轮做: + +1. 后端最小 registration gate。 + - 新增 `capability_draft_register` 命令。 + - 只允许 `verified_pending_registration` 状态进入注册。 + - 注册前再次校验 draft 文件清单完整性与 Agent Skills 标准合规。 + - 将 draft 生成文件复制到 `/.agents/skills//`。 + - 写入 draft 侧 `registration/latest.json` 和 manifest `lastRegistration`。 +2. 前端 API / domain / UI 接入。 + - `capabilityDraftsApi.register(...)` 统一封装命令。 + - UI 只在 `verified_pending_registration` 显示“注册到当前 Workspace”。 + - 注册后展示目录与来源,不显示“立即运行 / 自动化”。 +3. 命令治理与 mock 同步。 + - Rust 注册、DevBridge dispatcher、`agentCommandCatalog`、`mockPriorityCommands`、`defaultMocks` 保持一致。 +4. 定向验证。 + +本轮不做: + +1. 不调用 `AsterAgentState::reload_lime_skills()`。 +2. 不修改全局 seeded skill 或用户级 Lime skill 目录。 +3. 不把已注册 skill 自动放进默认 tool surface。 +4. 不新增 runtime binding、scheduler、automation job 或 Managed Objective。 +5. 不执行 draft 中的脚本,不做 dry-run、shell、依赖安装或外部写操作。 +6. 不解决现有 `resolve_project_skills_dir()` 依赖进程 cwd 的完整 catalog 可见性问题;这属于 P3B discovery/runtime binding。 + +## P3A / P3B 边界 + +本计划只交付 **P3A:workspace-local file registration**。 + +```text +P3A: verified draft -> workspace .agents/skills package -> provenance +P3B: workspace catalog discovery -> skill launch metadata -> tool_runtime surface +P4 : managed execution / automation / objective loop +``` + +这样拆分的原因: + +1. registration 是文件与来源事实,不等于可运行能力。 +2. catalog discovery 需要解决 workspace 选择、进程 cwd、SkillService root 和 runtime session 的一致性,不能顺手塞进复制文件命令里。 +3. runtime binding 必须回到 `agent_runtime_submit_turn -> Query Loop -> tool_runtime -> artifact/evidence` 主链,不能让 `capability_draft_register` 变成第二套执行入口。 + +## 注册规则 + +1. **状态前置**:仅允许 `verified_pending_registration`。 +2. **标准前置**:`SKILL.md` 必须通过 Agent Skills 标准检查;P1B 的静态 gate 通过不等于标准合规。 +3. **路径前置**:只复制 manifest `generatedFiles` 清单内的相对路径,继续拒绝绝对路径、`..`、平台相关路径与 symlink。 +4. **目标位置**:只写当前 `workspaceRoot/.agents/skills//`。 +5. **冲突处理**:目标目录已存在时拒绝,不覆盖、不合并、不删除用户已有目录。 +6. **来源记录**:注册摘要必须包含 draft id、verification report id、权限摘要、文件数量、注册时间与目标目录。 +7. **可见性限制**:注册完成只表示“workspace 中已有标准 skill 包”,不表示已经进入运行时工具面。 + +## 实施步骤 + +### P3-0:计划与边界 + +- [x] 新增本执行计划。 +- [x] 明确本轮只做 P3A registration,不做 P3B discovery/runtime binding。 + +### P3-1:后端 registration service + +- [x] 新增 registration summary / request / result 类型。 +- [x] 新增注册目录派生、目标路径 guard、文件复制与 provenance 写入。 +- [x] 新增 `register_capability_draft(...)` 服务函数。 +- [x] 注册后更新 manifest 状态为 `registered`。 +- [x] 补 Rust 单测:未验证拒绝、验证失败拒绝、标准不合规拒绝、标准草案可注册、目标目录冲突拒绝。 + +### P3-2:命令边界 + +- [x] 新增 Tauri command `capability_draft_register`。 +- [x] 同步 `runner.rs`、DevBridge dispatcher。 +- [x] 同步 `agentCommandCatalog`、`mockPriorityCommands`、`defaultMocks`。 +- [x] 运行 `npm run test:contracts`。 + +### P3-3:前端 API / UI + +- [x] 扩展 `capabilityDraftsApi.register(...)` 与 normalization。 +- [x] 扩展 domain helper:`canRegisterCapabilityDraft`、注册摘要展示。 +- [x] 在 `CapabilityDraftPanel` 展示注册按钮和注册目录。 +- [x] 补 API、domain、UI 回归测试。 + +### P3-4:试跑与验收 + +- [x] 用 DevBridge 创建完整标准 draft,验证通过后注册。 +- [x] 通过 Rust service 定向测试确认生成 `/.agents/skills//SKILL.md`。 +- [x] 通过 Rust service 定向测试确认 manifest 进入 `registered` 并记录 `lastRegistration`。 +- [x] 通过前端回归测试确认 UI 仍没有运行或自动化入口。 +- [x] 根据改动风险补 `npm run verify:gui-smoke`。 + +## 验收标准 + +1. 未验证或验证失败 draft 无法注册。 +2. P1B 静态 gate 通过但 Agent Skills 标准不合规的 draft 仍无法注册。 +3. 标准合规 draft 能注册到当前 workspace 的 `.agents/skills`。 +4. 注册动作不会覆盖已有 skill 目录。 +5. 注册后 manifest 和 `registration/latest.json` 能追踪来源、权限和 verification report。 +6. UI 能看到注册结果,但没有运行、自动化或外部写入口。 +7. 命令契约、mock、文档与 GUI smoke 保持一致。 + +## 执行记录 + +### 2026-05-05 + +- 已创建 P3A 执行计划,明确本轮只做 workspace-local file registration。 +- 已确认 `create_skill_scaffold_for_app` 是人工 scaffold 主链,不复用为 draft registration;P3A 使用独立 `capability_draft_register`,避免未验证 / 未授权能力进入 Skill reload 或运行时主链。 +- 已完成后端 P3A registration gate:只允许 `verified_pending_registration`,注册前复核 manifest 文件完整性与 Agent Skills 标准,复制到当前 workspace 的 `.agents/skills//`,并写入 draft / registered skill 两侧 provenance。 +- 已完成命令边界同步:`capability_draft_register` 已接入 Tauri command、`runner.rs`、DevBridge dispatcher、`agentCommandCatalog`、`mockPriorityCommands`、`defaultMocks` 与前端 API 网关。 +- 已完成 Skills 工作台最小 UI:只有 `verified_pending_registration` 显示“注册到 Workspace”,注册成功只展示目录与来源提示,仍不展示立即运行、自动化或 runtime binding 入口。 +- 校验通过:`cargo fmt --manifest-path src-tauri/Cargo.toml --check`。 +- 校验通过:`CARGO_TARGET_DIR=src-tauri/target-codex-p3 cargo test --manifest-path src-tauri/Cargo.toml capability_draft`,11 个 capability draft 定向测试通过。 +- 校验通过:`npm test -- src/lib/api/capabilityDrafts.test.ts src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx src/components/skills/SkillsWorkspacePage.test.tsx`,4 个文件 42 个测试通过。 +- 校验通过:`npm run test:contracts`,命令契约、Harness 契约、modality runtime contracts 与 cleanup report contract 通过。 +- 已做稳健性补强:注册复制使用目标目录独占创建,避免 race 下覆盖或合并已有 workspace skill 目录;manifest 写入失败时同步清理 draft 侧 registration summary,避免留下不可达 provenance。 +- GUI smoke 首次尝试时,`smoke:knowledge-gui` 点击旧 `ariaLabel=知识库` 失败,当前导航按钮实际为 `灵感库` / `项目资料` 等;这是 Knowledge 导航 smoke 断言与当前 UI 命名不一致,非 P3A Capability Draft 注册链路改动。 +- 已收口 Knowledge GUI smoke 导航断言:`scripts/knowledge-gui-smoke.mjs` 从旧的 `知识库` 导航切到当前 `项目资料` 入口,并更新 Agent 页资料使用文案断言。 +- 校验通过:`npm run smoke:knowledge-gui -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --invoke-url http://127.0.0.1:3030/invoke --timeout-ms 300000 --interval-ms 1000`。 +- 校验通过:`npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000`,完整 GUI smoke 通过。 +- DevBridge 链路验证通过:通过 `capability_draft_create -> capability_draft_verify -> capability_draft_register` 创建标准 draft,验证结果 `passed`,注册后 manifest 状态为 `registered`,并确认生成 `.agents/skills//SKILL.md` 与 `.lime/registration.json`。 +- 已知非本轮阻塞:`npm run typecheck` 仍受 `src/lib/layered-design/imageTasks.ts` 既有类型问题影响,错误为 `LayeredDesignImageRuntimeContract` 不能赋给 `Record`;本计划不顺手修 layered-design 旁支。 diff --git a/docs/exec-plans/creaoai-capability-verification-p1b-plan.md b/docs/exec-plans/creaoai-capability-verification-p1b-plan.md new file mode 100644 index 000000000..3d50bb6ca --- /dev/null +++ b/docs/exec-plans/creaoai-capability-verification-p1b-plan.md @@ -0,0 +1,126 @@ +# CreoAI Capability Verification P1B 执行计划 + +> 状态:P1B 完成,已通过 GUI smoke +> 创建时间:2026-05-05 +> 前置计划:`docs/exec-plans/creaoai-capability-authoring-p1a-plan.md` +> 路线图来源:`docs/roadmap/creaoai/implementation-plan.md`、`docs/roadmap/creaoai/coding-agent-layer.md`、`docs/roadmap/creaoai/architecture-review.md` +> 当前目标:在 P1A `Capability Draft` 事实源上补最小 verification gate,让草案可以被结构化检查并进入 `verification_failed` 或 `verified_pending_registration`,但仍不注册、不运行、不接自动化。 + +## 主目标 + +把 P1A 的“未验证草案可见”推进到“草案可以被门禁检查”: + +```text +workspace-local capability draft + -> static verification gate + -> verification report + -> manifest status update + -> Skills 工作台 review surface + -> 后续 P3 registration +``` + +固定宗旨: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 本轮范围 + +本轮做: + +1. 后端最小 verification gate。 + - 新增 `capability_draft_verify` 命令。 + - 只做结构、contract、权限声明、危险 token 静态扫描和 fixture 存在性检查。 + - 输出 `verification/latest.json` 报告,并同步 manifest 状态。 +2. 前端 API / domain / UI 接入。 + - `capabilityDraftsApi.verify(...)` 统一封装命令。 + - UI 暴露“运行验证”按钮,但不暴露“运行草案 / 注册方法 / 自动化”。 + - 展示最近验证摘要与失败建议。 +3. 命令治理与 mock 同步。 + - Rust 注册、DevBridge、`agentCommandCatalog`、`mockPriorityCommands`、`defaultMocks` 保持一致。 +4. 定向验证。 + +本轮不做: + +1. 不执行用户生成脚本。 +2. 不开放 shell、安装依赖、联网 dry-run 或外部写操作。 +3. 不注册 workspace-local skill。 +4. 不把 `verified_pending_registration` 草案放进 tool surface。 +5. 不新增 evidence pack 主链,只保留可后续消费的 verification report 文件。 + +## 最小检查矩阵 + +| 检查 | 目标 | 失败后状态 | +| ------------------------ | --------------------------------------------------------------------- | --------------------- | +| `package_structure` | `SKILL.md` 存在,manifest 文件清单与磁盘一致 | `verification_failed` | +| `skill_readme_quality` | `SKILL.md` 内容不是空壳,包含可读任务说明 | `verification_failed` | +| `input_contract` | 存在 `contract/input.schema.json` 或等价输入 schema | `verification_failed` | +| `output_contract` | 存在 `contract/output.schema.json` 或等价输出 schema | `verification_failed` | +| `permission_declaration` | 权限摘要非空,并能解释只读 / 草案内写入边界 | `verification_failed` | +| `static_risk_scan` | 未出现删除、发布、付款、依赖安装、任意 shell、HTTP 写操作等危险 token | `verification_failed` | +| `fixture_presence` | 至少存在 `tests/` 或 `examples/` 作为后续 dry-run 输入 | `verification_failed` | + +通过后状态只到: + +```text +verified_pending_registration +``` + +它表示“可以进入 P3 注册设计”,不表示现在已经能运行。 + +## 实施步骤 + +### P1B-0:计划与边界 + +- [x] 新增本执行计划。 +- [x] 确认 P1B 只做静态 gate,不做注册和执行。 + +### P1B-1:后端 verification service + +- [x] 扩展 draft 状态:`verification_failed / verified_pending_registration`。 +- [x] 新增 verification report 类型、summary、check item。 +- [x] 新增 `verify_capability_draft(...)` 服务函数。 +- [x] 写入 `verification/latest.json` 并更新 manifest。 +- [x] 补 Rust 单测:通过、缺 contract 失败、危险 token 失败。 + +### P1B-2:命令边界 + +- [x] 新增 Tauri command `capability_draft_verify`。 +- [x] 同步 `runner.rs`、DevBridge dispatcher。 +- [x] 同步 `agentCommandCatalog`、`mockPriorityCommands`、`defaultMocks`。 +- [x] 运行 `npm run test:contracts`。 + +### P1B-3:前端 API / UI + +- [x] 扩展 `capabilityDraftsApi.verify(...)` 与 normalization。 +- [x] 扩展 domain helper:状态文案、能否验证、验证摘要。 +- [x] 在 `CapabilityDraftPanel` 展示验证按钮与最近结果。 +- [x] 补 API、domain、UI 回归测试。 + +### P1B-4:试跑与验收 + +- [x] 用 DevBridge 创建一个完整 draft 并验证通过。 +- [x] 用 DevBridge 创建一个危险 draft 并验证失败。 +- [x] 运行前后端定向测试。 +- [x] 根据改动风险补 `npm run verify:gui-smoke` 或记录原因。 + +## 验收标准 + +1. 完整草案能进入 `verified_pending_registration`。 +2. 缺 input/output contract 的草案会进入 `verification_failed`。 +3. 出现危险 token 的草案会进入 `verification_failed`,并给出可修复建议。 +4. UI 能触发 verification gate 并刷新状态。 +5. 即使验证通过,也没有运行、注册或自动化入口。 +6. 命令契约、mock 与文档保持一致。 + +## 执行记录 + +### 2026-05-05 + +- 已完成后端 verification gate:`capability_draft_service` 扩展状态、report、check item 与 `verify_capability_draft(...)`,验证报告落到 `verification/latest.json`,manifest 同步 `lastVerification` 与 `verificationStatus`。 +- 已完成命令边界:新增 `capability_draft_verify`,同步 Tauri 注册、DevBridge dispatcher、`agentCommandCatalog.capabilityDraftCommands`、`mockPriorityCommands` 与 `defaultMocks`。 +- 已完成前端接入:`capabilityDraftsApi.verify(...)`、状态 / 验证摘要 domain helper、`CapabilityDraftPanel` 的“运行验证”按钮和最近验证摘要;验证通过后仍只显示“待注册”,没有运行、注册或自动化按钮。 +- 已通过 Rust 定向测试:`cargo test --manifest-path src-tauri/Cargo.toml capability_draft`,6 个 capability draft 测试通过。 +- 已通过前端定向测试:`npm test -- src/lib/api/capabilityDrafts.test.ts src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx src/components/skills/SkillsWorkspacePage.test.tsx`,39 个测试通过。 +- 已通过契约与类型检查:`npm run test:contracts`、`npm run typecheck`、`cargo fmt --manifest-path src-tauri/Cargo.toml --check`。 +- 已通过 DevBridge smoke:完整草案验证后进入 `verified_pending_registration`;包含 `method: "POST"` 的危险草案验证后进入 `verification_failed`,失败项为 `static_risk_scan`。 +- 已通过 GUI smoke:`npm run verify:gui-smoke` 全绿,覆盖 DevBridge、workspace-ready、browser-runtime、site-adapters、agent-service-skill-entry、agent-runtime-tool-surface、agent-runtime-tool-surface-page 与 knowledge-gui。 diff --git a/docs/exec-plans/multimodal-runtime-contract-plan.md b/docs/exec-plans/multimodal-runtime-contract-plan.md index 76fbbcbf0..aaf24dbaa 100644 --- a/docs/exec-plans/multimodal-runtime-contract-plan.md +++ b/docs/exec-plans/multimodal-runtime-contract-plan.md @@ -61,7 +61,7 @@ runtime identity 1. `docs/roadmap/warp/capability-matrix.md` 2. `src/lib/governance/modalityCapabilityMatrix.json` 3. contract 守卫检查 `required_capabilities` 与 `routing_slot` 是否引用已登记能力和模型角色 -4. `request_model_resolution` 已开始消费 `TaskProfile.routingSlot`,把 `browser_reasoning_model`、`image_generation_model`、`audio_transcription_model`、`voice_generation_model` 等槽位折叠成最小模型能力需求;候选池会过滤不满足能力的模型,显式用户模型锁定仍保留锁定模型但输出 `*_candidate_missing` gap。 +4. `request_model_resolution` 已开始消费 `TaskProfile.routingSlot`,把 `browser_reasoning_model`、`image_generation_model`、`audio_transcription_model`、`voice_generation_model` 等槽位折叠成最小模型能力需求;候选池会过滤不满足能力的模型,显式用户模型锁定仍保留锁定模型但输出 `*_candidate_missing` gap,并在 gap 来源为 `explicit_model_lock` 时把 `limit_state.status` 标为 `user_locked_capability_gap`,由 runtime turn 在模型执行前阻断。 ### Phase 3:ModalityExecutionProfile @@ -77,7 +77,7 @@ runtime identity 6. Rust runtime contract snapshot、Evidence Pack、Replay 与统一媒体任务索引已经携带 profile / adapter key;媒体任务 worker 已在进入图片、配音、转写执行器前做最小 profile / adapter / executor binding preflight;Browser Assist 工具层现在也会在真实浏览器动作前校验 `browser_control` 的 execution profile、executor adapter 与 executor binding,失败时返回 `runtime_preflight` 工具错误并保留同一 runtime contract metadata,供 Evidence / Replay 识别为被合同阻断;`LimeSkillTool` 现在也会对 current Skill 主链生成治理 registry 驱动的 `modality_runtime_contract` metadata,并在上层显式传入冲突 `execution_profile` / `executor_adapter` / `executor_binding` 时阻断进入 Skill 执行器;旧 `lime_run_service_skill` 仍是 compat guard,但命中 `voice_generation` 时会携带并校验 `service_skill:voice_runtime` 合同,避免旧云运行工具被误看成 current executor;LimeCore policy refs/snapshot 已进入 runtime contract、Evidence Pack 与统一媒体任务索引;后续仍需在 registry 出现明确 `gateway:*` adapter 后接入 Gateway preflight、真实 runtime policy merge、更完整 thread read 决策解释和 GUI 可视化。 7. `thread_read.runtime_summary.modalityRuntime` 现在会从最近 ToolCall / FileArtifact 的同一 `runtime_contract` 投影 `contractKey`、`modality`、`routingSlot`、`requiredCapabilities`、`profileKey`、`executorAdapterKey`、`executorKind` 与 `executorBindingKey`;这只暴露最近合同的 profile / adapter / binding 摘要,不把 thread read 变成上层 `@` 命令事实源。 8. `SessionExecutionRuntimeTaskProfile.routingSlot` 已接入 provider/model resolution 的最小能力 enforcement:非显式用户锁定路径会优先在候选池内选择满足 runtime requirements 的模型,显式用户锁定路径继续 honored,但 `capability_gap` 会暴露 `browser_reasoning_candidate_missing`、`image_generation_candidate_missing` 等 gap code。 -9. `SessionExecutionRuntimePermissionState` 已把 `permissionProfileKeys` 推进为 `lime_runtime.permission_state` 最小权限摘要,并同步进入 `runtime_summary.permissionStatus / permissionAskCount / permissionBlockingCount`、`AgentRuntimeThreadReadModel.permission_state` 与前端 execution runtime / thread read 类型。本阶段只解释 profile 声明、需确认 profile 与空阻断清单,不执行真实授权、不阻断 turn,也不把 `ask_user_question` 视为风险权限。 +9. `SessionExecutionRuntimePermissionState` 已把 `permissionProfileKeys` 推进为 `lime_runtime.permission_state` 最小权限摘要,并同步进入 `runtime_summary.permissionStatus / permissionAskCount / permissionBlockingCount`、`AgentRuntimeThreadReadModel.permission_state` 与前端 execution runtime / thread read 类型。本阶段解释 profile 声明、需确认 profile 与空阻断清单,不把 `ask_user_question` 视为风险权限;未解决确认现在会在 prelude 状态发出后、模型执行前阻断 turn,且最小 `RequestUserInput` 权限确认 + `agent_runtime_respond_action` 写回 + 下一轮 `resolved/denied` metadata merge 已接入,避免声明态需确认权限被误当成功执行;这仍不是完整权限系统或 LimeCore 云授权。 ### Phase 5:Executor Adapter registry @@ -293,7 +293,7 @@ runtime identity 61. `Phase 6` 第六十八刀把 policy evaluator explanation 接入图片消息轻卡:`ImageWorkbenchMessagePreview` 复用共享 helper,从 `preview.runtimeContract` 解析同一份 evaluator 摘要,并在聊天区图片预览顶部展示 `LimeCore 策略输入待命中 / 阻断 / 需确认` 胶囊标签;该刀只让图片消息卡消费已有 runtime contract,不新增命令、不接 LimeCore 云 run/poll,也不改图片消息的布局骨架。 62. `Phase 5/6` 第七十一刀把 profile / adapter 摘要接入 thread read:`AgentRuntimeThreadReadModel` 现在会扫描最近 ToolCall metadata / FileArtifact content 中的 runtime contract,并把 `contractKey`、`routingSlot`、`requiredCapabilities`、`profileKey`、`executorAdapterKey`、`executorKind`、`executorBindingKey` 写入 `runtime_summary.modalityRuntime`。该刀只投影已有底层合同,不新增命令、不接云 run/poll,也不把上层 `@` 命令当执行合同事实源。 63. `Phase 5/6` 第七十二刀把 profile / adapter / binding 摘要合入 `SessionExecutionRuntimeTaskProfile`:`build_runtime_task_profile()` 现在会从 request metadata 中已有的 `runtime_contract / modality_runtime_contract` 提取 `modalityContractKey`、`routingSlot`、`executionProfileKey`、`executorAdapterKey`、`executorKind`、`executorBindingKey`,并按 `modalityExecutionProfiles.json` 补齐 `permissionProfileKeys` 与 `userLockPolicy`;`lime_runtime.task_profile` 和 `task_profile_resolved` 事件会承载同一摘要,前端 API 类型与协议测试同步。该刀仍不新增 Tauri command、不接 LimeCore 云 run/poll、不把上层 `@` 命令当事实源;权限判定执行、用户显式模型锁定 enforcement 与真实 `gateway:*` adapter preflight 继续后置。 -64. `Phase 5/6` 第七十三刀把 `routingSlot` 推进到最小模型能力 enforcement:`request_model_resolution` 现在会把 `routingSlot` 映射成 runtime model capability requirements,并用它过滤候选池、fallback 候选与多候选自动重选;`browser_reasoning_model` 会要求 reasoning,`image_generation_model` 会允许专用图片模型进入候选池,显式用户模型锁定仍 honored,但 `capability_gap` 会输出对应 `*_candidate_missing`。该刀不新增 Tauri command、不接 LimeCore 云 run/poll、不触碰上层 `@` 命令;完整权限判定和用户确认/阻断执行继续后置。 +64. `Phase 5/6` 第七十三刀把 `routingSlot` 推进到最小模型能力 enforcement:`request_model_resolution` 现在会把 `routingSlot` 映射成 runtime model capability requirements,并用它过滤候选池、fallback 候选与多候选自动重选;`browser_reasoning_model` 会要求 reasoning,`image_generation_model` 会允许专用图片模型进入候选池,显式用户模型锁定仍 honored,但 `capability_gap` 会输出对应 `*_candidate_missing`。第 113 刀已把显式用户锁定导致的 capability gap 从“只解释”推进为 `user_locked_capability_gap` 执行前阻断;该刀本身不新增 Tauri command、不接 LimeCore 云 run/poll、不触碰上层 `@` 命令。 65. `Phase 5/6` 第七十四刀把 `permissionProfileKeys` 推进到最小 runtime permission summary:`request_model_resolution` 现在会生成 `SessionExecutionRuntimePermissionState`,随 `lime_runtime.permission_state` 写入 turn metadata;runtime view、thread read fallback summary、前端 execution runtime 类型与内部辅助任务 metadata 都能识别该摘要。`ask_user_question` 不视为风险权限,`blockingProfileKeys` 本刀保持为空,因此这一步只解释权限声明和需确认 profile,不执行真实授权或阻断。 66. `Phase 5/6` 第七十五刀把 `permission_state` 推到 thread read 结构化读取面:`AgentRuntimeThreadReadModel` 现在直接暴露 `permission_state`,前端 `AgentRuntimeThreadReadModel` 类型同步引用 `AsterSessionExecutionRuntimePermissionState`。这一步让上层读取完整 `requiredProfileKeys / askProfileKeys / blockingProfileKeys / notes`,不再只能从 `runtime_summary` 数字摘要或隐藏 turn metadata 推断;仍不新增事件、不阻断执行、不接真实权限授权。 @@ -307,7 +307,7 @@ runtime identity 6. 暂不新增独立 `report_generation` 合同;`@研报 / @竞品` 继续走 `report_skill_launch -> Skill(report_generate)` 主链,但其底层能力归属先收敛到 `web_research`,避免把 report artifact 协议提前扩张成第二套事实源。 7. 暂不新增独立 `summary_generation`、`translation`、`analysis`、`publish_compliance` 或 `logo_decomposition` 合同;这组轻量文本/文档转换入口先统一收敛到 `text_transform`,避免把上层 `@` 命令提前扩张成平行底层事实源。 8. 暂不新增非 OpenAI-compatible ASR adapter 或本地离线 ASR 执行器;`audio_transcription` 当前交付标准 `transcription_generate` task writer、`lime-transcription-worker`、`transcript.completed/failed` 回写、统一媒体任务索引、聊天任务卡、可编辑校对运行时文档 viewer、JSON/SRT/VTT 时间轴与说话人段落展示、ArtifactDocument 版本化校对稿保存、校对稿状态/差异摘要、Evidence `transcriptIndex` 与 Replay 检查。 -9. 暂不在本刀实现完整权限判定 enforcement、用户确认/阻断执行、LimeCore 云端 allow / ask / deny evaluator、真实 `gateway:*` adapter preflight 或完整 GUI/evidence 可视化;当前已让图片、配音、转写媒体 worker 消费 Phase 3 / Phase 5 的 profile / adapter 事实源做最小执行前检查,并把 Browser Assist preflight、通用 Skill metadata/preflight、`lime_run_service_skill` voice compat guard、Phase 6 的 LimeCore policy refs/snapshot、model/offer/gateway/tenant hit producers、最小本地 policy input evaluator、thread read 摘要、`SessionExecutionRuntimeTaskProfile` profile/adapter/binding merge、`routingSlot` 模型能力 enforcement、`permissionProfileKeys` 最小 runtime permission summary、thread read 结构化 `permission_state`、统一媒体任务索引 explanation、配音/转写任务卡 meta、图片 viewer policy 标签与图片消息轻卡标签接进 current 主链。显式用户模型锁定已能输出 capability gap,权限 profile 已能输出需确认摘要,但还没有接真实授权、用户确认或阻断执行。后续继续把同一决策扩展到权限执行、确认式用户锁定处理、云端策略 evaluator、真实 Gateway adapter、更多任务卡与更多 GUI 可视化。 +9. 暂不在本刀实现完整权限判定系统、同 turn 自动恢复、LimeCore 云端 allow / ask / deny evaluator、真实 `gateway:*` adapter preflight 或完整 GUI/evidence 可视化;当前已让图片、配音、转写媒体 worker 消费 Phase 3 / Phase 5 的 profile / adapter 事实源做最小执行前检查,并把 Browser Assist preflight、通用 Skill metadata/preflight、`lime_run_service_skill` voice compat guard、Phase 6 的 LimeCore policy refs/snapshot、model/offer/gateway/tenant hit producers、最小本地 policy input evaluator、thread read 摘要、`SessionExecutionRuntimeTaskProfile` profile/adapter/binding merge、`routingSlot` 模型能力 enforcement、`permissionProfileKeys` 最小 runtime permission summary、thread read 结构化 `permission_state`、统一媒体任务索引 explanation、配音/转写任务卡 meta、图片 viewer policy 标签与图片消息轻卡标签接进 current 主链。显式用户模型锁定已能输出 capability gap,且 `explicit_model_lock` gap 会以 `user_locked_capability_gap` 在模型执行前阻断;权限 profile 已能输出需确认摘要,Evidence / Replay 已把 `not_requested / requested` 未解决确认作为交付阻断事实;未 resolved 的 `requires_confirmation` 也已在 prelude 后、模型执行前阻断 turn;最小 `runtime_permission_confirmation:*` / `RequestUserInput` 权限确认、`agent_runtime_respond_action` 写回和下一轮 `resolved/denied` metadata merge 已接入。后续继续把同一决策扩展到完整权限授权、用户锁定 gap 的确认式恢复、云端策略 evaluator、真实 Gateway adapter、更多任务卡与更多 GUI 可视化。 ## 分类 @@ -460,3 +460,21 @@ runtime identity - 2026-05-05:继续第九十三刀 `Phase 5/6 review decision mock/API denied acceptance guardrail`:浏览器 `tauri-mock` 的 `agent_runtime_save_review_decision` 现在与 Rust save API 对齐,默认 denied 权限确认下保存 `accepted` 会抛错,保存 `rejected` 仍返回 denied 权限确认摘要;前端 API 回归确认后端 denied 错误会透传给调用方。该刀防止 DevBridge fallback / 浏览器测试链路假装 denied+accepted 成功,不改审批执行、不新增命令、不接云策略。 - 2026-05-05:继续第九十四刀 `Phase 7 entry binding inventory guardrail`:新增 `entry-binding-inventory.md` 并把 Phase 7 验收落进 `check-modality-runtime-contracts.mjs`,要求所有 current contract 至少有一个 entry binding、entry key 全局唯一、`entry_source` 只能引用同一 contract 下的入口、`launch_metadata_path` 只能停留在 `harness.*`,且 scene entry 只有声明 `client_scenes / scene_policy` 后才允许登记;`/scene-key` 明确保持 planned,等待 LimeCore Scene catalog 与 audit contract 接齐后再进入 current。该刀只收口入口绑定事实源,不新增 `@` 命令、不改执行器、不接云 run/poll。 - 2026-05-05:继续第九十五刀 `Phase 8 task index inventory guardrail`:新增 `task-index-inventory.md`,并把 task index 最低验收落进 `check-modality-runtime-contracts.mjs`:所有 current / partial artifact kind 必须声明 `task_id / contract_key / artifact_kind / status / created_at / updated_at`,且 `task_index_fields` 不允许重复。文档同时明确 `thread_id / turn_id / content_id / entry_key / model_id / executor_kind / cost_state / limit_state / limecore_policy_snapshot` 仍未统一稳定化,下一刀再从 `runtime_contract / entry_source / task_profile / policy snapshot` 回填查询维度。该刀只收口任务索引事实源,不新增执行器、不改 `@` 命令、不接云 run/poll。 +- 2026-05-05:继续第九十六刀 `Phase 8 task index entry_key projection`:`list_media_task_artifacts.modality_runtime_contracts` 现在会把媒体 task payload 中的 `entry_key / entry_source` 归一投影为 snapshot `entry_key`,并汇总 `entry_keys`;`image_task / image_output / audio_task / audio_output / transcript` 的 artifact graph 索引声明与机器守卫同步要求 `entry_key`,前端类型、浏览器 fallback mock 与 mediaTasks / tauri-mock 回归已同步。该刀只稳定媒体任务查询维度,不新增执行器、不改上层 `@` 命令、不接 LimeCore 云 run/poll。 +- 2026-05-05:继续第九十七刀 `Phase 8 task index executor policy dimensions`:`list_media_task_artifacts.modality_runtime_contracts` 现在汇总 `executor_kinds / executor_binding_keys`,媒体 artifact graph 与机器守卫同步要求 `entry_key / executor_kind / executor_binding_key / limecore_policy_snapshot_status`;前端类型、浏览器 fallback mock、mediaTasks 与 tauri-mock 回归已覆盖这些查询维度。该刀只把已有 runtime contract snapshot 投影为任务索引字段,不新增执行器、不改变上层 `@` 命令、不把本地默认 policy snapshot 伪造成 LimeCore 云策略结论。 +- 2026-05-05:继续第九十八刀 `Phase 8 task index runtime identity anchors`:媒体任务创建请求、task payload、Rust `list_media_task_artifacts.modality_runtime_contracts`、前端类型与浏览器 fallback mock 现在同步承载 `thread_id / turn_id / content_id`,并汇总为 `thread_ids / turn_ids / content_ids`;媒体 artifact graph 与机器守卫同步要求这组运行身份锚点。该刀只把已有媒体任务身份字段接入查询索引,不新增命令、不改变上层 `@` 命令,也不把非媒体 Evidence / Replay artifact 伪装成已完成。 +- 2026-05-05:继续第九十九刀 `Phase 8 task index modality skill model dimensions`:媒体任务索引现在从 task payload / runtime contract 投影 `modality / skill_id / model_id`,并汇总为 `modalities / skill_ids / model_ids`;`skill_id` 优先读取显式 payload 字段,缺省时只对 `skill / service_skill` executor 使用 `executor_binding_key` 作为当前查询锚点。媒体 artifact graph 与机器守卫同步要求这三个字段。该刀只稳定任务查询维度,不新增执行器、不改上层 `@` 命令,也不把非媒体 Evidence / Replay artifact 伪装成已完成。 +- 2026-05-05:继续第一百刀 `Phase 8 task index cost limit summaries`:媒体任务索引现在从 task payload、`runtime_summary` 与 `task_profile` 中读取 `cost_state / limit_state` 摘要,并汇总 `cost_states / limit_states / estimated_cost_classes / limit_event_kinds / quota_low_count`;snapshot 同步输出 `cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low`。媒体 artifact graph 与机器守卫同步要求这组成本/限额查询字段;同轮补齐 `creation_tools` 构造媒体任务请求时的 `thread_id / turn_id` 透传,避免第九十八刀新增身份锚点后 Rust 字面量编译漂移。该刀只稳定已有摘要的任务索引口径,不新增 Tauri command、不接 LimeCore 云 run/poll、不把上层 `@` 命令变成底层事实源。 +- 2026-05-05:继续第一百零一刀 `Phase 8 evidence taskIndex convergence`:Evidence Pack 的 `modalityRuntimeContracts.snapshotIndex` 新增 `taskIndex`,把 Browser / PDF / Web Research / Text Transform / Voice Service 等非媒体 runtime contract snapshot 的 `thread_id / turn_id / content_id / entry_key / modality / skill_id / model_id / executor_kind / executor_binding_key / cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low` 归一到与媒体任务索引一致的查询口径;前端 `AgentRuntimeEvidencePack` 类型与 normalizer 同步解析该索引。该刀只收敛 Evidence 审计事实源,不新增 Tauri command、不碰上层 `@` 命令、不接 LimeCore 云 run/poll;Replay / grader 消费同一 `taskIndex` 仍作为下一步收口。 +- 2026-05-05:继续第一百零二刀 `Phase 8 replay taskIndex consumption`:Replay case 现在消费 Evidence `snapshotIndex.taskIndex`,在 `input.json.runtimeContext.runtimeFacts.modalityTaskIndex` 输出 identity / executor / cost-limit compact 摘要,suite tags 增加 `modality-task-index / modality-task-identity / modality-task-cost-limit`,`expected.json` 与 `grader.md` 会要求保留 thread/turn/content/entry、executor binding 与 cost/limit 摘要;缺少 `taskIndex` 的多模态合同会生成 blocking check。该刀只把第 101 刀的 Evidence 索引接入复盘验收,不新增命令、不碰上层 `@`、不接云 run/poll;任务中心 / 客服诊断消费非媒体索引仍作为下一步。 +- 2026-05-05:继续第一百零三刀 `Phase 8 harness diagnostic taskIndex surface`:HarnessStatusPanel 的 Evidence Pack 区块新增“多模态任务索引”卡片,直接消费 `observability_summary.modality_runtime_contracts.snapshot_index.task_index`,展示 identity anchors、executor dimensions、cost/limit 统计与最近 `items[]`,并补组件回归覆盖 `thread/content/binding/limit` 可见。该刀只把既有 Evidence taskIndex 接入客服/开发诊断面板,不新增命令、不改 `@`、不接云 run/poll;任务中心过滤非媒体 artifact 仍作为下一步。 +- 2026-05-05:继续第一百零四刀 `Phase 8 taskIndex query model`:新增 `src/lib/agentRuntime/modalityTaskIndexPresentation.ts`,把 Evidence `snapshotIndex.taskIndex` 统一转换为任务中心可复用的 facets、rows 与 exact filters,并让 HarnessStatusPanel 的“多模态任务索引”卡片复用同一查询模型而不是本地临时汇总。该刀只建立非媒体任务索引的前端查询消费层,不新增命令、不碰上层 `@`、不接 LimeCore 云 run/poll;完整任务中心列表 UI 接入仍作为 Phase 8 收尾项。 +- 2026-05-05:继续第一百零五刀 `Phase 8 taskIndex task center filter surface`:HarnessStatusPanel 的“多模态任务索引”卡片新增“任务中心过滤列表”,直接消费 `modalityTaskIndexPresentation.rows`,支持按 entry、content、executor、cost、limit 过滤非媒体任务行,并展示过滤命中数与完整 artifact path。该刀把第 104 刀查询模型落成可见任务中心过滤 UI,不新增命令、不碰上层 `@`、不接 LimeCore 云 run/poll;后续若新增独立主任务中心入口,只能复用同一 rows。 +- 2026-05-05:继续第一百零六刀 `Phase 8 taskIndex surface cleanup guard`:把第 105 刀内联在 HarnessStatusPanel 的 taskIndex 列表/过滤 UI 抽成 `HarnessTaskIndexSection`,HarnessStatusPanel 只保留挂载面;`check-modality-runtime-contracts.mjs` 新增 `task index presentation guard`,要求 `modalityTaskIndexPresentation` 继续导出 facets / rows / filters、section 必须消费这些 helper,且禁止 HarnessStatusPanel 重新内联 taskIndex 查询/list UI。该刀是治理减法和守卫,不新增命令、不碰上层 `@`、不接云 run/poll。 +- 2026-05-05:继续第一百零七刀 `Phase 8 taskIndex section regression split`:新增 `HarnessTaskIndexSection.test.tsx`,独立覆盖 taskIndex 摘要、任务中心过滤列表、entry 过滤与清空过滤;`HarnessStatusPanel.test.tsx` 只保留 evidence pack 挂载与关键文案断言,不再承载 taskIndex 过滤交互细节。该刀继续收口测试边界,让 taskIndex UI 的回归跟随 current section,而不是依赖巨型面板测试;不新增命令、不碰上层 `@`、不接云 run/poll。 +- 2026-05-05:继续第一百零八刀 `Phase 5/6 unresolved permission delivery block`:Evidence Pack 现在把 `requires_confirmation` 且 `confirmationStatus=not_requested / requested` 的权限状态标为 `signalCoverage.permissionState=blocked`,`knownGaps` 同步输出“尚未发起 ApprovalRequest”或“真实权限确认等待处理”的交付阻断说明;Replay 补回归固定 `not_requested` 会生成 blocking check,`resolved` 仍不阻断。该刀只推进审计/复盘/交付判定事实源,不伪造 `ApprovalRequest`、不改变 native approval 行为、不阻断 turn 执行、不新增命令、不接 LimeCore 云策略。 +- 2026-05-05:继续第一百零九刀 `Phase 5/6 unresolved permission review decision guardrail`:`save_runtime_review_decision` 现在不只阻止 `denied + accepted`,也会在 `requires_confirmation` 且 `confirmationStatus=not_requested / requested / 未解决` 时拒绝保存 `accepted`;Review decision checklist / suggested actions 同步提示未解决权限确认不能作为成功交付证据,前端填写弹窗与浏览器 mock 也改为“权限确认未解决时不能保存接受”。该刀守住第 108 刀 Evidence / Replay 阻断语义的写回边界,不伪造 `ApprovalRequest`、不改变 native approval 行为、不阻断 turn 执行、不新增命令、不接 LimeCore 云策略。 +- 2026-05-05:继续第一百一十刀 `Phase 5/6 unresolved permission handoff analysis risk`:Handoff bundle 与 Analysis handoff 现在把 `not_requested / requested / denied` 未解决权限确认统一视为交付阻断风险;`plan.md / handoff.md / review-summary.md`、analysis brief、analysis context 与 copy prompt 会显示“权限确认尚未解决 / 尚未发起真实审批请求 / 不能作为成功交付证据”,`resolved` 仍保持非阻断。该刀把第 108 刀 Evidence / Replay 阻断事实同步到交接与外部分析入口,不伪造 `ApprovalRequest`、不改变 native approval 行为、不阻断 turn 执行、不新增命令、不接 LimeCore 云策略。 +- 2026-05-05:继续第一百一十一刀 `Phase 5/6 unresolved permission turn gating`:`runtime_turn` 现在会在 prelude 发出 `permission_review` 状态后、模型流真正开始前读取同一 `lime_runtime.permission_state`,当 `status=requires_confirmation` 且 `confirmationStatus` 不是 `resolved` 时把 turn 标为 failed 并发送错误事件;`permission_review` 文案同步说明未解决确认会阻断模型执行,`resolved` 才允许继续。该刀不伪造 `ApprovalRequest`、不新增 Tauri command、不接 LimeCore 云 run/poll,也不碰上层 `@` 命令;用户确认/恢复入口留给下一刀。 +- 2026-05-05:继续第一百一十二刀 `Phase 5/6 permission confirmation request recovery`:runtime turn 在未 resolved 的 `requires_confirmation` 阻断前,会为 `confirmationStatus=not_requested` 且尚无 request id 的权限摘要写入真实 `runtime_permission_confirmation:` / `RequestUserInput(elicitation)` timeline item,并发送同源 `action_required`;响应复用既有 `agent_runtime_respond_action`,只对该前缀请求写回 completed response,不新增 Tauri command,也不把它伪装成工具 `ApprovalRequest`。下一轮恢复请求会从同一 session detail 读取最近权限确认 item,把 response 派生为 `confirmationStatus=resolved/denied`、真实 request id 与 `runtime_action_required` 来源,再交给同一 turn gating 判定;这完成的是本地最小确认恢复闭环,完整权限系统、同 turn 自动恢复、用户锁定 gap 确认式恢复、云端 policy evaluator 与真实 Gateway adapter 仍后置。 +- 2026-05-05:继续第一百一十三刀 `Phase 5/6 user locked capability gap turn gating`:`request_model_resolution` 现在会给显式用户模型锁定导致的 runtime capability gap 标记 `capability_gap_source=explicit_model_lock`,并把 `limit_state.status` 收敛为 `user_locked_capability_gap`;`runtime_turn` 在 prelude 后、模型执行前读取同一 `lime_runtime.limit_state`,命中该状态时发出 routing runtime status、标记 turn failed 并发送错误事件,要求用户切换到满足 `routingSlot` 的模型或取消本轮显式锁定。该刀只把第 73 刀的 user lock gap 从解释推进为执行前阻断,不新增 Tauri command、不接 LimeCore 云 run/poll、不触碰上层 `@` 命令;确认式恢复与更完整 GUI 仍后置。 diff --git a/docs/knowledge/README.md b/docs/knowledge/README.md new file mode 100644 index 000000000..9615936da --- /dev/null +++ b/docs/knowledge/README.md @@ -0,0 +1,28 @@ +# Knowledge 文档索引 + +> 当前实现事实源:`docs/roadmap/knowledge/prd.md` 与 `docs/exec-plans/agent-knowledge-implementation-plan.md`。本目录多数文件是早期方案或样例,只作为迁移参考,不作为 current 实现依据。 + +## Current + +- `docs/roadmap/knowledge/prd.md`:项目资料模块的 current PRD、架构图、流程图、时序图和分阶段计划。 +- `docs/exec-plans/agent-knowledge-implementation-plan.md`:Agent Knowledge 实现执行计划、进度日志和验证记录。 + +## Compat / 参考 + +- `lime-knowledge-base-construction-blueprint.md`:早期 KnowledgePack 构建蓝图,保留概念参考;目录结构和 UI 主路径以 current PRD 为准。 +- `markdown-first-knowledge-pack-plan.md`:Markdown-first 方案探索,保留迁移参考。 +- `lime-project-knowledge-base-solution.md`:项目知识库早期产品方案,保留用户场景参考。 +- `agent-skills-and-knowledge-pack-boundary.md`:Skill 与 Knowledge 边界说明,仍可作为概念参考。 + +## Current 产品闭环 + +```text +File Manager / @项目资料 / 首页引导 / Agent 输出 + -> 添加或沉淀为项目资料 + -> 整理与人工确认 + -> 现有 Agent 输入框显式使用 + -> 生成新内容 + -> 继续沉淀为项目资料 +``` + +固定规则:资料管理页是维护面板,不是独立聊天入口;项目资料使用必须回到现有 Agent。 diff --git a/docs/research/ai-layered-design/README.md b/docs/research/ai-layered-design/README.md new file mode 100644 index 000000000..c4d9feb41 --- /dev/null +++ b/docs/research/ai-layered-design/README.md @@ -0,0 +1,104 @@ +# AI 图层化设计研究总入口 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:把 Lovart 类 AI 设计产品、语义分割、抠图、背景修补与 PSD-like 图层工程拆成可持续对照的研究事实源,供 Lime 后续规划“生成可编辑设计工程”时校准方向。 + +## 1. 目录定位 + +`docs/research/ai-layered-design/` 只回答两类问题: + +1. Lovart 这类产品为什么能把 AI 图片变成可调整图层。 +2. Lime 应该学习哪一层,不应该照搬哪一层。 + +这里是**研究目录**,不是 Lime 的产品决策目录。 + +固定分工: + +1. `docs/research/ai-layered-design/` 负责外部案例拆解、技术路线识别和风险判断。 +2. `docs/roadmap/ai-layered-design/` 负责 Lime 自己的开发计划。 +3. 代码实现必须回到 Lime current 主链:`生成` 主舞台、媒体任务、Workspace、artifact / evidence 与后续 Canvas 编辑器。 + +## 2. 为什么单独建立这一层 + +AI 生成图片已经不是稀缺能力,真正的设计交付缺口是: + +**从一张死图升级为可编辑、可复用、可交付给设计师继续调整的图层工程。** + +如果不单独建立研究事实源,后续容易出现三种跑偏: + +1. 把 `gpt-image-2`、Gemini、Flux 等生成模型误认为完整解决方案。 +2. 直接追求自训 image-to-PSD 大模型,忽略 Lime 当前可以先做的工程编排闭环。 +3. 把图层化做成纯前端画布玩具,没有 clean plate、TextLayer、单层重生成和导出事实源。 + +## 3. 固定研究结论 + +1. **图像模型不是图层系统** + - `gpt-image-2` / Gemini / Flux 负责生成和编辑像素,不负责输出 Lime 的设计工程协议。 + +2. **图层化首先是工程编排** + - 可行主链是:图层规划、分层生成、抠图/透明通道、背景修补、文字重建、Canvas 状态和导出。 + +3. **原生分层生成优先于任意图拆层** + - 生成时保留背景、主体、特效、Logo、文字等中间资产,比从扁平图反推 PSD 稳定得多。 + +4. **扁平图拆层是后续增强** + - 上传海报后拆出主要元素,需要 SAM/RMBG、matting、OCR 和 inpainting 组合,首期不承诺完美。 + +5. **设计师真正要的是非破坏性编辑** + - 移动、缩放、隐藏、重排、单层重生成、改文案、换背景和导出,才是产品价值。 + +## 4. 固定不照搬的东西 + +以下内容默认不直接搬进 Lime: + +1. Lovart 的品牌表达、交互动效和商业叙事。 +2. “一次生成完整 PSD”的大模型路线作为首期目标。 +3. 完整 Photoshop / Figma 替代品定位。 +4. 把所有 AI 图像编辑都塞进聊天,不沉淀设计工程状态。 +5. 为了显得强大而暴露分割、抠图、修补、OCR 等底层模型名。 + +Lime 真正要学的是: + +1. 生成过程保留中间资产。 +2. 扁平图可被语义拆成主要图层。 +3. 背景 clean plate 让对象移动后不露洞。 +4. 普通文案变成真实 TextLayer。 +5. 画布项目文件成为唯一可编辑事实源。 +6. 单层可重生成,整体不必重做。 + +## 5. 建议阅读顺序 + +1. [architecture-breakdown.md](./architecture-breakdown.md) +2. [model-and-tooling-map.md](./model-and-tooling-map.md) +3. [lime-gap-analysis.md](./lime-gap-analysis.md) +4. [../../roadmap/ai-layered-design/README.md](../../roadmap/ai-layered-design/README.md) +5. [../../roadmap/ai-layered-design/architecture.md](../../roadmap/ai-layered-design/architecture.md) +6. [../../roadmap/ai-layered-design/implementation-plan.md](../../roadmap/ai-layered-design/implementation-plan.md) +7. [../../roadmap/ai-layered-design/architecture-diagrams.md](../../roadmap/ai-layered-design/architecture-diagrams.md) +8. [../../roadmap/ai-layered-design/prototype.md](../../roadmap/ai-layered-design/prototype.md) +9. [../../roadmap/ai-layered-design/sequences.md](../../roadmap/ai-layered-design/sequences.md) +10. [../../roadmap/ai-layered-design/flowcharts.md](../../roadmap/ai-layered-design/flowcharts.md) + +## 6. 参考事实源 + +外部资料只作为研究参考,Lime 的实现决策以后续 roadmap 为准: + +1. Lovart Edit Element / Canvas 相关公开文档:用于理解 flat image 到 editable layers 的产品表达。 +2. OpenAI `gpt-image-2` 模型页:确认其定位是高质量图像生成与编辑模型,并支持 Image generation / Image edit 端点。 +3. OpenAI image generation guide:确认 GPT Image 系列的生成、编辑、透明背景、尺寸、质量、格式与限制口径。 +4. Segment Anything / SAM:用于理解提示式实例分割和 mask 生成。 +5. LaMa / inpainting 类方法:用于理解对象移除后的背景修补。 +6. PSD / Canvas 开源生态:用于理解图层协议和导出边界。 + +## 7. 与 Lime 路线图的关系 + +后续所有实现建议默认遵守以下顺序: + +1. 先读本目录,确认“外部产品到底实现了哪类能力”。 +2. 再读 [../../roadmap/ai-layered-design/README.md](../../roadmap/ai-layered-design/README.md),确认“Lime 决定怎么做”。 +3. 涉及 `@配图`、`@海报`、图片任务或媒体 artifact 时,回看 [../../aiprompts/command-runtime.md](../../aiprompts/command-runtime.md) 与相关媒体任务事实源。 + +一句话: + +**`research/ai-layered-design` 负责防止把模型当产品,`roadmap/ai-layered-design` 负责把启发收敛成 Lime current 主线。** diff --git a/docs/research/ai-layered-design/architecture-breakdown.md b/docs/research/ai-layered-design/architecture-breakdown.md new file mode 100644 index 000000000..90bb3a80c --- /dev/null +++ b/docs/research/ai-layered-design/architecture-breakdown.md @@ -0,0 +1,222 @@ +# AI 图层化设计架构拆解 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:解释 Lovart 类产品如何把 AI 生成图或上传图片变成可移动、可缩放、可重排和可导出的图层工程。 + +## 1. 总判断 + +这类产品不是靠某个模型直接吐出完整 PSD。 + +更准确的系统形态是: + +```text +图像生成 / 上传图片 + -> 语义理解 + -> 实例分割 / mask + -> RGBA 图层提取 + -> 边缘精修 / matting + -> 背景 clean plate 修补 + -> 文字 OCR 与 TextLayer 重建 + -> Canvas 图层状态管理 + -> PNG / JSON / PSD-like 导出 +``` + +其中最关键的不是“生成图”,而是 **layer decomposition + clean plate reconstruction + non-destructive canvas editing**。 + +## 2. 两条技术路线 + +### 2.1 原生分层生成 + +用户一开始就让系统做海报、封面、商品图或游戏视觉时,最稳的路线是先规划图层,再分别生成资产: + +```text +用户目标 + -> Layer Planner + -> 背景层 prompt + -> 主体层 prompt + -> 特效层 prompt + -> Logo / 标题层 prompt + -> 真实 TextLayer 文案 + -> Canvas 合成 +``` + +优点: + +1. 每个元素天然是独立资产。 +2. 单层可重生成,不必整体重做。 +3. 文字可以直接成为 TextLayer。 +4. 更容易保持设计工程的可解释性。 + +缺点: + +1. 图层之间的光影、遮挡和风格一致性需要额外协调。 +2. Logo、人物、背景等资产可能需要多次生成和筛选。 +3. 不适合直接还原用户已有的扁平图。 + +### 2.2 扁平图后处理拆层 + +用户上传已有图片或系统只有最终海报图时,需要从扁平图反推出主要图层: + +```text +flat.png + -> VLM / OCR 识别对象和文字 + -> SAM / RMBG 生成 mask + -> 原图乘 mask 得到 RGBA 图层 + -> 原图擦除对象区域 + -> inpainting 生成 clean background + -> Canvas 里恢复图层对象 +``` + +优点: + +1. 可以编辑已有图、竞品图、旧海报或模型一次性生成结果。 +2. 更接近 Lovart “Edit Elements” 这类用户体验。 + +缺点: + +1. 原图没有真实被遮挡背景,只能靠修补猜。 +2. 头发、烟雾、透明材质和复杂 Logo 容易出边缘问题。 +3. 艺术字通常难以变成真实可编辑字体。 + +## 3. 关键子系统 + +### 3.1 Layer Planner + +Planner 不是普通 prompt 改写器,而是把设计任务拆成可编辑对象: + +```text +背景层:暗黑冥界场景,无文字,无人物 +主体层:白发女巫,透明背景或可抠图纯色背景 +特效层:绿色魔法烟雾,可半透明叠加 +Logo 层:HADES II 风格标题,可作为 raster logo +正文层:真实 TextLayer +按钮层:ShapeLayer + TextLayer +``` + +Planner 输出应包含: + +1. 图层名称。 +2. 图层类型。 +3. prompt / 文案。 +4. 推荐位置和尺寸。 +5. zIndex。 +6. 是否需要透明通道。 +7. 是否允许单层重生成。 + +### 3.2 Mask 与 RGBA 图层 + +mask 是从扁平图进入图层系统的桥: + +```text +layer.rgb = original.rgb +layer.alpha = mask +``` + +基础 mask 还不够,商业可用需要继续处理: + +1. alpha matting。 +2. 边缘羽化。 +3. 白边/黑边去污染。 +4. 小碎片删除。 +5. mask 洞填充。 +6. 半透明烟雾 soft alpha。 + +### 3.3 Clean Plate + +能“随意移动图层”的前提,是背景层已经补齐被主体挡住的区域: + +```text +原图 + 主体 mask + -> 移除主体 + -> inpainting 补背景 + -> clean background layer +``` + +没有 clean plate,用户移动人物后会看到原位置的空洞或残影。 + +### 3.4 TextLayer 重建 + +普通文本不应该默认烘焙成图片层: + +```text +OCR 检测文字区域 + -> 识别文本内容 + -> 估计字号、颜色、对齐、阴影 + -> 生成 Canvas TextLayer + -> 原图文字区域可选 inpaint +``` + +边界: + +1. 普通标题、正文、按钮文案应重建为 TextLayer。 +2. 艺术 Logo、复杂金属字、游戏标题首期可保留为 ImageLayer。 +3. 字体精确匹配不是 P0 目标。 + +### 3.5 Canvas 状态管理 + +真正承载可编辑性的不是模型,而是 Canvas 文档: + +```text +LayeredDesignDocument + -> canvas + -> layers[] + -> assets[] + -> preview + -> editHistory +``` + +用户拖动、缩放和排序时,本质只是更新图层 transform,不重新调用模型。 + +## 4. ControlNet Seg 的位置 + +ControlNet Seg 是构图控制工具,不是图层系统。 + +它适合解决: + +1. 人物大概放在哪里。 +2. 背景、天空、建筑、产品区域如何分布。 +3. 生成模型按 segmentation map 遵守布局。 + +它不能直接解决: + +1. 输出可编辑图层。 +2. 背景 clean plate。 +3. TextLayer 重建。 +4. PSD 导出。 + +因此在 Lime 里,ControlNet Seg 只能作为可选上游生成约束: + +```text +Layout / Seg Map + -> 图像生成 + -> 图层规划或拆层 + -> Lime Canvas 文档 +``` + +## 5. `gpt-image-2` 的位置 + +`gpt-image-2` 适合做三类事: + +1. 生成背景、主体、特效、Logo 等 bitmap 资产。 +2. 对已有图做局部编辑、风格统一和背景修补。 +3. 作为高保真图片输入编辑器,辅助单层重生成。 + +它不应承担: + +1. Lime 图层协议定义。 +2. Canvas 状态管理。 +3. PSD 兼容语义。 +4. 设计项目版本历史。 + +固定判断: + +**模型输出是资产,`LayeredDesignDocument` 才是设计工程事实源。** + +## 6. 对 Lime 的启发 + +1. 先做“生成时保留图层”,再做“已有图自动拆层”。 +2. 首期把普通文字变成 TextLayer,把艺术 Logo 保留为 ImageLayer。 +3. 背景修补是移动图层体验的硬门槛。 +4. 图层 JSON 和资源目录必须成为持久化对象,不能只存在前端状态。 +5. 单层重生成比整图重生成更符合设计师细调习惯。 diff --git a/docs/research/ai-layered-design/lime-gap-analysis.md b/docs/research/ai-layered-design/lime-gap-analysis.md new file mode 100644 index 000000000..a2f6204d6 --- /dev/null +++ b/docs/research/ai-layered-design/lime-gap-analysis.md @@ -0,0 +1,150 @@ +# AI 图层化设计对照 Lime 的偏差分析 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:明确 Lime 在 AI 图像设计能力上距离 Lovart 类“可编辑图层工程”还差什么,并为 `docs/roadmap/ai-layered-design/` 提供 current / compat / deprecated / dead 边界。 + +## 1. 总判断 + +Lime 不需要先训练模型。 + +当前真正缺的是: + +**把图片生成任务从“返回一张图片”升级为“返回一个可编辑设计工程”。** + +这意味着主线不应先追求更换图片模型,而应先建立: + +1. 图层文档协议。 +2. 分层生成编排。 +3. Canvas 编辑状态。 +4. 单层重生成。 +5. 背景 clean plate。 +6. 后续导出链路。 + +## 2. Lime 已经接近的部分 + +以下能力已经能作为起点: + +1. `@配图` / `@海报` 已有媒体任务语义,可以承载图像生成入口。 +2. Workspace 和 artifact / evidence 主链已经适合作为设计项目记录落点。 +3. Provider / model routing 已在 Lime 里逐步成为可治理能力,不必为 `gpt-image-2` 单独开旁路。 +4. Lime 的 `生成` 主舞台适合作为图层化设计入口,不需要新建平行设计 App。 +5. 现有文档体系已经能把外部研究和 Lime roadmap 分层保存。 + +## 3. 当前主要差距 + +### 3.1 结果形态仍偏向单图片 + +现在图片任务通常以单个 output artifact 为中心,缺少: + +1. 设计项目 JSON。 +2. 图层资产目录。 +3. 图层列表和 transform。 +4. 单层 provenance。 +5. 重新打开后继续编辑的状态。 + +### 3.2 生成过程没有强制保留中间资产 + +Lovart 类体验的关键是生成过程中就保留: + +1. 背景。 +2. 主体。 +3. 特效。 +4. Logo。 +5. 文本。 +6. 合成预览。 + +如果 Lime 只保存最终 PNG,后续只能进入更难的扁平图拆层。 + +### 3.3 文字仍容易被当作图片 + +设计师需要改文案,而不是移动一张文字截图。 + +首期必须把普通文案变成 TextLayer,至少覆盖: + +1. 标题。 +2. 副标题。 +3. 正文。 +4. CTA。 +5. 按钮文字。 + +### 3.4 缺少 clean plate 概念 + +如果从扁平图中抠出主体但不修补背景,用户移动主体后会露出洞。 + +这会直接破坏“图层可编辑”的可信度。 + +### 3.5 缺少设计专用 Canvas 工作区 + +聊天消息里的图片预览不等于图层编辑器。 + +至少需要: + +1. 图层栏。 +2. 画布。 +3. 选中框。 +4. 属性面板。 +5. 重生成/替换按钮。 +6. 导出入口。 + +## 4. current / compat / deprecated / dead 分类 + +### 4.1 current + +后续应继续强化的主路径: + +1. `生成` 作为 AI 图层化设计入口。 +2. `LayeredDesignDocument` 作为设计工程事实源。 +3. 原生分层生成作为首期能力。 +4. `gpt-image-2` / Gemini / 现有 provider seam 作为资产生成和编辑来源。 +5. 普通文案落为真实 TextLayer。 +6. Canvas 编辑器管理图层 transform、zIndex、visible、locked。 +7. 单层重生成不破坏其他图层状态。 + +### 4.2 compat + +可以过渡保留,但不应作为首期主叙事: + +1. 最终 PNG 图片任务。 + - 继续作为预览和导出结果,但不能替代设计工程事实源。 +2. 艺术 Logo 的 ImageLayer。 + - 首期可以是 raster 图层,后续再探索矢量化或可编辑文字。 +3. 扁平图主要对象拆层。 + - 作为增强能力,不承诺完整 PSD 还原。 +4. 黑底特效 + blend mode。 + - 可作为透明特效不稳定时的过渡方案。 + +### 4.3 deprecated + +不应继续扩展成主线的方向: + +1. 只优化 prompt,让模型一次性生成更好看的整图。 +2. 把图像编辑完全做成聊天指令,不沉淀图层状态。 +3. 前端临时保存图层,不落项目文件。 +4. 把 `gpt-image-2` 当成图层系统本身。 +5. 把普通文案烘焙成不可编辑图片层。 + +### 4.4 dead + +首期明确不做的方向: + +1. 自训 image-to-PSD 大模型。 +2. 承诺任意图片完美拆成 Photoshop 原始图层。 +3. 完整替代 Photoshop / Figma。 +4. 新增平行图片设计 runtime,绕过 Lime 媒体任务和 Workspace 主链。 +5. 为图层化设计单独创建不可治理 provider 旁路。 + +## 5. 对 roadmap 的直接要求 + +后续 `docs/roadmap/ai-layered-design/` 必须做到: + +1. 把外部产品参考留在 research 层。 +2. 把 Lime 的决定写成独立路线图。 +3. 把 `LayeredDesignDocument` 定义成 current 事实源。 +4. 首期以原生分层生成为主,不先挑战任意图拆层。 +5. 明确图像模型只是资产生成器,Canvas 文档才是工程交付物。 +6. 明确 PNG 是导出结果,不是唯一保存事实源。 + +一句话: + +**后续真正需要补的不是更强模型,而是从媒体 artifact 到可编辑设计工程的事实源升级。** diff --git a/docs/research/ai-layered-design/model-and-tooling-map.md b/docs/research/ai-layered-design/model-and-tooling-map.md new file mode 100644 index 000000000..ce20cc31e --- /dev/null +++ b/docs/research/ai-layered-design/model-and-tooling-map.md @@ -0,0 +1,156 @@ +# AI 图层化设计模型与工具地图 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:把可用于 Lime 图层化设计的现成模型、API、开源库和工程组件按能力分层,避免把“是否训练模型”误判成首要问题。 + +## 1. 总结论 + +Lime 首期不需要自训模型。 + +更现实的组合是: + +```text +LLM / VLM 做规划和识别 +GPT Image / Gemini / Flux 做生成与编辑 +SAM / RMBG 做 mask 和抠图 +inpainting 做 clean plate +OCR 做文字重建 +Canvas 做图层编辑 +导出器做 PNG / JSON / PSD-like 交付 +``` + +核心壁垒在 **图层协议、工作流编排、编辑体验和可验证交付**,不在单个模型。 + +## 2. 能力地图 + +| 能力 | 首期推荐 | 后续可选 | Lime 责任 | +| --- | --- | --- | --- | +| 图层规划 | 通用 LLM | 多模态 VLM + layout scorer | 定义图层计划 schema | +| 图片生成 | `gpt-image-2` 或现有 provider seam | Gemini / Flux / SDXL | 按层生成资产并记录 provenance | +| 图片编辑 | `gpt-image-2` edit / Gemini edit | Flux inpaint / SD inpaint | 保持单层替换不破坏文档 | +| 实例分割 | SAM / SAM2 | GroundingDINO + SAM | 把 mask 转成 ImageLayer | +| 背景移除 | RMBG / rembg / BiRefNet | 专用 matting 服务 | 输出 RGBA 和 alpha 质量标记 | +| 边缘精修 | alpha matting | Matting Anything | 消除白边、断发、脏边 | +| 背景修补 | 图像编辑模型 inpaint | LaMa / IOPaint | 生成 clean plate 背景层 | +| OCR | PaddleOCR / 系统 OCR / VLM | 字体识别模型 | 普通文本转 TextLayer | +| Canvas | Konva / Fabric / Pixi | 自研渲染器 | 管理 transform、zIndex、选中态 | +| 导出 | PNG + JSON | PSD writer / ag-psd / psd-tools | 保留图层语义和资源引用 | + +## 3. `gpt-image-2` 接入判断 + +根据 2026-05-05 可见的 OpenAI 模型页,`gpt-image-2` 定位为图像生成与编辑模型,支持 Image generation 和 Image edit 端点。 + +Lime 使用时应遵守三条规则: + +1. **通过 provider capability 管理能力** + - 不在业务代码里假设所有模型都支持相同 size、quality、background、mask 或多图输入能力。 + +2. **透明通道走双路径** + - 如果当前 provider/model 明确支持 `background=transparent`,可以直接请求透明输出。 + - 如果不支持或效果不稳定,使用 RMBG/SAM/matting 后处理得到 RGBA。 + +3. **局部编辑不等于图层编辑** + - edit endpoint 只负责像素重绘,成功后仍要把结果写回 `LayeredDesignDocument` 的某个图层或 clean plate。 + +## 4. 推荐首期 provider contract + +图像 provider 返回结果不应只是图片 URL,而应包含可追踪上下文: + +```ts +type GeneratedAsset = { + id: string + kind: "background" | "subject" | "effect" | "logo" | "texture" | "clean_plate" + src: string + maskSrc?: string + prompt: string + modelId: string + provider: "openai" | "gemini" | "local" | "other" + width: number + height: number + hasAlpha: boolean + generationParams: Record +} +``` + +这能支持: + +1. 单层重生成。 +2. 失败重试。 +3. 设计过程回放。 +4. 成本和 provider 追踪。 +5. 后续 evidence pack 接入。 + +## 5. 分割与抠图组合 + +首期建议按场景选择: + +1. **生成时主体层** + - 让模型生成纯色或简单背景主体图。 + - 用 RMBG 抠成 RGBA。 + - 用 matting 修边。 + +2. **上传扁平图主要对象** + - 用 VLM 识别对象清单。 + - 用 SAM 根据 box / point / text prompt 生成 mask。 + - 对每个 mask 输出候选 ImageLayer。 + +3. **烟雾、光效、粒子** + - 优先生成黑底或透明输出。 + - 黑底素材可用 screen/lighten blend mode,或按亮度转 alpha。 + +## 6. 背景修补组合 + +移动图层前必须有 clean plate。 + +首期可以直接用图像编辑模型: + +```text +输入:原图 + 被移除对象 alpha mask +prompt:移除 mask 区域对象,并保持周围背景、光影、材质和风格一致 +输出:clean_background.png +``` + +后续再引入专用 inpainting: + +1. LaMa / IOPaint:快,适合本地对象移除。 +2. SD / Flux inpaint:适合风格化或复杂幻想背景。 +3. provider edit:适合高质量但成本更高的修补。 + +## 7. OCR 与文字重建 + +文字处理分两档: + +1. **普通文案** + - OCR 识别文字。 + - 估计字号、颜色、位置。 + - 生成真实 TextLayer。 + +2. **艺术字 / Logo** + - 首期作为 ImageLayer。 + - 允许用户替换或单层重生成。 + - 不承诺可编辑每个字符。 + +这条边界能避免首期陷入字体识别和矢量化黑洞。 + +## 8. Canvas 和导出 + +Canvas 侧首期需要: + +1. 图层列表。 +2. 选中框和 transform。 +3. 显示/隐藏/锁定。 +4. zIndex 重排。 +5. 单层替换。 +6. 导出 PNG。 +7. 保存/恢复项目 JSON。 + +PSD 后续再做,首期只需要保证 JSON 中的图层语义足够稳定,后续可映射到 PSD 图层。 + +## 9. 参考链接 + +1. OpenAI `gpt-image-2` 模型页:https://developers.openai.com/api/docs/models/gpt-image-2 +2. OpenAI image generation guide:https://platform.openai.com/docs/guides/image-generation +3. OpenAI Images API reference:https://platform.openai.com/docs/api-reference/images +4. Segment Anything paper:https://arxiv.org/abs/2304.02643 +5. LaMa object removal 介绍:https://research.samsung.com/blog/LaMa-New-Photo-Editing-Technology-that-Helps-Removing-Objects-from-Images-Seamlessly diff --git a/docs/research/codex-goal/README.md b/docs/research/codex-goal/README.md new file mode 100644 index 000000000..e2421a991 --- /dev/null +++ b/docs/research/codex-goal/README.md @@ -0,0 +1,620 @@ +# Codex `/goal` 研究笔记 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 来源:本地源码调研 `/Users/coso/Documents/dev/rust/codex` +> 目标:把 Codex `/goal` 拆成独立 runtime pattern,判断它对 Lime “一轮 agent turn -> 持续推进目标” 的启发与边界。 + +## 1. 为什么单独成文档 + +`/goal` 不应该放进 CreoAI 研究目录里当附属小节。 + +原因是它研究的不是 Tool-Maker Agent,也不是电商运营自动化,而是另一个更小、更底层的 runtime pattern: + +```text +persistent thread goal + -> idle continuation turn + -> completion audit + -> budget / pause / resume / complete 状态机 +``` + +它回答的问题是: + +**一个 agent turn 结束后,系统如何知道还要不要继续推进同一个目标。** + +这和 CreoAI 的三层架构有关,但不等同: + +| 研究对象 | 主要回答什么 | Lime 对应层 | +| --- | --- | --- | +| CreoAI / Tool-Maker Agent | 能力如何被生成、验证、注册、长期运行 | Skill Forge / skills pipeline 上游 | +| Codex `/goal` | 一个 thread goal 如何跨多轮 turn 自动续跑直到完成、暂停或耗尽预算 | Query Loop / automation job 上的目标推进控制环 | + +固定结论: + +**`/goal` 是独立研究对象;它可以作为 Lime Managed Objective 的参考,但不应被写成 CreoAI 或 Skill Forge 的子章节。** + +### 1.1 什么是 Thread Goal Loop + +`thread goal loop` 是本文对 Codex `/goal` 这类机制的简写,不是 Codex 源码里的单一类型名。 + +先拆开看: + +1. **thread** + - 指 Codex 的一个会话线程 / 工作上下文,不是操作系统线程。 + - goal 被持久绑定到 `thread_id`,所以它跟着同一个对话线程延续,而不是跟着某一次模型回复延续。 + +2. **goal** + - 指这个 thread 当前要持续推进的目标。 + - Codex 会保存目标文本、状态、token 预算、已用 token、已用时间、创建和更新时间。 + - 它不是 prompt 里一句“请继续努力”,而是 state DB 里的持久状态。 + +3. **loop** + - 指 runtime 在一轮 turn 结束并进入 idle 后,会检查这个 thread 是否仍有 active goal。 + - 如果满足续跑条件,runtime 会注入 continuation prompt,启动下一轮普通 task。 + - 下一轮执行结束后再审计目标是否完成;未完成就继续保持 active,等待下一次 idle continuation。 + +所以 `thread goal loop` 的完整含义是: + +```text +同一个会话线程上有一个持久目标 + -> 每轮执行结束后 runtime 检查目标是否仍 active + -> 空闲且安全时自动发起下一轮 continuation turn + -> 模型基于当前证据做 completion audit + -> 完成则 update_goal complete + -> 未完成则保持 active,下一次 idle 后继续 +``` + +它不是下面这些东西: + +1. 不是单次模型调用里的 `while` 循环。 +2. 不是 cron 定时任务。 +3. 不是 workflow DAG。 +4. 不是 Skill Forge。 +5. 不是 automation job。 +6. 不是 evidence pack。 + +更接近的心智模型是: + +```text +把“帮我完成这个目标”从一次回复,提升为 thread 上的一条持久控制状态。 +runtime 负责在合适的时机继续开下一轮 turn。 +模型负责在每轮里做事,并在证据足够时请求标记 complete。 +``` + +一个最小例子: + +```text +用户:/goal 把这个仓库的本地测试修到通过 + +第 1 轮: + Codex 读测试、修一部分问题、运行部分检查,但还没全绿。 + runtime 结算 token/time,goal 仍 active。 + +idle 后: + runtime 发现没有用户新输入、没有 pending interrupt、goal 仍 active。 + runtime 注入 continuation prompt,启动第 2 轮。 + +第 2 轮: + Codex 继续修剩余测试,运行验证。 + 如果证据显示目标完成,模型调用 update_goal complete。 + 否则 goal 继续 active,后续再续跑。 +``` + +这个词最关键的边界是: + +**loop 的拥有者是 runtime,不是模型自发“再来一轮”;goal 的作用域是 thread,不是 workspace 级业务任务。** + +## 2. 图纸入口 + +如果先想通过图理解 Thread Goal Loop,直接看: + +- [diagrams.md](./diagrams.md) + +该图纸包含:总体架构图、分层架构图、流程图、关键时序图、completion audit 时序图、状态机图、与 Lime Managed Objective 的对照图、最小心智原型图。 + +## 3. 源码事实地图 + +以下路径均相对本地 Codex 仓库: + +`/Users/coso/Documents/dev/rust/codex` + +### 3.1 Feature flag + +- `codex-rs/features/src/lib.rs:200` + - `Feature::Goals` + - 注释:启用 persisted thread goals 与 automatic goal continuation +- `codex-rs/features/src/lib.rs:1027` + - key:`goals` + - stage:`Experimental` + - 默认关闭 + - menu description:`Set a persistent goal Codex can continue over time` + +判断: + +**`/goal` 不是稳定默认能力,而是 experimental runtime feature。** + +### 3.2 TUI slash command + +- `codex-rs/tui/src/slash_command.rs:115` + - `/goal` 描述为:`set or view the goal for a long-running task` +- `codex-rs/tui/src/chatwidget/slash_dispatch.rs:624` + - 支持 `/goal ` + - 支持 `/goal clear` + - 支持 `/goal pause` + - 支持 `/goal resume` + +判断: + +**slash command 只是入口,不是能力本体。能力本体在 core runtime。** + +### 3.3 App-server protocol + +- `codex-rs/app-server-protocol/src/protocol/common.rs:492` + - `thread/goal/set` + - `thread/goal/get` + - `thread/goal/clear` +- `codex-rs/app-server-protocol/src/protocol/common.rs:1416` + - `thread/goal/updated` + - `thread/goal/cleared` +- `codex-rs/app-server-protocol/src/protocol/v2.rs:4218` + - `ThreadGoal` 结构 +- `codex-rs/app-server-protocol/src/protocol/v2.rs:4252` + - `ThreadGoalSetParams` + +判断: + +**Codex 把 goal 做成 thread-level protocol surface,而不是只在 TUI 内部实现。** + +### 3.4 State DB + +- `codex-rs/state/migrations/0029_thread_goals.sql:1` + - 表:`thread_goals` + - 主键:`thread_id` + - 字段:`goal_id / objective / status / token_budget / tokens_used / time_used_seconds / created_at_ms / updated_at_ms` +- `codex-rs/state/src/model/thread_goal.rs:12` + - 状态:`Active / Paused / BudgetLimited / Complete` + +判断: + +**目标是持久状态,不是 prompt 里的临时约定。** + +### 3.5 Core runtime + +核心文件: + +- `codex-rs/core/src/goals.rs` + +关键点: + +- `codex-rs/core/src/goals.rs:266` + - 注释明确 runtime policy:turn start、tool completion、budget steering、interrupt pause、thread resume restore、idle continuation。 +- `codex-rs/core/src/goals.rs:1062` + - `maybe_continue_goal_if_idle_runtime` +- `codex-rs/core/src/goals.rs:1067` + - active goal 空闲续跑启动逻辑 +- `codex-rs/core/src/goals.rs:1146` + - active goal continuation candidate 条件 +- `codex-rs/core/src/goals.rs:1292` + - Plan mode 不触发 goal continuation + +判断: + +**`/goal` 的关键价值是 runtime-owned continuation,而不是模型自发说“我继续”。** + +### 3.6 Model-visible tools + +- `codex-rs/tools/src/goal_tool.rs:12` + - `get_goal` + - `create_goal` + - `update_goal` +- `codex-rs/tools/src/goal_tool.rs:48` + - `create_goal` 只能在用户或 system/developer 明确要求时创建,不能从普通任务自动推断。 +- `codex-rs/tools/src/goal_tool.rs:62` + - `update_goal` 只暴露 `complete`。 +- `codex-rs/core/src/tools/handlers/goal.rs:161` + - handler 层再次拒绝非 complete 状态更新。 + +判断: + +**模型可以读取和完成 goal,但不能自行 pause、resume 或 budget-limit。控制权被分给用户和系统。** + +### 3.7 Continuation prompt + +- `codex-rs/core/templates/goals/continuation.md:1` + - `Continue working toward the active thread goal.` +- `codex-rs/core/templates/goals/continuation.md:17` + - 完成前必须做 completion audit。 +- `codex-rs/core/templates/goals/continuation.md:26` + - 只有审计证明目标实际完成,才调用 `update_goal complete`。 +- `codex-rs/core/templates/goals/budget_limit.md:1` + - budget 达到后,系统提示不再开始新的实质工作,只总结进展、剩余工作和下一步。 + +判断: + +**Codex 的完成判定主要依赖 prompt-level audit + `update_goal complete` 工具闭环。** + +## 4. `/goal` 的系统结构 + +可以稳定抽象为五层: + +```text +TUI / API Entry + -> ThreadGoal State + -> Goal Runtime Hooks + -> Idle Continuation Scheduler + -> Model Audit + update_goal +``` + +### 4.1 入口层 + +入口层包括: + +1. `/goal ` +2. `/goal pause` +3. `/goal resume` +4. `/goal clear` +5. `thread/goal/set|get|clear` + +它只负责创建、查询和用户控制,不负责执行目标。 + +### 4.2 状态层 + +状态层是 `thread_goals` 表。 + +它记录: + +1. 当前目标是什么。 +2. 当前状态是否 active。 +3. token 预算和已用 token。 +4. 已用时间。 +5. 创建和更新时间。 + +它不记录: + +1. 结构化任务 DAG。 +2. 多 step checklist。 +3. artifact refs。 +4. evidence refs。 +5. workspace-level job 关系。 + +### 4.3 Runtime hook 层 + +runtime 会监听: + +1. turn started。 +2. tool completed。 +3. turn finished。 +4. task aborted。 +5. external set / clear。 +6. thread resumed。 +7. maybe continue if idle。 + +它负责: + +1. 捕获 active goal 的 token baseline。 +2. 结算 token/time usage。 +3. budget 达到时注入 budget steering。 +4. interrupt 时暂停 active goal。 +5. resume 后恢复 runtime accounting。 + +### 4.4 Continuation scheduler 层 + +核心判断: + +```text +if feature enabled +and not Plan mode +and no active turn +and no queued input +and no pending trigger mailbox input +and persisted thread has active goal +then inject continuation developer prompt +and start a new regular task +``` + +这解释了为什么它能把“一轮 turn”升级为“多轮持续推进”。 + +### 4.5 Audit completion 层 + +模型在 continuation prompt 中被要求: + +1. 把 objective 转成 success criteria。 +2. 建 prompt-to-artifact checklist。 +3. 检查真实文件、命令输出、测试、PR 状态等证据。 +4. 不把代理信号直接当完成。 +5. 不确定就继续。 +6. 真完成才调用 `update_goal complete`。 + +固定判断: + +**`update_goal complete` 是模型可调用的完成出口,但完成判断仍是 prompt discipline,而不是强结构化 verifier。** + +## 5. 状态机 + +Codex `/goal` 的状态机很小: + +```text +active + -> paused 用户 pause 或 interrupt + -> budget_limited runtime 预算耗尽 + -> complete 模型 update_goal complete 或外部设置 + +paused + -> active 用户 resume + -> complete 外部设置 + -> clear 用户 clear + +budget_limited + -> complete 只有目标真的完成才 complete + -> clear 用户 clear + +complete + -> active replace goal 时新 goal active + -> clear 用户 clear +``` + +注意: + +1. 没有 `blocked`。 +2. 没有 `needs_input`。 +3. 没有 `failed`。 +4. 没有 `verifying`。 +5. 没有 `scheduled`。 + +这说明 Codex `/goal` 是 [thread goal loop](#11-什么是-thread-goal-loop),不是完整业务任务状态机。 + +## 6. 它到底是不是 RALF 类循环 + +可以说像,但要限定范围。 + +相似点: + +1. 都是“检查当前状态 -> 做下一步 -> 再检查 -> 直到完成”。 +2. 都强调不要把局部进展误判为完成。 +3. 都试图把单次 agent response 升级成持续推进。 + +不同点: + +1. Codex `/goal` 是 runtime-managed,不只是 prompt 手法。 +2. 它有持久 goal state 和 token/time accounting。 +3. 它会在 idle 时自动开 continuation turn。 +4. 它没有完整 workflow DAG 或 business process schema。 +5. 它的完成审计仍主要依赖模型执行 prompt checklist。 + +固定表述: + +**Codex `/goal` 是 RALF-like loop 的 runtime 化最小实现,不是完整业务 workflow engine。** + +## 7. 它不是什么 + +### 7.1 不是 Skill Forge + +Skill Forge 负责: + +```text +能力缺口 -> 生成 Skill Bundle / Adapter / Contract / Test -> Verification -> Registration +``` + +Codex `/goal` 负责: + +```text +active objective -> idle continuation turn -> audit -> complete / continue +``` + +二者一前一后: + +- Skill Forge 生产能力。 +- Goal loop 使用能力持续推进目标。 + +### 7.2 不是 skills pipeline + +skills pipeline 负责: + +1. 能力包标准。 +2. 产品投影。 +3. slot schema。 +4. runtime binding。 +5. catalog 发现。 + +`/goal` 负责: + +1. 目标持久化。 +2. 空闲续跑。 +3. 状态控制。 +4. 完成审计。 + +二者不冲突,但也不能互相替代。 + +### 7.3 不是 automation job + +automation job 负责: + +1. durable scheduling。 +2. 到点触发。 +3. 后台执行。 +4. 运行历史。 + +`/goal` 负责: + +1. 目标是否仍 active。 +2. 当前是否该继续下一轮。 +3. 什么时候标记 complete。 + +在 Lime 里,goal-like 机制应挂到 automation job 或 agent session 上,而不是替代 automation job。 + +### 7.4 不是 evidence pack + +evidence pack 负责记录事实: + +1. timeline。 +2. artifacts。 +3. tool calls。 +4. verification outcomes。 +5. request telemetry。 + +Codex `/goal` 本身不导出类似 Lime evidence pack 的结构化证据包。它通过 prompt 要求模型检查证据,但状态表不保存 artifact/evidence refs。 + +固定判断: + +**Lime 如果借鉴 `/goal`,完成审计必须比 Codex 更结构化,默认消费 evidence pack,而不能只靠模型自报。** + +## 8. 对 Lime 的映射 + +### 8.1 Lime 已有底座 + +Lime 当前已经有下面这些相关事实源: + +1. `docs/aiprompts/query-loop.md` + - `agent_runtime_submit_turn -> runtime_turn -> TurnInputEnvelope -> runtime_queue -> stream_reply_once -> timeline / artifact / memory -> thread_read / evidence / replay / review` +2. `docs/aiprompts/task-agent-taxonomy.md` + - 一等执行实体只有 `agent turn / subagent turn / automation job` +3. `docs/aiprompts/skill-standard.md` + - runtime binding 为 `agent_turn / browser_assist / automation_job / native_skill` +4. `docs/aiprompts/harness-engine-governance.md` + - evidence pack 是运行时事实源 +5. `src-tauri/src/services/automation_service/executor.rs` + - automation job 当前可把 payload 映射成一次 agent turn 并进入 runtime queue +6. `src-tauri/src/commands/aster_agent_cmd/prompt_context.rs` + - 已有 `auto_continue`,但它是文稿续写 prompt augmentation,不是持久目标 runtime + +### 8.2 Lime 当前缺口 + +Codex `/goal` 暴露出 Lime 的一个具体缺口: + +**Lime 有 durable scheduling 和 agent turn,但缺少一个持久 objective 驱动多轮 continuation 的控制层。** + +更细地说,缺四件事: + +1. **Objective state** + - 当前没有类似 `thread_goals` 的目标状态事实源。 + +2. **Idle continuation policy** + - 当前 runtime queue 能接下一条 queued turn,但没有“active objective 仍未完成时自动生成下一轮 continuation turn”的通用策略。 + +3. **Completion audit contract** + - 当前 evidence pack 很强,但还没有被 goal-like 状态机明确用作“完成审计输入”。 + +4. **Goal 与 automation/subagent/session 的绑定关系** + - 当前自动化可以跑一轮 agent turn,但“这个 job 的业务目标是否已完成、是否需要继续、是否 blocked”还不是统一事实。 + +### 8.3 Lime 不应照搬的部分 + +1. 不应新增第四类执行实体。 +2. 不应只做 `/goal` slash command。 +3. 不应把 goal 状态只绑定 thread。 +4. 不应只靠模型调用 `update_goal complete` 判断完成。 +5. 不应绕过 automation job 做新的 durable scheduler。 +6. 不应让 goal loop 反过来定义 Skill / ServiceSkill / Adapter 标准。 + +## 9. Lime 借鉴方向 + +后续如果进入 roadmap,建议把这层暂称为: + +**Managed Objective Layer** + +但它必须是控制层,不是新的 runtime taxonomy。 + +推荐落位: + +```text +agent session / automation job + -> objective state + -> continuation policy + -> Query Loop agent turn + -> artifact / evidence + -> audit result + -> continue / pause / needs_input / blocked / complete +``` + +最小对象不应直接照抄 Codex,而应结合 Lime: + +```text +objective_id +workspace_id +session_id? +automation_job_id? +root_turn_id? +status +objective_text +success_criteria[] +budget_policy +risk_policy +approval_policy +artifact_refs[] +evidence_pack_refs[] +last_audit_summary +created_at +updated_at +``` + +推荐状态比 Codex 更多: + +```text +active +paused +needs_input +blocked +budget_limited +complete +failed +``` + +原因: + +1. Lime 有业务自动化,不只是 coding thread。 +2. Lime 有 slot filling / elicitation。 +3. Lime 有外部权限、浏览器登录态、workspace artifact。 +4. Lime 有 evidence pack,可以支持更强审计。 + +## 10. 和 CreoAI 研究的关系 + +Codex `/goal` 可以补 CreoAI 三层架构里的第二层视角: + +```text +CreoAI 三层: +Coding Agent / Agent Builder + -> Autonomous Execution / Runtime + -> Workspace / Agent App Surface + +Codex /goal 对应: +Autonomous Execution / Runtime 里的 persistent objective + continuation loop 子模式 +``` + +但它不覆盖: + +1. Tool-Maker Agent。 +2. Generated Capability Draft。 +3. Skill / Adapter 编译。 +4. Workspace-local skill catalog。 +5. 业务任务中心。 + +因此两个研究目录应分工: + +- `docs/research/creaoai/`:研究“能力如何被 agent 生成并长期运行”。 +- `docs/research/codex-goal/`:研究“目标如何跨 turn 被 runtime 持续推进”。 + +## 11. 研究结论 + +1. Codex `/goal` 是独立 runtime pattern,不是普通 slash command。 +2. 它把 thread-level objective 持久化,并在 idle 时自动启动 continuation turn。 +3. 它的完成闭环依赖 model-visible `update_goal complete` 与 continuation prompt 的 completion audit。 +4. 它比纯 prompt RALF 稳,因为 runtime 负责状态、预算、暂停、恢复和续跑。 +5. 它又比完整业务 workflow 小,因为没有 DAG、workspace job、artifact/evidence refs 和复杂阻塞态。 +6. 对 Lime 的真正启发是补一层 Managed Objective,而不是复制 `/goal` 命令。 +7. Managed Objective 必须折回 Lime 现有 `agent turn / subagent turn / automation job` taxonomy,不允许成为第四类 runtime。 +8. Lime 如果实现这层,完成审计应消费 evidence pack,而不是只靠模型自报。 + +## 12. 已落成的 Lime 路线图 + +这个研究已经落成独立 Lime roadmap: + +1. `docs/roadmap/managed-objective/README.md` +2. `docs/roadmap/managed-objective/architecture.md` +3. `docs/roadmap/managed-objective/implementation-plan.md` +4. `docs/roadmap/managed-objective/diagrams.md` + +它不直接塞进 `docs/roadmap/creaoai/`,除非只是说明两者关系。 + +一句话: + +**Codex `/goal` 研究应服务 Lime 的目标推进控制层;CreoAI 研究应服务 Lime 的能力生成与长期业务自动化闭环。两者有关,但必须分开建模。** diff --git a/docs/research/codex-goal/diagrams.md b/docs/research/codex-goal/diagrams.md new file mode 100644 index 000000000..d4eb0a4c7 --- /dev/null +++ b/docs/research/codex-goal/diagrams.md @@ -0,0 +1,293 @@ +# Codex `/goal` Thread Goal Loop 图纸 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 来源:本地源码调研 `/Users/coso/Documents/dev/rust/codex` +> 目标:用架构图、时序图、流程图和状态图解释 Codex `/goal` 的 thread goal loop,避免把它误读成普通 slash command 或完整 workflow engine。 + +配套文档: + +- [./README.md](./README.md) + +## 1. 总体架构图 + +```mermaid +flowchart TB + User[用户 / TUI / App Server] --> Entry[/goal set / pause / resume / clear] + Entry --> Protocol[thread/goal protocol surface] + Protocol --> State[(thread_goals state DB)] + + State --> Runtime[core goals runtime] + Runtime --> Hooks[turn start / tool complete / turn finish / abort / resume] + Hooks --> Accounting[token / time accounting] + Hooks --> Idle[maybe_continue_goal_if_idle_runtime] + + Idle --> Guard{Continuation guard} + Guard -->|不满足| Wait[保持 idle / 等用户输入] + Guard -->|满足| Prompt[注入 continuation prompt] + Prompt --> Turn[启动下一轮 regular task] + + Turn --> Tools[模型执行工具 / 修改文件 / 跑命令] + Tools --> Audit[completion audit] + Audit --> UpdateGoal[model-visible update_goal complete] + UpdateGoal --> State + Audit -->|未完成| State +``` + +固定判断: + +1. `/goal` 命令只是入口。 +2. `thread_goals` 是持久目标状态。 +3. `core goals runtime` 才是 loop 的拥有者。 +4. continuation turn 仍是一轮普通 task。 +5. 完成出口是 `update_goal complete`,不是自然语言自报。 + +## 2. 分层架构图 + +```mermaid +flowchart LR + subgraph EntryLayer[入口层] + Slash[/goal slash command] + API[thread/goal API] + end + + subgraph StateLayer[状态层] + DB[(thread_goals)] + Status[active / paused / budget_limited / complete] + end + + subgraph RuntimeLayer[Runtime hook 层] + TurnStart[turn start] + ToolDone[tool complete] + TurnFinish[turn finish] + Abort[abort / interrupt] + Resume[thread resume] + end + + subgraph ContinuationLayer[续跑层] + IdleCheck[idle continuation check] + Budget[budget steering] + Prompt[continuation developer prompt] + end + + subgraph ModelLayer[模型审计层] + Work[执行下一步] + Audit[completion audit] + Complete[update_goal complete] + end + + Slash --> DB + API --> DB + DB --> TurnStart + DB --> Resume + TurnStart --> Budget + ToolDone --> Budget + TurnFinish --> IdleCheck + Abort --> Status + IdleCheck --> Prompt + Prompt --> Work + Work --> Audit + Audit --> Complete + Complete --> DB +``` + +固定判断: + +**Thread Goal Loop 是 runtime pattern,不是 UI pattern。** + +## 3. Thread Goal Loop 流程图 + +```mermaid +flowchart TD + Start[用户设置 /goal objective] --> Persist[持久化 thread goal 为 active] + Persist --> Turn[执行当前 turn] + Turn --> Finish[turn 结束] + Finish --> Settle[结算 token / time] + Settle --> Active{goal 是否仍 active} + + Active -->|否| Stop[停止续跑] + Active -->|是| Idle{runtime 是否 idle} + Idle -->|否| Wait[等待 active turn / queued input 结束] + Idle -->|是| Guard{是否满足续跑 guard} + + Guard -->|否| Wait + Guard -->|是| Continue[注入 continuation prompt] + Continue --> NextTurn[启动下一轮 regular task] + NextTurn --> Evidence[读取文件 / 命令输出 / 工具结果] + Evidence --> Audit{completion audit 是否完成} + + Audit -->|完成| Complete[调用 update_goal complete] + Complete --> Done[goal complete] + Audit -->|未完成| Turn +``` + +续跑 guard 至少包括: + +1. feature flag 开启。 +2. 不是 Plan mode。 +3. 没有 active turn。 +4. 没有 queued input。 +5. 没有 pending trigger mailbox input。 +6. persisted thread 有 active goal。 +7. 预算未进入 budget-limited 停止条件。 + +## 4. 关键时序图:从 `/goal` 到自动续跑 + +```mermaid +sequenceDiagram + participant U as 用户 + participant T as TUI / App Server + participant S as State DB + participant R as Goal Runtime + participant M as Model Task + participant G as Goal Tool + + U->>T: /goal 修好本仓库测试 + T->>S: 写入 thread_goals(active) + T->>R: 通知 goal updated + R->>M: 执行当前 regular task + M-->>R: turn finished + R->>S: 结算 token / time + R->>R: maybe_continue_goal_if_idle_runtime + + alt 不满足 guard + R-->>T: 不启动 continuation + else 满足 guard + R->>M: 注入 continuation prompt 并启动下一轮 task + M->>M: 执行工具 / 检查证据 / 修复问题 + M->>G: update_goal complete 或保持未完成 + G->>S: 如果 complete,更新 goal status + end +``` + +固定判断: + +**续跑不是模型自己在回复里递归调用自己,而是 runtime 在 idle 时启动下一轮 task。** + +## 5. Completion Audit 时序图 + +```mermaid +sequenceDiagram + participant R as Goal Runtime + participant M as Model + participant FS as Files / Commands / Tool Output + participant G as update_goal tool + participant S as thread_goals + + R->>M: continuation prompt 要求继续目标 + M->>M: 把 objective 转成 success criteria + M->>FS: 检查真实文件、命令输出、测试结果 + FS-->>M: 返回证据 + M->>M: prompt-to-artifact checklist + + alt 证据足够完成 + M->>G: update_goal complete + G->>S: status = complete + else 证据不足或未完成 + M-->>R: 继续工作或汇报未完成状态 + R->>S: goal 保持 active + end +``` + +Codex 的限制: + +1. audit discipline 主要靠 continuation prompt 约束。 +2. `thread_goals` 不保存 artifact refs。 +3. 没有 Lime 式 evidence pack。 +4. 因此 Lime 不能只照搬 `update_goal complete`,必须接结构化 evidence audit。 + +## 6. 状态机图 + +```mermaid +stateDiagram-v2 + [*] --> active: set goal + + active --> paused: user pause / interrupt + active --> budget_limited: budget reached + active --> complete: update_goal complete / external set + + paused --> active: user resume + paused --> complete: external set complete + paused --> [*]: clear + + budget_limited --> complete: audit confirms done + budget_limited --> [*]: clear + + complete --> active: replace goal + complete --> [*]: clear +``` + +这个状态机说明: + +1. Codex `/goal` 没有 `needs_input`。 +2. Codex `/goal` 没有 `blocked`。 +3. Codex `/goal` 没有 `failed`。 +4. Codex `/goal` 没有 `scheduled`。 +5. 所以它是 thread goal loop,不是业务任务状态机。 + +## 7. 与 Lime Managed Objective 对照图 + +```mermaid +flowchart LR + subgraph CodexGoal[Codex /goal] + CGThread[thread-scoped goal] + CGIdle[idle continuation] + CGAudit[prompt audit] + CGComplete[update_goal complete] + end + + subgraph LimeObjective[Lime Managed Objective] + LOOwner[owner: session / subagent / automation job] + LOGuard[continuation policy] + LOAudit[evidence-based audit] + LOState[active / needs_input / blocked / completed] + end + + CGThread -->|借鉴持久目标| LOOwner + CGIdle -->|借鉴 idle continuation| LOGuard + CGAudit -->|升级为结构化审计| LOAudit + CGComplete -->|升级为状态机结果| LOState + + CGThread -.不能照搬.-> GoalRuntime[goal_runtime] + CGAudit -.不能照搬.-> ModelOnlyComplete[模型自报完成] +``` + +固定判断: + +1. Lime 借鉴的是 runtime continuation pattern。 +2. Lime 不照搬 thread-only 作用域。 +3. Lime 不照搬 prompt-only audit。 +4. Lime 不新增 `goal_runtime`。 + +## 8. 最小心智原型图 + +这是 Codex `/goal` 对用户可见的最小交互原型,不是 Lime UI 方案: + +```text +┌────────────────────────────────────────────────────────────┐ +│ Codex Thread │ +├────────────────────────────────────────────────────────────┤ +│ Active Goal │ +│ 目标:修好本仓库测试 │ +│ 状态:active │ +│ 预算:token 80k / 已用 31k │ +├────────────────────────────────────────────────────────────┤ +│ 当前 turn │ +│ - 已运行测试:失败 3 个 │ +│ - 已修复:配置路径、mock 数据 │ +│ - 仍需继续:剩余 1 个 flaky case │ +├────────────────────────────────────────────────────────────┤ +│ Runtime decision │ +│ idle: yes │ +│ queued input: no │ +│ plan mode: no │ +│ decision: start continuation turn │ +├────────────────────────────────────────────────────────────┤ +│ 下一轮 │ +│ Continue working toward the active thread goal... │ +└────────────────────────────────────────────────────────────┘ +``` + +这个原型只用于理解机制: + +**用户感受到的是“目标还在继续推进”,真实实现是 runtime 在 thread 级状态上续开 turn。** diff --git a/docs/research/creaoai/README.md b/docs/research/creaoai/README.md new file mode 100644 index 000000000..551ee1011 --- /dev/null +++ b/docs/research/creaoai/README.md @@ -0,0 +1,118 @@ +# CreoAI 研究总入口 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:把视频转述中的 CreoAI / Career AI / CreaoIO 案例拆成可持续对照的研究事实源,供 Lime 后续规划校准“Coding Agent 编码工具并长期运行业务”的产品范式。 + +## 1. 命名与来源边界 + +用户转述中出现了 `career AI`、`creaoio`、`creaoai` 等名称差异。本文档统一称为 **CreoAI**,只分析视频转述中体现的架构范式。 + +固定边界: + +1. 本目录不把用户数、融资额、团队背景等转述内容写成已核验事实。 +2. 本目录不评估 CreoAI 公司真实性、商业数据或投资信息。 +3. 本目录只沉淀对 Lime 有用的产品与工程启发。 + +一句话: + +**这里研究的是“Tool-Maker Agent / 长时自治工作流”这类范式,不是做外部公司尽调。** + +## 2. 目录定位 + +`docs/research/creaoai/` 只回答两类问题: + +1. 视频里的三层架构和工具编码编排到底是什么。 +2. Lime 应该学它的哪一层,不应该照搬哪一层。 + +这里是**研究目录**,不是 Lime 的产品决策目录。 + +固定分工: + +1. `docs/research/creaoai/` 负责外部案例拆解和风险识别。 +2. `docs/roadmap/creaoai/` 负责 Lime 自己的开发计划。 +3. [../codex-goal/README.md](../codex-goal/README.md) 单独研究 Codex `/goal` 这类 persistent objective / continuation loop,不再塞进 CreoAI 研究目录。 +4. 代码实现仍必须回到 Lime 现有 current 主链:`skills pipeline / Query Loop / tool_runtime / Workspace / evidence pack`。 + +## 3. 为什么单独建立这一层 + +这个案例最容易被误读成: + +1. 又一个工作流自动化工具。 +2. 又一个电商运营垂类 agent。 +3. 又一个“AI 会调用 API”的工具集合。 + +真正值得拆出来的是: + +**Coding Agent 不只是调用工具,而是把 CLI、API、网页流程编码成新的可复用能力,再把这些能力纳入长期执行。** + +这和 Lime 当前的 skills pipeline 高度相关。如果不单独建研究目录,后续容易出现两种跑偏: + +1. 另造一套 `generated tools runtime`,和现有 Skill / tool registry / evidence 主链冲突。 +2. 只把它理解成“多接几个 API / MCP”,错过“能力生成、验证、注册、复用”的关键闭环。 + +## 4. 固定研究结论 + +当前研究先固定以下结论: + +1. **三层架构不是页面结构** + - 它更像 `Coding Agent -> Autonomous Execution -> Workspace` 的系统分层。 + +2. **核心不是全自动电商运营** + - 电商只是 demo。真正能力是把明确工作流编译成长期运行的 agent app。 + +3. **Coding Agent 是工具生产者** + - 它要能读取 API / CLI / 文档 / 网页流程,生成 adapter、script、contract、test。 + +4. **执行必须有 harness** + - 自动执行必须受权限、dry-run、测试、证据和人工确认约束。 + +5. **对 Lime 不应新增平行标准** + - 动态生成能力必须编译进 Lime 现有 Skill Bundle / ServiceSkill / Adapter Spec / tool_runtime 主链。 + +## 5. 建议阅读顺序 + +1. [architecture-breakdown.md](./architecture-breakdown.md) +2. [tool-coding-orchestration.md](./tool-coding-orchestration.md) +3. [lime-gap-analysis.md](./lime-gap-analysis.md) +4. [../pi-mono-coding-agent/README.md](../pi-mono-coding-agent/README.md) +5. [../codex-goal/README.md](../codex-goal/README.md) +6. [../../roadmap/creaoai/README.md](../../roadmap/creaoai/README.md) +7. [../../roadmap/creaoai/implementation-plan.md](../../roadmap/creaoai/implementation-plan.md) +8. [../../roadmap/creaoai/diagrams.md](../../roadmap/creaoai/diagrams.md) + +## 6. 固定不照搬的东西 + +以下内容默认不直接搬进 Lime: + +1. 电商运营垂类定位。 +2. “零门槛全自动”的营销叙事。 +3. 不经权限审查的自动发布、自动下单、自动改价。 +4. 平行的 workflow builder、scheduler、tool registry 或 evidence 系统。 +5. 把 agent 生成代码直接当成用户不可见黑盒执行。 + +Lime 真正要学的是: + +1. Coding Agent 生成可复用能力。 +2. CLI / API / 网页流程被编译为标准 adapter。 +3. 长时任务可以关窗继续跑。 +4. Workspace 沉淀业务上下文、产物、记忆和证据。 +5. 自动执行和治理 harness 必须同时存在。 + +补充参考: + +1. [../pi-mono-coding-agent/README.md](../pi-mono-coding-agent/README.md) 不是 CreoAI 公司研究,而是本地开源 coding harness 对照。 +2. 它用于回答“Lime 缺的 Coding Agent 层工程上怎么切”。 +3. 当前结论是:参考 pi-mono 的 `AgentSession` 分层、工具 allowlist、可插拔工具后端、事件与测试 harness;不复制它的终端产品、JSONL session 事实源或全仓库 shell/write 权限。 + +## 7. 与 Lime 路线图的关系 + +后续所有实现建议默认遵守以下顺序: + +1. 先读本目录,确认外部范式到底启发了什么。 +2. 再读 [../../roadmap/creaoai/README.md](../../roadmap/creaoai/README.md),确认 Lime 决定怎么做。 +3. 涉及 skill / adapter / runtime binding 时,回看 [../../aiprompts/skill-standard.md](../../aiprompts/skill-standard.md) 与 [../../aiprompts/query-loop.md](../../aiprompts/query-loop.md)。 + +一句话: + +**`research/creaoai` 负责防止误读外部案例,`roadmap/creaoai` 负责把启发收敛成 Lime current 主线。** diff --git a/docs/research/creaoai/architecture-breakdown.md b/docs/research/creaoai/architecture-breakdown.md new file mode 100644 index 000000000..ffd31eaf6 --- /dev/null +++ b/docs/research/creaoai/architecture-breakdown.md @@ -0,0 +1,178 @@ +# CreaoAI 三层架构拆解 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:把视频转述中的三层架构拆成稳定系统层次,避免误读成“电商自动化 demo”或“预设 API 编排器”。 + +## 1. 先给结论 + +视频里真正值得关注的不是某个电商流程,而是这条产品结构: + +```text +用户讲清楚目标 + -> Coding Agent 把目标编码成工具和流程 + -> Autonomous Execution 让流程长期运行 + -> Workspace 沉淀记忆、产物、配置和证据 +``` + +可以稳定拆成三层: + +1. **Coding Agent / Agent Builder** +2. **Autonomous Execution / Runtime** +3. **Workspace / Agent App Surface** + +这三层不是页面 IA,而是 agent 产品的系统骨架。 + +## 2. 第一层:Coding Agent / Agent Builder + +这一层的职责不是普通聊天,而是: + +**把自然语言业务目标编译成可执行能力。** + +它需要完成的工作包括: + +1. 理解用户目标和成功标准。 +2. 拆解任务链路和触发条件。 +3. 判断需要哪些外部能力。 +4. 读取 API 文档、CLI help、网页流程或平台说明。 +5. 生成 adapter、wrapper、script、workflow 和测试。 +6. 输出可复用的能力包,而不是一次性回答。 + +示例: + +```text +“每天监控竞品爆款,发现趋势后找货、生成素材、写文案、给出定价建议” + -> 竞品数据抓取 adapter + -> 1688 找货脚本 + -> 图片 / 视频 / 文案生成流程 + -> 定价规则模块 + -> 输出 schema 与权限声明 +``` + +固定判断: + +**这一层的核心是 tool-maker,不是 tool-user。** + +## 3. 第二层:Autonomous Execution / Runtime + +这一层负责让第一层生成的能力真正跑起来。 + +核心职责: + +1. 定时、手动、webhook 或事件触发。 +2. 任务排队、恢复、重试和降级。 +3. 用户关掉浏览器或重启后继续执行。 +4. 管理权限、预算、沙箱和人工确认。 +5. 执行 CLI、API、浏览器、MCP、脚本和 workspace 工具。 +6. 在失败时请求输入或进入阻塞态。 +7. 把执行事实写入 timeline、artifact 和 evidence。 + +它不是简单 cron,因为 agent 任务经常需要: + +1. 动态补上下文。 +2. 根据中间结果调整后续步骤。 +3. 在外部平台失败时重新规划。 +4. 对高风险动作进行人工确认。 + +推荐状态模型: + +```text +planned -> running -> verifying -> completed + -> needs_input + -> blocked + -> failed +``` + +固定判断: + +**这一层的价值是把一次 agent turn 变成可持续推进的业务任务。** + +### 3.1 Codex `/goal` 在这一层的位置 + +[Codex `/goal` 研究](../codex-goal/README.md) 单独说明了一个更小的 runtime pattern: + +```text +persistent thread goal(同一会话线程上的持久目标状态) + -> idle continuation turn + -> completion audit + -> budget / pause / resume / complete +``` + +它能解释“如何把一轮 agent turn 续成多轮目标推进”,但不能代表完整 CreoAI 三层架构: + +1. 它不负责生成 Skill / Adapter / Contract / Test。 +2. 它不负责 workspace-local skill catalog。 +3. 它不负责业务 workflow DAG 或多平台自动化。 +4. 它不导出 Lime 式 evidence pack。 + +固定边界: + +**Codex `/goal` 是 Autonomous Execution 层的目标续跑参考,不是 Coding Agent / Skill Forge,也不是完整业务 workflow runtime。** + +## 4. 第三层:Workspace / Agent App Surface + +这一层是用户真正感知产品价值的地方。 + +核心职责: + +1. 保存业务上下文、目标、约束和偏好。 +2. 保存账号配置、API 连接、浏览器登录态和权限边界。 +3. 保存 agent 生成的 adapter、skill、script、workflow、测试。 +4. 展示任务中心、阻塞点、产物、执行历史。 +5. 沉淀运行结果、反馈、记忆和复盘。 +6. 暴露 evidence、review、replay 和人工确认入口。 + +没有 workspace,agent 每次都是临时工;有了 workspace,agent 才像一个持续工作的业务员工。 + +固定判断: + +**Workspace 不是文件夹,而是 agent app 的运行与记忆容器。** + +## 5. 三层合成链路 + +```text +用户目标 + -> Coding Agent 拆解并生成能力 + -> 生成 Skill / Adapter / Script / Contract / Test + -> Runtime 验证、注册、调度、执行 + -> Workspace 保存配置、任务、产物、证据 + -> 用户复盘并调整目标 + -> Coding Agent 继续改进能力 +``` + +这个闭环解释了为什么用户会感知到: + +**“我关掉浏览器,它还在干活。”** + +关键不是后台线程一直在跑,而是系统同时具备: + +1. 可复用能力。 +2. 长期执行纪律。 +3. 可追踪证据。 +4. 可持续改进的 workspace 记忆。 + +## 6. 对 Lime 的映射 + +| CreoAI 层级 | Lime 中应收敛到的主链 | 不应新增的旁路 | +| --- | --- | --- | +| Coding Agent / Agent Builder | Skill Forge、Agent Skill Bundle、Adapter Spec、ServiceSkill 投影 | 平行 generated tool 类型 | +| Autonomous Execution | Query Loop、runtime_queue、tool_runtime、automation job、subagent | 独立 scheduler / workflow runtime | +| Workspace / Agent App Surface | Workspace、Skill Catalog、Artifact、Task Center、Evidence Pack | 单场景自建状态与证据系统 | + +一句话: + +**Lime 不需要复制一个 CreoAI,而是把这三层折回现有 skills pipeline 与 Harness Engine。** + +## 7. 关键风险 + +1. **无约束代码生成** + - agent 写出的 adapter 如果直接执行,会放大安全和质量风险。 + +2. **双事实源** + - 如果 generated capability 绕过 Skill / tool registry,会产生第二套权限、状态、证据。 + +3. **营销叙事过度** + - “零门槛全自动”容易掩盖复杂业务仍需要架构师判断。 + +4. **垂类 demo 误导** + - 电商案例不应决定 Lime 的产品边界;它只是一个验证三层架构的样例。 diff --git a/docs/research/creaoai/lime-gap-analysis.md b/docs/research/creaoai/lime-gap-analysis.md new file mode 100644 index 000000000..be019c4d3 --- /dev/null +++ b/docs/research/creaoai/lime-gap-analysis.md @@ -0,0 +1,123 @@ +# CreoAI 对照 Lime 的偏差分析 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:判断 Lime 当前路线和 CreoAI 启发是否冲突,并明确后续应该补哪条闭环。 + +## 1. 总判断 + +Lime 当前方向不冲突。 + +更准确的判断是: + +**Lime 已有底座,但 skills pipeline 还偏静态;CreoAI 启发的是把“能力生成、验证、注册、长期运行”补成闭环。** + +也就是说,问题不是 Lime 缺 tool,也不是缺 skill 标准,而是缺少: + +```text +Coding Agent 自动生成 capability + -> 编译进 Lime Skill 标准 + -> 验证后注册 + -> 进入长期 runtime + -> evidence 形成可审计闭环 +``` + +补充宗旨: + +**CreoAI 启发不等于无限放权。Lime 后续应坚持“权限永远显式受控,能力逐级开放”;限制的是未经验证、未经授权、不可审计的执行,不是限制 agent 的理解、设计和编码能力。** + +## 2. Lime 已经接近的部分 + +Lime 当前已经具备以下相关底座: + +1. Agent Skills 作为唯一技能包格式标准。 +2. Skill 是 bundle,不是单个 Markdown。 +3. ServiceSkill / SkillCatalog 作为产品投影层。 +4. SiteAdapterSpec 作为站点 adapter 标准。 +5. Query Loop 统一 submit turn、metadata、tool runtime、queue、evidence。 +6. tool catalog 已有 capability、lifecycle、permission plane。 +7. workspace、artifact、subagent、automation、evidence pack 已进入主链。 + +这些说明 Lime 不需要另起炉灶。 + +## 3. Lime 当前缺口 + +真正缺口集中在四点: + +1. **Coding Agent / Capability Authoring 层偏弱** + - Lime 原本不是 terminal coding agent,现有强项是 Query Loop、Workspace、Artifact、Automation 和 Evidence;弱项是让 agent 受控地读取 CLI / API / docs、写 adapter / contract / tests、并修复 verification 失败。 + - 这部分可参考 [../pi-mono-coding-agent/README.md](../pi-mono-coding-agent/README.md) 的工具 allowlist、可插拔工具后端、session/runtime/services 分层和 deterministic test harness,但不能照搬其全仓库 shell/write 权限。 + +2. **生成态能力缺少标准入口** + - 用户还不能稳定地让 agent 生成 workspace-local skill / adapter,并进入统一校验和注册。 + +3. **验证 gate 不够产品化** + - dry-run、schema 校验、权限声明、fixture test 还没有形成 generated capability 的默认门禁。 + +4. **长期任务纪律不够强** + - durable automation、runtime queue、subagent 已有,但“持久目标 -> 空闲续跑 -> completion audit -> complete / blocked / needs_input”还未成为所有 managed skill 的统一行为。 + - 这一缺口与 [Codex `/goal`](../codex-goal/README.md) 的 persistent objective / continuation loop 直接相关,但 Lime 不能照搬 thread-level `/goal`,必须折回 `agent turn / subagent turn / automation job` 与 evidence pack。 + +5. **Workspace 对生成能力的可见性不足** + - 用户需要看到哪些能力是 agent 生成的、来源是什么、权限是什么、最近运行如何、证据在哪里。 + +## 4. current / compat / deprecated / dead 分类 + +### 4.1 current + +后续应继续强化的主路径: + +1. `Agent Skill Bundle` 作为技能包标准。 +2. `ServiceSkill / SkillCatalog` 作为用户可见产品投影。 +3. `SiteAdapterSpec` 作为站点类 adapter 标准。 +4. `agent_runtime_submit_turn -> runtime_turn -> tool_runtime -> evidence` 作为执行主链。 +5. `workspace / artifact / evidence pack` 作为任务产物与事实源。 +6. `tool catalog` 中的 capability / lifecycle / permission plane。 +7. 未来 Managed Objective 只能作为目标推进控制层挂到 `agent turn / subagent turn / automation job`,不能成为第四类 runtime。 + +### 4.2 compat + +可以作为过渡支撑,但不应成为主叙事: + +1. 手工创建的本地 skill scaffold。 +2. seeded / fallback skill 目录。 +3. debug-only DevBridge 调试入口。 +4. 现有单场景 skill launch metadata。 + +这些可以继续服务 current 主链,但不能反向定义 generated capability 标准。 + +### 4.3 deprecated + +后续不应继续扩展的表达和实现方向: + +1. 把 generated capability 当成独立 runtime family。 +2. 把 adapter 当成用户可见产品场景本体。 +3. 为单个自动化场景自建状态机、scheduler、artifact 和 evidence。 +4. 仅靠 prompt 描述权限与参数,而不进入结构化 contract。 +5. 让高风险 API 调用只由模型自行判断是否安全。 +6. 把 `/goal` 或 Managed Objective 当成新的长期执行实体,绕过 automation job 和 Query Loop。 + +### 4.4 dead + +后续应明确判死的方向: + +1. `GeneratedTool` 作为与 Skill / ServiceSkill / Adapter 平级的长期主类型。 +2. agent 生成代码后绕过 tool_runtime 直接执行。 +3. 外部 API / CLI 原始 schema 直接成为 Lime 运行时协议。 +4. 为“更像 CreoAI”而复制电商垂类产品结构。 +5. 为自动化能力新增第二套 evidence pack。 + +## 5. 对 Lime 开发计划的直接要求 + +后续 `docs/roadmap/creaoai/` 必须做到: + +1. 把 Skill Forge 写成 skills pipeline 的上游阶段,而不是新 runtime。 +2. 把 generated capability 的结果固定为 Skill Bundle / Adapter Spec / ServiceSkill 投影。 +3. 把 verification gate 写成注册前硬门槛。 +4. 把 tool_runtime 与 evidence pack 写成唯一执行和事实源。 +5. 把 workspace-local visibility 纳入首批产品验收。 +6. 把 Codex `/goal` 参考单独留在 `docs/research/codex-goal/`;CreoAI roadmap 只引用它来解释长期目标推进,不把它写成 Skill Forge 的一部分。 + +## 6. 一句话结论 + +**CreoAI 启发不推翻 Lime 的 skills pipeline;它要求 Lime 把 skills pipeline 从“安装和调用技能”升级为“生成、编译、验证、注册并长期运行技能”。** diff --git a/docs/research/creaoai/tool-coding-orchestration.md b/docs/research/creaoai/tool-coding-orchestration.md new file mode 100644 index 000000000..8790adc83 --- /dev/null +++ b/docs/research/creaoai/tool-coding-orchestration.md @@ -0,0 +1,267 @@ +# CreoAI 的工具编码编排 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:拆清楚“Coding Agent 将 CLI / API / tools 编码编排”这件事,明确它对 Lime skills pipeline 的真正启发。 + +## 1. 先修正一个误区 + +最容易的误读是: + +**Agent 会调用更多 API,所以它更强。** + +更准确的理解是: + +**Agent 会把外部 API、CLI、网页流程和已有 tools 编码成新的业务专用能力,然后再编排这些能力长期运行。** + +这不是工具调用能力的线性增强,而是角色变化: + +```text +Tool User Agent + -> 调用已经注册好的工具 + +Tool Maker Agent + -> 发现外部能力 + -> 编写 adapter / script / wrapper + -> 定义 contract / test / permission + -> 注册为可复用工具 + -> 编排成长期任务 +``` + +固定判断: + +**核心非共识是 agent 从“使用工具的人”变成“制造工具的人”。** + +## 2. Tool-user 路线与 Tool-maker 路线 + +### 2.1 Tool-user 路线 + +```text +用户目标 + -> LLM 判断需要哪一个预设工具 + -> 调用 tool / MCP / API + -> 返回结果 +``` + +优点: + +1. 安全边界清晰。 +2. 可控性高。 +3. 工程实现简单。 + +缺点: + +1. 只能做预先接好的能力。 +2. 新平台、新流程、新业务规则需要人工开发。 +3. 很容易变成“工具市场 + 聊天壳”。 + +### 2.2 Tool-maker 路线 + +```text +用户目标 + -> Coding Agent 分析能力缺口 + -> 读取 API / CLI / docs / website + -> 生成 adapter / glue code / workflow code + -> dry-run / test / sandbox call + -> 注册为 workspace-local capability + -> 编排进长期 job + -> evidence 记录每次执行 +``` + +优点: + +1. 能覆盖长尾业务流程。 +2. 能把一次对话沉淀成可复用能力。 +3. 能快速适配用户自己的平台、账号和流程。 + +风险: + +1. 生成代码可能不安全。 +2. 外部 API 和网页结构可能变化。 +3. 高风险动作需要权限和人工确认。 +4. 如果缺少标准化,会形成一堆不可治理脚本。 + +## 3. 能力生成链路 + +CreoAI 式工具编码编排可以抽象成下面这条链: + +```text +Capability Source + -> Adapter Code + -> Tool Contract + -> Verification + -> Registry + -> Workflow Job + -> Evidence +``` + +### 3.1 Capability Source + +来源包括: + +1. API 文档。 +2. OpenAPI schema。 +3. CLI help 输出。 +4. SDK 示例。 +5. MCP server 能力。 +6. 网页操作流程。 +7. 用户提供的平台说明。 + +### 3.2 Adapter Code + +agent 生成的小型连接层,例如: + +1. `fetchCompetitorSales()` +2. `searchSupplierCandidates()` +3. `createListingDraft()` +4. `exportDailyTrendReport()` + +固定规则: + +**adapter 只应该承担连接和转换职责,不应该把完整产品状态机写进脚本里。** + +### 3.3 Tool Contract + +每个生成能力至少要声明: + +1. 输入 schema。 +2. 输出 schema。 +3. 权限类型。 +4. 是否联网。 +5. 是否写文件。 +6. 是否会发布、付款、删除或修改外部状态。 +7. 失败码和错误分类。 + +没有 contract 的代码不应进入长期 runtime。 + +### 3.4 Verification + +进入注册前至少需要: + +1. 静态校验。 +2. dry-run。 +3. fixture test。 +4. mock 或 sandbox 调用。 +5. 对高风险 API 的人工确认。 + +固定规则: + +**验证通过前只能是 draft capability,不能是 current tool。** + +### 3.5 Registry + +通过验证后才允许进入统一能力注册表。 + +对 Lime 来说,这一步不应创建新注册表,而应投影为: + +1. Agent Skill Bundle。 +2. ServiceSkill / SkillCatalog entry。 +3. SiteAdapterSpec。 +4. tool_runtime 可裁剪的工具面。 + +### 3.6 Workflow Job + +多个能力可以被编排成: + +1. 一次性 agent turn。 +2. scheduled job。 +3. managed task。 +4. subagent team run。 +5. remote channel trigger。 + +但执行仍必须回到 Lime 当前 runtime,不允许每个生成能力自带 scheduler。 + +### 3.7 Evidence + +每次执行都必须写入: + +1. 输入摘要。 +2. 输出摘要。 +3. 调用过的外部能力。 +4. 权限与确认记录。 +5. 失败、重试和降级。 +6. artifact 与最终产物。 + +固定规则: + +**自动化越强,evidence 越不能是可选项。** + +## 4. 这和 MCP 的区别 + +MCP 解决的是: + +**工具如何被模型发现和调用。** + +Tool-maker agent 解决的是: + +**工具如何根据用户业务目标被生成、验证、注册和复用。** + +两者关系: + +1. MCP 可以是 Capability Source。 +2. MCP server 可以由生成能力包装或调用。 +3. 但 MCP 不是 Lime 的 generated capability 标准。 +4. Lime 仍需自己的 Skill / Adapter / Runtime Binding 边界。 + +一句话: + +**MCP 是工具协议,Tool-maker 是工具生产系统。** + +## 5. 对 Lime skills pipeline 的启发 + +CreoAI 的关键启发不是替代 skills pipeline,而是给它补上游: + +```text +用户目标 + -> Coding Agent 生成 capability draft + -> 编译成 Skill Bundle / Adapter Spec + -> 校验 contract / permission / test + -> 注册到 workspace-local skill catalog + -> Query Loop 与 tool_runtime 统一执行 + -> evidence pack 统一导出 +``` + +正确关系: + +1. **Skill pipeline 是标准化管道。** +2. **Coding Agent 是上游自动生产者。** +3. **tool_runtime 是执行和权限边界。** +4. **Harness Engine 是审计和回放边界。** + +补充边界: + +[Codex `/goal`](../codex-goal/README.md) 研究的是“目标如何跨多轮 turn 被 runtime 持续推进”,不是“工具如何被生成”。因此: + +1. `Skill Forge` 产出可复用能力。 +2. `Managed Objective` 消费这些能力并决定是否继续下一轮。 +3. 两者都必须回到 Query Loop、tool_runtime、automation job 和 evidence pack。 +4. 不允许把 goal loop 写成 generated capability 的执行 runtime,也不允许把 Skill Forge 写成目标状态机。 + +## 6. 对 Lime 的禁止项 + +以下做法会和现有路线冲突: + +1. 新增 `GeneratedTool` 作为长期主类型。 +2. 让 agent 生成脚本后绕过 Skill Bundle 直接执行。 +3. 在 Query Loop 外另建 generated tool registry。 +4. 为 generated workflow 另建 queue / scheduler / evidence。 +5. 把 adapter 提升成前台产品入口,绕过 ServiceSkill。 +6. 把来源 API / CLI 的原始协议直接当作 Lime 标准。 +7. 把 persistent goal / Managed Objective 当成 generated tool registry 的替代品。 + +## 7. 推荐产品命名 + +研究层建议把这类能力暂称为: + +**Skill Forge / Capability Forge** + +含义: + +1. 它是生成和编译阶段。 +2. 它不是执行 runtime。 +3. 它产出标准 Skill Bundle / Adapter Spec。 +4. 它服从现有 Query Loop 和 tool_runtime。 + +一句话: + +**生成可以动态,标准必须统一;执行可以自动,治理必须收口。** diff --git a/docs/research/pi-mono-coding-agent/README.md b/docs/research/pi-mono-coding-agent/README.md new file mode 100644 index 000000000..1cb7e81b6 --- /dev/null +++ b/docs/research/pi-mono-coding-agent/README.md @@ -0,0 +1,613 @@ +# pi-mono Coding Agent 研究入口 + +> 状态:current research reference +> 更新时间:2026-05-05 +> 目标:调研 `/Users/coso/Documents/dev/js/pi-mono` 中 `@mariozechner/pi-coding-agent` 的架构,把可借鉴点收敛为 Lime `Capability Authoring Agent / Skill Forge` 的实现参考,避免把 Lime 误改成另一个终端 Coding Agent。 + +## 1. 结论先行 + +`pi-mono` 可以参考,而且很有价值,但参考对象不是“独立 Coding Agent 产品形态”,而是它背后的 **coding harness 工程切面**。 + +对 Lime 的正确结论是: + +**不要把 Lime 改造成 pi;要把 pi 的受控会话、最小工具面、可插拔工具后端、事件生命周期和测试 harness 借鉴到 Lime 的 Capability Authoring Agent。** + +换句话说: + +```text +pi-mono:终端 coding harness + -> 目标是让模型直接读写代码、跑命令、改项目 + +Lime:桌面 Agent Workspace + -> 目标是让模型生成受治理的 Skill / Adapter draft + -> 再通过 verification gate 注册到现有 Query Loop / tool_runtime 主链 +``` + +因此,Lime 可以学 pi-mono 的: + +1. `AgentSession / AgentSessionRuntime / services` 分层。 +2. `read-only tools` 与 `coding tools` 的工具面分级。 +3. `noTools / tools allowlist / customTools` 的能力开关。 +4. `BashOperations / EditOperations` 这种可插拔执行后端。 +5. agent lifecycle、tool lifecycle、session lifecycle 事件。 +6. JSONL 事件流、RPC、SDK 嵌入模式的可观测性设计。 +7. faux provider + harness 的确定性测试方式。 + +Lime 不应该学 pi-mono 的: + +1. 不新增 `coding_agent_runtime`。 +2. 不把 JSONL session 变成第二事实源。 +3. 不开放全仓库 `bash / write / edit` 给能力生成任务。 +4. 不让 pi-style extension runtime 替代 Lime governance / evidence / tool_runtime。 +5. 不把终端 UI 和命令系统搬到 Lime 前台。 +6. 不把未验证生成脚本直接注册成工具。 + +## 2. 来源边界 + +本研究只基于本地仓库快照: + +```text +/Users/coso/Documents/dev/js/pi-mono +``` + +已重点阅读的本地文件: + +1. `README.md` +2. `package.json` +3. `packages/coding-agent/README.md` +4. `packages/agent/src/agent-loop.ts` +5. `packages/coding-agent/src/core/agent-session.ts` +6. `packages/coding-agent/src/core/agent-session-runtime.ts` +7. `packages/coding-agent/src/core/sdk.ts` +8. `packages/coding-agent/src/core/tools/index.ts` +9. `packages/coding-agent/src/core/tools/bash.ts` +10. `packages/coding-agent/src/core/tools/edit.ts` +11. `packages/coding-agent/src/core/session-manager.ts` +12. `packages/coding-agent/src/core/extensions/types.ts` +13. `packages/coding-agent/docs/extensions.md` +14. `packages/coding-agent/docs/skills.md` +15. `packages/coding-agent/docs/rpc.md` +16. `packages/coding-agent/docs/json.md` +17. `packages/coding-agent/docs/sessions.md` +18. `packages/coding-agent/docs/session-format.md` +19. `packages/coding-agent/test/suite/README.md` +20. `packages/coding-agent/test/agent-session-dynamic-tools.test.ts` +21. `packages/coding-agent/test/agent-session-runtime-events.test.ts` +22. `packages/coding-agent/test/file-mutation-queue.test.ts` + +固定边界: + +1. 本文不是对 pi-mono 质量、商业化或维护状态的评价。 +2. 本文不建议把 pi-mono 作为 Lime 运行时依赖直接引入。 +3. 本文只提炼对 Lime `Skill Forge / Capability Authoring Agent` 有用的架构参考。 + +## 3. pi-mono 的系统骨架 + +`pi-mono` 是一个 monorepo,根 README 将包拆为: + +| 包 | 作用 | 对 Lime 的参考价值 | +| --- | --- | --- | +| `@mariozechner/pi-ai` | 多 provider LLM API | 低;Lime 已有 provider / runtime 主链 | +| `@mariozechner/pi-agent-core` | tool calling + state management 的 agent runtime | 中;可参考 agent loop 事件和 queue 语义 | +| `@mariozechner/pi-coding-agent` | interactive coding agent CLI | 高;可参考最小工具面、session、extension、SDK | +| `@mariozechner/pi-tui` | terminal UI library | 低;Lime 是 GUI 桌面产品 | +| `@mariozechner/pi-web-ui` | AI chat web components | 低到中;只参考事件投影,不搬 UI | + +`pi-coding-agent` README 直接把 pi 定义成 **minimal terminal coding harness**。它默认给模型四个工具: + +```text +read / write / edit / bash +``` + +同时在工具工厂里还有只读工具组: + +```text +read / grep / find / ls +``` + +这对 Lime 非常关键: + +**P1A 的 Capability Authoring Agent 不能一开始就拿到完整 coding tools;它应该先拿到 read-only + draft-scoped write/edit + dry-run 结果读取。** + +## 4. Agent Loop 可借鉴点 + +`packages/agent/src/agent-loop.ts` 里有清晰的 agent loop: + +```text +agentLoop / agentLoopContinue + -> runAgentLoop / runAgentLoopContinue + -> runLoop + -> streamAssistantResponse + -> executeToolCalls +``` + +它支持: + +1. `agent_start / agent_end` +2. `turn_start / turn_end` +3. `message_start / message_update / message_end` +4. `tool_execution_start / tool_execution_update / tool_execution_end` +5. sequential / parallel tool execution +6. `beforeToolCall / afterToolCall` +7. `shouldStopAfterTurn` +8. steering messages +9. follow-up messages + +对 Lime 的参考方式: + +| pi-mono 设计 | Lime 映射 | +| --- | --- | +| `agentLoopContinue` | Managed Objective 的 continuation turn 只能回到 `agent_runtime_submit_turn / runtime_queue` | +| `shouldStopAfterTurn` | completion audit / budget / pause / needs_input 判断 | +| steering / follow-up | Lime 的用户插队、补充输入、下一轮 continuation 语义可参考,但不能新增第二 queue | +| tool lifecycle events | 映射为 timeline / evidence / replay facts | +| parallel / sequential tool calls | verification dry-run 和 draft 写入默认 sequential,避免文件竞争 | + +固定边界: + +**Lime 已有 Query Loop;不能因为 pi-mono 有 agent loop,就复制一个平行 loop。** + +## 5. AgentSession / Runtime / Services 分层 + +`AgentSession` 的定位很清楚:同一个核心类被 interactive、print、RPC、SDK 模式复用。它封装: + +1. Agent state access。 +2. Event subscription 和 session persistence。 +3. model / thinking level 管理。 +4. compaction。 +5. bash execution。 +6. session switching / branching。 +7. prompt / steer / followUp queue。 +8. extension binding。 +9. tool registry 和 active tools。 + +`AgentSessionRuntime` 则负责: + +1. 创建 cwd-bound services。 +2. 管理当前 `AgentSession`。 +3. session switch / new / fork。 +4. session shutdown / startup lifecycle。 +5. rebind session。 + +`sdk.ts` 暴露: + +1. `createAgentSession` +2. `createAgentSessionRuntime` +3. `createAgentSessionServices` +4. `createCodingTools` +5. `createReadOnlyTools` +6. `createReadTool / createBashTool / createEditTool / createWriteTool` +7. `noTools` +8. `tools` allowlist +9. `customTools` +10. `resourceLoader` +11. `sessionManager` + +对 Lime 的启发不是“照搬类名”,而是固定三层心智: + +```text +Capability Authoring Session + -> 当前能力生成任务的状态、事件、draft、patch、self-check + +Capability Authoring Runtime Binding + -> 仍然绑定 Lime agent turn / Query Loop,不是新 runtime + +Capability Authoring Services + -> draft store、source refs、permission scanner、verification gate adapter +``` + +## 6. 工具面分级是最重要借鉴 + +pi-mono 的工具分组非常适合作为 Lime P1A 的风险边界参考。 + +### 6.1 pi-mono 工具分组 + +```text +coding tools = read / bash / edit / write +read-only tools = read / grep / find / ls +``` + +`createAgentSession` 还支持: + +```text +noTools: "all" | "builtin" +tools: string[] +customTools: ToolDefinition[] +``` + +这说明 coding harness 不是“默认什么都开放”,而是可以做能力裁剪。 + +### 6.2 Lime P1A 推荐工具面 + +Lime 首期不应该给 Capability Authoring Agent 完整 `bash / write / edit`。推荐分三档: + +| 档位 | 工具面 | 用途 | P1A 是否开放 | +| --- | --- | --- | --- | +| `author_readonly` | 读 workspace docs、读 source refs、查 CLI help、列 draft 目录 | discover / design | 开放 | +| `author_draft_write` | 只写 draft root 内文件、只做结构化 patch | generate | 开放,但路径强校验 | +| `author_dryrun` | 只运行 fixture / dry-run,不允许外部写操作 | self-check | 有限开放 | +| `author_full_shell` | 任意 bash / install / network write | 通用 coding agent | P1A 不开放,后续需 sandbox + 升级授权 | +| `author_external_write` | 发布、下单、改价、发消息 | 业务执行 | P1A 不开放,后续需人工确认或策略批准 | + +固定规则: + +1. P1A 不开放全局 shell。 +2. P1A 不自动安装依赖。 +3. P1A 写入范围只限 draft store。 +4. P1A 的 CLI 探索优先是 `--help / version / schema / dry-run`,不是任意命令。 +5. P1A 的输出只能是 unverified draft artifact。 + +## 7. 可插拔工具后端的启发 + +`bash.ts` 定义了 `BashOperations`: + +```text +exec(command, cwd, options) +``` + +并支持: + +1. timeout。 +2. AbortSignal。 +3. kill process tree。 +4. `BashSpawnHook` 修改 command / cwd / env。 +5. 自定义 operations,把执行委托给远程或受控后端。 + +`edit.ts` 定义了 `EditOperations`: + +```text +readFile(absolutePath) +writeFile(absolutePath, content) +access(absolutePath) +``` + +并且测试中有 `withFileMutationQueue`,用于串行化同一文件的并发修改。 + +对 Lime 的启发: + +1. 不要把 “run CLI” 等同于本地 shell。 +2. 不要把 “write draft” 等同于任意文件写入。 +3. 所有能力都应该是可替换 backend:本地、远程、mock、dry-run。 +4. 同一 draft 文件的并发写入需要队列或 patch 顺序保护。 +5. verification gate 读取的行为事实应该来自工具后端,而不是模型自述。 + +推荐 Lime 术语: + +```text +CapabilityAuthoringToolBackend + -> SourceReadBackend + -> DraftFileBackend + -> CliProbeBackend + -> DryRunBackend +``` + +这不是新 runtime,只是 `tool_runtime` 下的受控工具后端 profile。 + +## 8. Extension 机制的可借鉴与禁止照搬 + +pi-mono 的 extension 可以: + +1. 订阅 lifecycle events。 +2. 注册 LLM-callable tools。 +3. 注册 slash commands、shortcuts、flags。 +4. 通过 UI prompt 用户确认。 +5. 拦截 tool_call。 +6. 修改 tool_result。 +7. 注入 context。 +8. 修改 provider request。 +9. 持久化 CustomEntry。 +10. 注入 CustomMessage。 +11. 注册 provider。 + +它的 docs 也明确提醒:extension 以完整系统权限运行,只能安装可信来源。 + +对 Lime 的借鉴: + +| pi-mono extension 能力 | Lime 应该怎么收敛 | +| --- | --- | +| `tool_call` block | verification / permission gate | +| `tool_result` modify | dry-run result normalization | +| `context` inject | Query Loop prompt augmentation / capability_generation metadata | +| `CustomEntry` | timeline / artifact / evidence event | +| `registerTool` | 只能在 verified registration 后映射到 Skill / ServiceSkill / tool_runtime | +| `registerCommand` | Lime 不需要复制 terminal command system | +| `registerProvider` | 与 CreoAI P1A 无关,不进入首期 | + +固定边界: + +**Lime 不新增 pi-style extension runtime;Lime 只吸收 hook / gate / event lifecycle 这类工程思想。** + +## 9. Session JSONL 的启发与边界 + +pi-mono session 是 JSONL tree: + +1. header。 +2. message entries。 +3. model change。 +4. thinking level change。 +5. compaction。 +6. branch summary。 +7. custom entry。 +8. custom message entry。 +9. label。 +10. session info。 + +它支持 tree branching、compaction、branch summary,以及 extension state persistence。 + +对 Lime 的启发: + +1. generation / verification / repair 应该有可回放的 timeline。 +2. draft patch、source ref、self-check、gate result 都需要稳定事件。 +3. branch / retry / repair 不能只落在自然语言消息里。 + +但 Lime 不能照搬 JSONL 做第二事实源。Lime 当前事实链仍是: + +```text +agent_runtime_submit_turn + -> runtime_turn / TurnInputEnvelope + -> runtime_queue + -> stream events + -> timeline / artifact / memory + -> thread_read / evidence / replay / review +``` + +因此 Capability Authoring 的事件应该进入现有事实链,例如: + +```text +capability_generation_started +source_ref_read +capability_draft_file_written +capability_patch_applied +capability_self_check_run +capability_draft_created +capability_verification_run +capability_verification_result +capability_registration_result +``` + +P1A 只需要前六个事件,后续 P2 / P3 再补 verification / registration。 + +## 10. 测试 harness 的启发 + +`packages/coding-agent/test/suite/README.md` 固定了测试规则: + +1. 使用 suite harness。 +2. 使用 faux provider。 +3. 不使用真实 provider API。 +4. 不使用真实 API key。 +5. 不调用网络或付费 token。 +6. CI-safe、deterministic。 + +相关测试覆盖了: + +1. dynamic tool registration。 +2. SDK custom tools。 +3. tools allowlist。 +4. queue / steering / follow-up。 +5. session lifecycle events。 +6. file mutation queue。 +7. package command path。 +8. runtime replacement stale context。 + +对 Lime 的直接要求: + +**P1A 不能只靠 GUI 试跑;必须先有 deterministic harness 测试。** + +推荐 P1A 测试矩阵: + +| 测试项 | 目标 | +| --- | --- | +| metadata builder | `capability_generation` turn metadata 可稳定生成 | +| draft path guard | `generated_files` 不能逃出 draft root | +| draft write backend | 只允许写 draft root 内文件 | +| read-only source probe | CLI help / docs probe 不产生外部写操作 | +| unverified isolation | draft 不进入 default tool surface | +| self-check result | fixture dry-run 失败时状态为 `failed_self_check` | +| event emission | draft_created / file_written / self_check_run 进入 timeline 或 artifact | + +## 11. Lime 对照图 + +```mermaid +flowchart TB + subgraph Pi[pi-mono] + PiPrompt[用户 prompt] --> PiSession[AgentSession] + PiSession --> PiTools[read / write / edit / bash] + PiTools --> PiProject[直接修改项目] + PiSession --> PiJsonl[JSONL session] + end + + subgraph Lime[Lime] + LimeGoal[用户能力生成目标] --> QueryLoop[agent_runtime_submit_turn / Query Loop] + QueryLoop --> AuthorTools[Capability Authoring Tool Profile] + AuthorTools --> DraftStore[Workspace Draft Store] + DraftStore --> Gate[Verification Gate] + Gate --> Registry[Workspace-local Skill Catalog] + Registry --> Runtime[tool_runtime / automation / evidence] + end + + PiSession -.参考 session / event / queue 分层.-> QueryLoop + PiTools -.参考工具分级与可插拔后端.-> AuthorTools + PiJsonl -.参考可回放事件,不复制事实源.-> DraftStore +``` + +固定判断: + +1. pi-mono 的核心是“直接 coding”。 +2. Lime 的核心是“能力生成后治理注册”。 +3. 二者参考关系在 harness 层,不在产品入口层。 + +## 12. Capability Authoring Agent 最小形态 + +考虑用户担心“Lime 原本不是 Coding Agent,所以这块会弱”,建议不要把 P1A 命名为完整独立 Coding Agent,而是更准确地称为: + +**Capability Authoring Agent** + +它是 Coding Agent 的一个受控子集,只做能力草案生成。 + +### 12.1 最小循环 + +```text +clarify + -> discover source refs + -> design skill / adapter draft + -> generate draft files + -> self-check + -> mark unverified or failed_self_check +``` + +### 12.2 最小状态机 + +```mermaid +stateDiagram-v2 + [*] --> requested + requested --> clarifying + clarifying --> discovering + discovering --> designing + designing --> generating + generating --> self_checking + self_checking --> unverified: self-check passed + self_checking --> failed_self_check: self-check failed + failed_self_check --> generating: repair + unverified --> [*] +``` + +### 12.3 最小权限面 + +```text +允许: + - 读取用户指定 docs / CLI help / OpenAPI / 本地代码片段 + - 写 draft root 内 SKILL.md / manifest / scripts / examples / tests + - 运行 fixture dry-run 或静态检查 + - 生成 artifact 和 evidence 事件 + +禁止: + - 任意 shell + - 自动安装依赖 + - 自动读取 secret + - 自动访问未声明域名 + - 自动执行外部写操作 + - 自动注册到 tool surface +``` + +### 12.4 最小输出目录 + +P1A 推荐先把 draft 作为 workspace-local 文件事实源: + +```text +/.lime/capability-drafts// + manifest.json + SKILL.md + scripts/ + examples/ + tests/ + self-check.json +``` + +说明: + +1. 这是建议目录,不是已实现事实。 +2. 若 Lime 现有 artifact store 已有更合适路径,应优先复用现有封装。 +3. 不要为了 P1A 新增全局数据库主事实源。 + +## 13. 和 CreoAI / Managed Objective 的关系 + +pi-mono 参考的是 CreoAI 三层里的第一层: + +```text +Coding Agent / Agent Builder +``` + +不是第二层: + +```text +Autonomous Execution / Managed Objective +``` + +也不是第三层: + +```text +Workspace / Agent App Surface +``` + +因此实施顺序应该保持: + +```text +P1A Capability Authoring Agent draft + -> P1B self-check + -> P2 verification gate + -> P3 registration + -> P4 Managed Objective / automation job +``` + +如果先做 P4,就会变成“已有工具多跑几轮”;如果先做 P1A,才开始补 Lime 最弱的 coding capability authoring 层。 + +## 14. 是否引入 pi-mono 作为依赖 + +当前建议:**不要引入运行时依赖。** + +理由: + +1. Lime 是 Tauri GUI + Rust runtime 主链,pi 是 Node terminal harness。 +2. Lime 已有 Query Loop、runtime queue、tool_runtime、evidence pack。 +3. 直接引入会带来第二套 session、tool registry、extension、auth、UI 和 provider 抽象。 +4. P1A 需要的是设计模式,不是终端 agent 产品。 + +可以复制的不是代码,而是约束: + +1. 工具 allowlist。 +2. read-only / write / bash 分级。 +3. pluggable operations。 +4. deterministic faux provider tests。 +5. lifecycle event names。 +6. session/runtime/services 分层边界。 + +## 15. 权限宗旨:default deny 不是永久低权限 + +pi-mono 的默认场景是开发者终端 coding harness,因此默认 `read / write / edit / bash` 是合理的;Lime 的首期场景是 generated capability authoring,生成结果未来会进入 Skill Catalog、automation job 和 evidence 主链,因此必须更保守。 + +固定宗旨: + +**权限永远显式受控,能力逐级开放;限制的是未经验证、未经授权、不可审计的执行,不是限制 agent 的理解、设计、编码和修复能力。** + +对 Lime 的含义: + +1. `P1A` 限制 full shell / external write,是为了控制未验证 draft 的 blast radius。 +2. 这不代表 Lime 永远只能做 read-only 或 draft-only。 +3. 后续可以逐级开放 sandbox shell、verified execution、human-confirmed external write 和 policy-approved scheduled write。 +4. 每一级开放都必须有对应的 sandbox、verification gate、permission policy、用户确认或 evidence audit。 +5. 如果一个能力不能解释“为什么这次执行被允许”,就不能进入 current tool surface 或 automation job。 + +推荐分级: + +```text +Level 0: read-only discovery +Level 1: draft-scoped write +Level 2: fixture dry-run +Level 3: sandbox shell +Level 4: workspace-local verified execution +Level 5: human-confirmed external write +Level 6: policy-approved scheduled external write +``` + +一句话: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 16. 最终建议 + +对用户问题“独立 Coding Agent 是否可以参考 pi-mono”,答案是: + +**可以,但只能参考为 Lime 的 Capability Authoring Agent 设计,不应把 Lime 改造成 pi-style 独立 Coding Agent。** + +更具体地说: + +1. P1A 参考 pi-mono 的 read-only tools / allowlist / customTools 思路。 +2. P1A 不参考 pi-mono 默认 `read / write / edit / bash` 全能力。 +3. P1A 参考 `AgentSession` 分层,但挂回 Lime Query Loop。 +4. P1A 参考 session event / JSON event stream,但写入 Lime timeline / artifact / evidence。 +5. P1A 参考 test suite harness,不用真实 provider / API key / network。 +6. P1A 输出 unverified draft,不注册、不运行、不调度。 + +一句话: + +**pi-mono 告诉我们 Coding Agent 的工程骨架应该长什么样;Lime 要做的是把这套骨架关进 Skill Forge 的治理边界里。** diff --git a/docs/roadmap/agentui/conversation-projection-implementation-plan.md b/docs/roadmap/agentui/conversation-projection-implementation-plan.md index 21a76db5c..ec5e479a9 100644 --- a/docs/roadmap/agentui/conversation-projection-implementation-plan.md +++ b/docs/roadmap/agentui/conversation-projection-implementation-plan.md @@ -251,4 +251,39 @@ AgentChatWorkspace - Phase 2 已继续抽出 `sessionFinalizeController`,统一 finalize 阶段 workspace restore guard、runtime/topic/shadow workspace 汇总、shadow execution strategy fallback 与最终 execution strategy override。 - Phase 2 已继续抽出 `sessionMetadataSyncScheduler`,统一 finalize 后 metadata patch 的 invoke capability guard、旧调度取消、idle 调度、stale guard、成功回写与失败回调。 - Phase 2 已继续抽出 `sessionPostFinalizePersistenceController`,统一 finalize 后 topic workspace、workspace 映射持久化、runtime workspace topic 回写与 provider preference apply 的决策。 -- 下一刀建议继续 Phase 2:抽 `sessionSwitchErrorController` 或 `sessionHistoryPaginationController`,把错误恢复与完整历史分页状态继续从 `useAgentSession` 主体中移出;只有 E2E 指标显示 `messageListTimelineBuildMs` 仍高时才进入 worker 化。 +- Phase 2 已继续抽出 `sessionSwitchErrorController`,统一切换旧会话失败时的 session not found、保留当前快照、清空快照、刷新 topics 与 toast 决策。 +- Phase 2 已继续抽出 `sessionHistoryPaginationController`,统一完整历史分页窗口、分页请求参数、detail loaded count 与下一轮 history window 计算。 +- Phase 2 已继续抽出 `sessionHistoryMergeController`,统一完整历史分页 detail 返回后的 messages / turns / threadItems 合并与 currentTurnId 恢复计划。 +- Phase 3 已开始最小 controller 化:`agentStreamSubmissionController` 统一 submit dispatched / accepted / failed 的耗时上下文与错误文案,`agentStreamSubmitExecution` 继续只负责串接 ensure session、listener binding 与 runtime `submitOp`。 +- Phase 3 已继续抽出 `agentStreamSubmitOpController`,统一首页/对话流式提交的 `user_input` op payload 组装,并固定 `queueIfBusy` 语义;`agentStreamSubmitExecution` 不再直接拼 runtime submit payload。 +- Phase 3 已继续抽出 `agentStreamListenerReadinessController`,统一 listener bound、first event、first event deferred、inactivity watchdog guard 的上下文与判断,首字链路 readiness 分段可单测。 +- Phase 3 已继续抽出 `agentStreamSubmitLifecycleController`,统一 submit dispatched / accepted / failed metric、debug log 与 `runtime.submitOp` invoke 包装,提交生命周期可独立测试。 +- Phase 3 已继续抽出 `agentStreamRequestStartController`,统一 request start metric、activity log payload 与 `requestState.requestStartedAt/requestLogId` 写入,首字链路起点可独立测试。 +- Phase 3 已继续抽出 `agentStreamUnknownEventController`,统一未知 runtime event 活跃态保留、告警文案和告警去重计划,runtime projection/bootstrap 类未知事件不再由事件绑定主函数内联处理。 +- Phase 3 已继续抽出 `agentStreamInactivityController`,统一首包超时、首包 deferred、silent recovery、inactivity timeout 的用户文案、告警文案与恢复动作决策。 +- Phase 3 已继续抽出 `agentStreamRuntimeMetricsController`,统一 first runtime status 与 first text delta 的指标上下文和“一次性记录”判断,首字后段 metric 可独立测试。 +- Phase 3 已继续抽出 `agentStreamRuntimeStatusController`,统一 runtime status 归一化、summary 文案、summary item 选择与更新计划,`runtime_status` apply 逻辑可独立测试。 +- Phase 3 已继续抽出 `agentStreamTextDeltaController`,统一 text delta buffer 计数、首 delta 指标上下文与 overlap append 计划,重复吐字防线可独立测试。 +- Phase 3 已继续抽出 `agentStreamTextRenderFlushController`,统一 pending text delta 解析、首个可见文本立即 flush、后续 32ms 节流、first text render flush / first text paint 指标与 backlog debug plan。 +- Phase 3 已继续抽出 `agentStreamCompletionController`,统一空最终回复判定、协议残留清理后的 graceful completion 内容、empty-final-error 识别与最终 `contentParts` reconcile。 +- Phase 3 已继续抽出 `agentStreamToolCompletionSignalController`,统一 tool result 是否可作为 meaningful completion signal 的站点保存、图片任务、通用任务与 artifact 预览判断。 +- Phase 3 已继续抽出 `agentStreamErrorController`,统一 runtime error toast level / 文案与失败 assistant 消息 patch,error 分支不再内联 rate limit 判断和失败消息内容组装。 +- Phase 3 已继续抽出 `agentStreamWarningController`,统一 runtime warning 的忽略、去重、标记与 toast plan,warning 分支只保留 warnedKeys 与 toast 副作用。 +- Phase 3 已继续抽出 `agentStreamQueueController`,统一 queued draft 消息 patch 与 queue removed / cleared 后是否继续观察当前草稿的判断。 +- Phase 3 已继续抽出 `agentStreamThreadItemController`,统一 thread item 高频更新延后判断与 turn_started 时 pending item 绑定真实 turn 的 patch。 +- Phase 3 已继续抽出 `agentStreamToolEventController`,统一 `tool_end` 前置的 result normalize、tool name lookup 与 meaningful completion signal 计划。 +- Phase 3 已继续抽出 `agentStreamArtifactActionController`,统一 `artifact_snapshot / action_required` 前置 activate、清 optimistic item 与 meaningful completion signal 计划。 +- Phase 3 已继续抽出 `agentStreamRuntimeContextController`,统一 `context_trace / turn_context / model_change` 前置 activate / clear optimistic item 计划与 execution runtime apply wrapper。 +- Phase 3 已继续抽出 `agentStreamThinkingDeltaController`,统一 `thinking_delta` 前置 activate / surface guard 与 thinking 消息 patch。 +- Phase 3 已继续扩展 `agentStreamCompletionController`,统一完成态 assistant message patch,`final_done` 与 empty-final graceful completion 不再内联拼 `content / contentParts / usage / runtimeStatus`。 +- Phase 3 已继续扩展 `agentStreamCompletionController`,统一 `final_done` 与 empty-final error 的 completion side-effect plan,handler 只执行日志、队列清理、observer 与 listener 副作用。 +- Phase 3 已继续扩展 `agentStreamErrorController`,统一普通 runtime error 的失败 side-effect plan,error 分支不再内联 queued turn 清理、request log payload 与 toast plan。 +- Phase 3 已继续扩展 `agentStreamErrorController`,统一 failed timeline turn / turn_summary 更新计划,handler 不再内联查找 running turn 或拼失败 summary 文案。 +- Phase 3 已继续扩展 `agentStreamWarningController`,统一 warning toast action 与 dispatcher 执行,warning 分支不再内联 toast level switch。 +- Phase 3 已继续扩展 `agentStreamErrorController`,统一普通 runtime error toast dispatcher 执行,error 分支不再内联 warning/error toast 分发。 +- Phase 3 已继续扩展 `agentStreamCompletionController`,统一 missing final reply failure 的 queued turn、request log 与 toast plan,handler 不再内联空最终回复失败副作用参数。 +- Phase 3 已继续扩展 `agentStreamQueueController`,统一 queued draft 状态副作用计划,handler 不再内联 queued draft 的 active stream / optimistic / sending 状态决策。 +- Phase 3 已继续抽出 `agentStreamRequestLogController`,统一 request log finish 的去重、duration 与 update payload 决策,handler 只保留 `activityLogger.updateLog` 副作用。 +- Phase 3 已继续抽出 `agentStreamTimerController`,统一 text render flush timer、queued draft cleanup timer 的调度/触发/清理决策,handler 只保留 `setTimeout/clearTimeout` 与 UI 副作用。 +- Phase 3 已继续扩展 `agentStreamCompletionController` 与 `agentStreamErrorController`,统一 missing final failure 执行层副作用计划与 failed timeline state plan,handler 不再内联这两类失败路径的执行参数。 +- 下一刀建议先收口 Phase 3 定向 E2E 指标采集;若 E2E 仍显示慢在事件处理后段,再检查 stream handler 剩余执行层 helper;若慢在 render,则进入 Phase 4 render projection。 diff --git a/docs/roadmap/ai-layered-design/README.md b/docs/roadmap/ai-layered-design/README.md new file mode 100644 index 000000000..d61bfcac2 --- /dev/null +++ b/docs/roadmap/ai-layered-design/README.md @@ -0,0 +1,177 @@ +# Lime AI 图层化设计路线图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 AI 图片生成升级为可编辑的设计工程输出,让 Lime 能生成、调整、重生成和导出多图层海报 / 封面 / 商品图,而不是只返回一张扁平图片。 + +配套研究: + +- [../../research/ai-layered-design/README.md](../../research/ai-layered-design/README.md) +- [../../research/ai-layered-design/architecture-breakdown.md](../../research/ai-layered-design/architecture-breakdown.md) +- [../../research/ai-layered-design/model-and-tooling-map.md](../../research/ai-layered-design/model-and-tooling-map.md) +- [../../research/ai-layered-design/lime-gap-analysis.md](../../research/ai-layered-design/lime-gap-analysis.md) + +配套图纸: + +- [./architecture-diagrams.md](./architecture-diagrams.md):系统分层、前端工作台、后端服务、存储、provider 与 evidence 边界图 +- [./prototype.md](./prototype.md):生成结果卡、图层设计工作台、属性栏、拆层确认页、导出弹窗低保真原型 +- [./sequences.md](./sequences.md):原生分层生成、打开编辑、单层重生成、扁平图拆层、导出时序图 +- [./flowcharts.md](./flowcharts.md):入口分流、Layer Planner、mask 质量、clean plate、Canvas 状态机、导出决策流程图 +- [./diagrams.md](./diagrams.md):早期总览图纸,后续具体实现优先引用上述拆分图纸 + +相关 Lime 事实源: + +- [../../aiprompts/command-runtime.md](../../aiprompts/command-runtime.md) +- [../warp/artifact-graph.md](../warp/artifact-graph.md) +- [../task/model-routing.md](../task/model-routing.md) + +## 1. 先给结论 + +Lime 不应该先自训图层模型。 + +Lime 应该先做的是: + +**用现成图像模型生成和编辑资产,用 Lime 自己的 `LayeredDesignDocument` 承载图层工程。** + +一句话北极星: + +**Lime 的图片生成从“输出图片”升级为“输出可编辑设计工程”。** + +## 2. 固定主链 + +后续所有实现必须收敛到下面这条主链: + +```text +用户目标 / @海报 / @配图 + -> Layer Planner 拆成图层计划 + -> Asset Generator 调用 gpt-image-2 / Gemini / provider seam 分层生成 + -> Matting / Mask / Inpaint 生成透明图层和 clean plate + -> LayeredDesignDocument 保存图层、资产、预览和历史 + -> Canvas Editor 提供移动、缩放、隐藏、重排、单层重生成 + -> Exporter 导出 PNG / JSON / 后续 PSD + -> Artifact / Evidence 记录设计工程和生成过程 +``` + +这条主链意味着: + +1. `gpt-image-2` 是资产生成与编辑器,不是图层事实源。 +2. `LayeredDesignDocument` 是 current 设计工程事实源。 +3. PNG 是预览或导出结果,不是唯一保存对象。 +4. 单层重生成必须保留其他图层 transform 和 zIndex。 +5. 后续 PSD 导出消费同一份图层文档,不另开协议。 + +## 3. 非目标 + +本路线图明确不做: + +1. 不自训 image-to-PSD 大模型。 +2. 不承诺任意图片完美拆成 Photoshop 原始图层。 +3. 不新增平行图片设计 runtime。 +4. 不绕过现有 provider / model routing / media task / Workspace 主链。 +5. 不把完整 Photoshop / Figma 替代作为首期目标。 +6. 不默认把艺术 Logo 矢量化或字体完全识别。 +7. 不把普通文案继续烘焙成不可编辑图片层。 + +## 4. 产品对象分层 + +### 4.1 LayeredDesignDocument + +`LayeredDesignDocument` 是 Lime AI 设计项目的唯一 current 事实源。 + +它负责保存: + +1. 画布尺寸和背景。 +2. 图层列表。 +3. 图层 transform。 +4. 资产引用。 +5. 预览图。 +6. 单层生成来源。 +7. 编辑历史。 + +固定边界: + +**任何图层编辑都必须回写文档,不能只停留在前端 Canvas 状态。** + +### 4.2 Layer Planner + +Layer Planner 负责把用户目标拆成可编辑图层计划: + +1. 背景层。 +2. 主体层。 +3. 特效层。 +4. Logo 层。 +5. TextLayer。 +6. ShapeLayer。 +7. GroupLayer。 + +固定边界: + +**Planner 不生成最终图片,只生成可执行图层计划。** + +### 4.3 Asset Generator + +Asset Generator 负责调用 provider 生成 bitmap 资产: + +1. 背景。 +2. 主体。 +3. 特效。 +4. Logo。 +5. clean plate。 +6. 单层替换图。 + +固定边界: + +**Asset Generator 的输出必须注册为 asset,并绑定到某个 layer 或 edit history。** + +### 4.4 Canvas Editor + +Canvas Editor 是用户可见编辑面: + +1. 画布预览。 +2. 图层栏。 +3. 属性面板。 +4. 拖拽缩放。 +5. 显示/隐藏/锁定。 +6. 重排和分组。 +7. 单层重生成。 + +固定边界: + +**Canvas Editor 不直接定义模型调用协议,只消费 `LayeredDesignDocument`。** + +### 4.5 Exporter + +Exporter 负责把同一设计文档输出成: + +1. PNG。 +2. 项目 JSON。 +3. 后续 PSD。 +4. 后续打包资源目录。 + +固定边界: + +**导出格式是投影,不是新的设计事实源。** + +## 5. 分阶段路线 + +| 阶段 | 目标 | 主产物 | +| --- | --- | --- | +| P0 | 文档与边界落盘 | research + roadmap + current/compat/deprecated/dead 分类 | +| P1 | 原生分层生成 | `LayeredDesignDocument` draft + 分层资产 + 合成预览 | +| P2 | Canvas 可编辑 | 图层栏、transform、隐藏/锁定、单层重生成 | +| P3 | 扁平图拆层 | 主要对象 mask、RGBA 图层、clean plate、OCR TextLayer | +| P4 | 专业交付 | PNG / JSON 稳定导出,PSD-like 导出试点 | + +## 6. 验收总线 + +每个阶段都必须回答: + +1. 是否仍只有一个设计工程事实源。 +2. 是否能重新打开继续编辑。 +3. 是否能证明单层修改不破坏其他图层。 +4. 是否能导出和当前 Canvas 一致的 PNG。 +5. 是否能在 artifact / evidence 中追踪模型、prompt、资产和编辑历史。 + +一句话: + +**可交付标准不是“图片好看”,而是“设计项目能被继续编辑”。** diff --git a/docs/roadmap/ai-layered-design/architecture-diagrams.md b/docs/roadmap/ai-layered-design/architecture-diagrams.md new file mode 100644 index 000000000..78caa01a5 --- /dev/null +++ b/docs/roadmap/ai-layered-design/architecture-diagrams.md @@ -0,0 +1,222 @@ +# AI 图层化设计架构图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 `LayeredDesignDocument`、Canvas Editor、媒体任务、provider seam、asset store、artifact/evidence 的边界画清楚,防止后续实现变成单图输出旁路。 + +## 1. 总体系统分层图 + +```mermaid +flowchart TB + subgraph Entry[入口层] + Chat[生成主舞台 / @海报 / @配图] + Upload[上传扁平图] + Gallery[历史图片 / 设计项目] + end + + subgraph Planning[规划层] + Intent[Intent Normalizer] + Planner[Layer Planner] + Plan[Layer Plan] + end + + subgraph Generation[资产生成与处理层] + Provider["Image Provider Adapter
gpt-image-2 / Gemini / Local"] + Matting[Matting / RMBG / SAM] + Inpaint[Inpaint / Clean Plate] + OCR[OCR / Text Reconstruction] + end + + subgraph Document[设计工程事实源] + DesignDoc[LayeredDesignDocument] + AssetStore[Design Asset Store] + Preview[Preview Renderer] + end + + subgraph UI[编辑与交付层] + Canvas[Canvas Editor] + Inspector[Layer Inspector] + Exporter["Exporter
PNG / JSON / PSD-like"] + end + + subgraph Runtime[运行治理层] + Task[Media Task / Artifact] + Evidence[Evidence Pack] + Routing[Provider / Model Routing] + end + + Chat --> Intent + Upload --> Intent + Gallery --> DesignDoc + Intent --> Planner + Planner --> Plan + Plan --> Provider + Plan --> OCR + Provider --> Matting + Provider --> Inpaint + Matting --> AssetStore + Inpaint --> AssetStore + OCR --> DesignDoc + AssetStore --> DesignDoc + DesignDoc --> Preview + DesignDoc --> Canvas + Canvas --> Inspector + Canvas --> DesignDoc + DesignDoc --> Exporter + Routing --> Provider + Provider --> Evidence + DesignDoc --> Task + Exporter --> Task + Task --> Evidence +``` + +固定判断: + +1. `LayeredDesignDocument` 是 current 事实源。 +2. provider 输出只进入 `AssetStore`,不能直接成为最终产品状态。 +3. Canvas 的每次用户编辑都必须回写设计文档。 +4. artifact / evidence 记录生成与导出事实,不定义新的图层协议。 + +## 2. 前端工作台架构图 + +```mermaid +flowchart LR + subgraph Workspace[Layered Design Workspace] + Topbar["顶部工具栏
返回 / 保存 / 导出"] + Stage["中央画布
缩放 / 拖拽 / 对齐"] + LayerRail["左侧图层栏
缩略图 / 可见 / 锁定 / 顺序"] + Inspector["右侧属性栏
位置 / 尺寸 / 透明度 / prompt"] + Timeline["底部轻量历史
生成 / 替换 / 导出"] + end + + LayerRail -->|选中 layerId| Stage + Stage -->|transform update| Inspector + Inspector -->|属性修改| Stage + Stage -->|保存 patch| DocAPI[Design Document API] + Inspector -->|单层重生成| Regen[Layer Regenerate Action] + Topbar -->|导出| Export[Export Action] + Regen --> DocAPI + Export --> DocAPI + DocAPI --> Stage + DocAPI --> LayerRail + DocAPI --> Timeline +``` + +页面类型:这是**宽内容区工作台**,不是窄表单页。视觉应遵守 Lime 现有设计语言:主表面实体底色、浅边框、信息优先、低干扰背景。 + +## 3. 后端服务边界图 + +```mermaid +flowchart TB + Frontend[Frontend Canvas / Workspace] --> Gateway[Design Runtime API Gateway] + + Gateway --> DocSvc[Design Document Service] + Gateway --> PlanSvc[Layer Planning Service] + Gateway --> AssetSvc[Design Asset Service] + Gateway --> ExportSvc[Design Export Service] + + PlanSvc --> ModelRouter[Provider / Model Routing] + ModelRouter --> ImageProvider[Image Provider Adapter] + ImageProvider --> AssetSvc + + AssetSvc --> Matting[Matting Processor] + AssetSvc --> Inpaint[Inpaint Processor] + AssetSvc --> OCR[OCR Processor] + + DocSvc --> Store[(Workspace Design Store)] + AssetSvc --> Files[(Assets / Masks / Previews)] + ExportSvc --> Files + + DocSvc --> Artifact[Artifact Projection] + ExportSvc --> Artifact + ImageProvider --> Evidence[Evidence Event] + Matting --> Evidence + Inpaint --> Evidence +``` + +实现约束: + +1. 不新增平行图片 runtime;服务必须挂回现有媒体任务、Workspace 与 artifact 主链。 +2. 如果未来新增 Tauri 命令,必须同步前端调用、Rust 注册、治理目录册和 mock。 +3. provider adapter 只暴露能力和结果,不暴露产品层“图层”语义。 + +## 4. 数据与存储架构图 + +```mermaid +flowchart TD + subgraph DesignFolder[.lime/designs/design_id] + JSON["design.json
LayeredDesignDocument"] + Assets["assets/*.png
source / rgba / mask / clean_plate"] + Preview[previews/latest.png] + Export[exports/*.png / *.zip / *.psd] + end + + JSON --> Layers["layers list"] + JSON --> AssetRefs["assets refs"] + JSON --> History["editHistory list"] + AssetRefs --> Assets + Layers --> AssetRefs + Preview --> Export + + JSON --> ArtifactDoc[Design Artifact] + Preview --> PreviewArtifact[Preview Artifact] + Export --> ExportArtifact[Export Artifact] + History --> Evidence[Evidence Pack] +``` + +最低持久化原则: + +1. `design.json` 可单独解释工程结构。 +2. `assets/` 可被重新绑定到图层。 +3. `previews/latest.png` 只做加速显示和分享预览。 +4. `exports/` 是投影结果,不能反向成为事实源。 + +## 5. Provider 能力边界图 + +```mermaid +flowchart LR + Request[Asset Request] --> Capability[Capability Resolver] + Capability -->|supportsGeneration| Generate[generate image] + Capability -->|supportsEdit| Edit[edit / inpaint] + Capability -->|supportsTransparentBackground| Transparent[transparent output] + Capability -->|no transparent| PostAlpha[RMBG / SAM / Matting] + + Generate --> RawAsset[Raw Asset] + Edit --> EditedAsset[Edited Asset] + Transparent --> RgbaAsset[RGBA Asset] + RawAsset --> PostAlpha + PostAlpha --> RgbaAsset + RgbaAsset --> Layer[ImageLayer assetId] + EditedAsset --> Clean[Clean Plate / Replacement Asset] +``` + +核心规则: + +1. `gpt-image-2`、Gemini、Flux 的差异只停留在 capability 层。 +2. 图层协议不依赖某个模型是否支持透明背景。 +3. 透明输出失败时可以回退到后处理,但必须记录 `alphaMode`。 + +## 6. Artifact / Evidence 分层图 + +```mermaid +flowchart TB + UserAction[用户动作] --> DocPatch[Document Patch] + ProviderCall[Provider 调用] --> EvidenceEvent[Evidence Event] + DocPatch --> DesignArtifact[Design Artifact] + ExportAction[导出动作] --> ExportArtifact[Export Artifact] + PreviewRender[预览渲染] --> PreviewArtifact[Preview Artifact] + + DesignArtifact --> ThreadRead[Thread / Workspace Projection] + ExportArtifact --> ThreadRead + PreviewArtifact --> ThreadRead + EvidenceEvent --> EvidencePack[Evidence Pack] + + EvidencePack --> Review[Review / Replay] + ThreadRead --> UI[Workspace UI] +``` + +这层的目标不是让 evidence 接管设计文档,而是让后续 review / replay 能解释: + +1. 哪个模型生成了哪个 asset。 +2. 用户什么时候替换了哪个 layer。 +3. 导出结果来自哪个 document 版本。 diff --git a/docs/roadmap/ai-layered-design/architecture.md b/docs/roadmap/ai-layered-design/architecture.md new file mode 100644 index 000000000..53d2eefa2 --- /dev/null +++ b/docs/roadmap/ai-layered-design/architecture.md @@ -0,0 +1,256 @@ +# Lime AI 图层化设计架构 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:定义 Lime AI 图层化设计的文档协议、数据流、图层类型、编辑行为和 provider 边界,让后续实现不落成单图输出旁路。 + +## 1. 架构原则 + +1. **文档优先** + - `LayeredDesignDocument` 是唯一 current 事实源。 + +2. **资产可替换** + - 每个 ImageLayer 绑定 asset,单层重生成只替换该 asset 或生成新版本。 + +3. **文字可编辑** + - 普通文案必须是 TextLayer,不默认烘焙成图片。 + +4. **导出是投影** + - PNG / PSD / zip 都从同一文档导出,不反向成为事实源。 + +5. **模型在边界外** + - `gpt-image-2`、Gemini、Flux 等只通过 provider adapter 进入 Asset Generator。 + +## 2. 核心文档协议 + +首期建议协议: + +```ts +type LayeredDesignDocument = { + id: string + title: string + status: "draft" | "ready" | "exported" + canvas: DesignCanvas + layers: DesignLayer[] + assets: GeneratedAsset[] + preview?: DesignPreview + editHistory: LayerEditRecord[] + createdAt: string + updatedAt: string +} + +type DesignCanvas = { + width: number + height: number + backgroundColor?: string + safeArea?: Rect +} + +type DesignLayer = ImageLayer | TextLayer | ShapeLayer | GroupLayer + +type BaseLayer = { + id: string + name: string + visible: boolean + locked: boolean + x: number + y: number + width: number + height: number + rotation: number + opacity: number + zIndex: number + blendMode?: "normal" | "multiply" | "screen" | "overlay" | "lighten" + source: "planned" | "generated" | "extracted" | "user_added" +} + +type ImageLayer = BaseLayer & { + type: "image" | "effect" + assetId: string + maskAssetId?: string + alphaMode: "embedded" | "mask" | "blend" | "none" + prompt?: string +} + +type TextLayer = BaseLayer & { + type: "text" + text: string + fontFamily?: string + fontSize: number + color: string + align: "left" | "center" | "right" + lineHeight?: number + letterSpacing?: number +} + +type ShapeLayer = BaseLayer & { + type: "shape" + shape: "rect" | "round_rect" | "line" | "ellipse" + fill?: string + stroke?: string + strokeWidth?: number +} + +type GroupLayer = BaseLayer & { + type: "group" + children: string[] +} +``` + +首期不需要把协议设计成完整 PSD schema,只要稳定支持 Canvas 编辑和后续映射即可。 + +## 3. 资产协议 + +```ts +type GeneratedAsset = { + id: string + kind: + | "background" + | "subject" + | "effect" + | "logo" + | "text_raster" + | "mask" + | "clean_plate" + | "preview" + src: string + width: number + height: number + hasAlpha: boolean + provider?: string + modelId?: string + prompt?: string + params?: Record + parentAssetId?: string + createdAt: string +} +``` + +要求: + +1. 每个生成资产都可追踪 provider、model、prompt 和参数。 +2. mask、clean plate 和 preview 都是资产,但只有 layer 引用的资产才出现在图层栏。 +3. 替换资产不应改变 layer id、transform 和 zIndex。 + +## 4. 原生分层生成数据流 + +```text +用户 prompt + -> Layer Planner 输出 layer plan + -> Asset Generator 分层生成 bitmap + -> Matting Processor 产出 RGBA / mask + -> Layout Normalizer 估算位置和尺寸 + -> Composer 生成 preview + -> 持久化 LayeredDesignDocument +``` + +关键规则: + +1. 背景 prompt 必须显式要求“无文字、无人物、无 Logo”。 +2. 主体 prompt 优先要求“干净背景”或透明输出;透明不稳定时走抠图。 +3. 烟雾、光效可使用 alpha 或 blend mode。 +4. 普通文案由 Lime 自己创建 TextLayer。 +5. 最终预览图只是 `preview` asset。 + +## 5. 扁平图拆层数据流 + +```text +flat image + -> VLM / OCR 识别对象和文字 + -> SAM/RMBG 输出候选 masks + -> mask -> RGBA ImageLayer + -> mask 合并 -> background removal mask + -> provider edit / inpaint 生成 clean plate + -> OCR 文字转 TextLayer + -> 生成 LayeredDesignDocument +``` + +关键规则: + +1. 自动拆层结果必须标记 `source="extracted"`。 +2. 低置信度 mask 只能作为候选层,不能静默覆盖原图。 +3. 艺术 Logo 首期保留为 ImageLayer。 +4. clean plate 失败时仍可编辑图层,但 UI 必须提示移动可能露出修补痕迹。 + +## 6. 单层重生成数据流 + +```text +用户选中 layer + -> 读取 layer prompt / style / bbox / context + -> 调用 provider 生成替代资产 + -> 必要时抠图和修边 + -> 写入新 asset + -> layer.assetId 指向新 asset + -> editHistory 记录替换 + -> Composer 更新 preview +``` + +关键规则: + +1. 保留原 layer id。 +2. 保留 transform、zIndex、visible、locked。 +3. edit history 记录旧 asset 和新 asset。 +4. 失败时不破坏旧 asset。 + +## 7. Provider 边界 + +Provider adapter 只提供能力,不定义产品语义: + +```ts +type ImageProviderCapabilities = { + modelId: string + supportsGeneration: boolean + supportsEdit: boolean + supportsTransparentBackground?: boolean + supportsMaskEdit?: boolean + supportedSizes: string[] + supportedQualities: string[] + maxInputImages?: number +} +``` + +`gpt-image-2` 接入时: + +1. 通过 capability matrix 决定是否可用于生成、编辑、透明输出和 mask。 +2. 不在 UI 文案中承诺模型级能力,UI 只表达“生成 / 编辑 / 抠图 / 修补”。 +3. 如果官方能力变化,只更新 provider capability,不改图层协议。 + +## 8. 持久化与 artifact + +首期保存建议: + +```text +.lime/designs//design.json +.lime/designs//assets/.png +.lime/designs//previews/latest.png +``` + +后续接入 artifact / evidence 时: + +1. `design.json` 是 design artifact。 +2. `latest.png` 是 preview artifact。 +3. 每次 provider 调用写入 evidence event。 +4. export 结果写入 export artifact。 + +## 9. GUI 边界 + +Canvas Editor 首期必须支持: + +1. 图层列表。 +2. 画布渲染。 +3. 选中图层。 +4. 拖拽移动。 +5. 缩放。 +6. 显示/隐藏。 +7. 锁定。 +8. zIndex 重排。 +9. 单层重生成。 +10. 导出 PNG。 + +不在首期承诺: + +1. 曲线钢笔工具。 +2. 完整蒙版编辑器。 +3. 复杂图层样式。 +4. 高级字体匹配。 +5. 完整 PSD 互操作。 diff --git a/docs/roadmap/ai-layered-design/diagrams.md b/docs/roadmap/ai-layered-design/diagrams.md new file mode 100644 index 000000000..4127af137 --- /dev/null +++ b/docs/roadmap/ai-layered-design/diagrams.md @@ -0,0 +1,200 @@ +# AI 图层化设计图纸 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:用图示固定原生分层生成、扁平图拆层、单层重生成和导出链路,避免后续实现回退成单图输出。 + +## 0. 图纸索引 + +本文件保留早期总览图。后续具体设计优先阅读: + +1. [architecture-diagrams.md](./architecture-diagrams.md) +2. [prototype.md](./prototype.md) +3. [sequences.md](./sequences.md) +4. [flowcharts.md](./flowcharts.md) + +## 1. 原生分层生成流程 + +```mermaid +flowchart TD + U[用户目标 / @海报] --> P[Layer Planner] + P --> LP[Layer Plan] + LP --> BG[背景资产生成] + LP --> SUB[主体资产生成] + LP --> FX[特效资产生成] + LP --> LOGO[Logo 资产生成] + LP --> TXT[TextLayer 创建] + SUB --> MAT[抠图 / Matting] + FX --> ALPHA[Alpha / Blend 处理] + BG --> DOC[LayeredDesignDocument] + MAT --> DOC + ALPHA --> DOC + LOGO --> DOC + TXT --> DOC + DOC --> PRE[Composer 预览 PNG] + DOC --> CANVAS[Canvas Editor] +``` + +## 2. 扁平图拆层流程 + +```mermaid +flowchart TD + F[flat.png] --> VLM[VLM / OCR 识别] + VLM --> OBJ[对象清单] + OBJ --> SAM[SAM / RMBG 生成 mask] + SAM --> RGBA[RGBA 图层] + SAM --> MERGE[移除区域 mask 合并] + MERGE --> INP[Inpaint / Edit 修补背景] + INP --> CLEAN[Clean Plate 背景层] + VLM --> TEXT[普通文字识别] + TEXT --> TL[TextLayer] + RGBA --> DOC[LayeredDesignDocument] + CLEAN --> DOC + TL --> DOC + F --> ORIG[原始图备份层] + ORIG --> DOC +``` + +## 3. 单层重生成时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Canvas as Canvas Editor + participant Doc as LayeredDesignDocument + participant Gen as Asset Generator + participant Mat as Matting Processor + participant Store as Asset Store + + User->>Canvas: 选择角色层并点击重生成 + Canvas->>Doc: 读取 layer prompt / bbox / style + Doc->>Gen: 请求替代资产 + Gen-->>Doc: 返回 generated asset draft + Doc->>Mat: 需要透明通道时抠图修边 + Mat-->>Store: 写入新 RGBA asset + Store-->>Doc: 返回 newAssetId + Doc->>Doc: layer.assetId = newAssetId + Doc->>Doc: 记录 editHistory + Doc-->>Canvas: 更新图层和预览 + Canvas-->>User: 展示新角色层 +``` + +## 4. 图层文档对象关系 + +```mermaid +classDiagram + class LayeredDesignDocument { + id + title + status + canvas + layers[] + assets[] + preview + editHistory[] + } + + class DesignCanvas { + width + height + backgroundColor + safeArea + } + + class DesignLayer { + id + name + visible + locked + x + y + width + height + rotation + opacity + zIndex + } + + class ImageLayer { + assetId + maskAssetId + alphaMode + prompt + } + + class TextLayer { + text + fontFamily + fontSize + color + align + } + + class ShapeLayer { + shape + fill + stroke + strokeWidth + } + + class GeneratedAsset { + id + kind + src + width + height + hasAlpha + provider + modelId + prompt + } + + class LayerEditRecord { + id + kind + layerId + before + after + createdAt + } + + LayeredDesignDocument --> DesignCanvas + LayeredDesignDocument --> DesignLayer + LayeredDesignDocument --> GeneratedAsset + LayeredDesignDocument --> LayerEditRecord + DesignLayer <|-- ImageLayer + DesignLayer <|-- TextLayer + DesignLayer <|-- ShapeLayer + ImageLayer --> GeneratedAsset +``` + +## 5. 导出链路 + +```mermaid +flowchart LR + DOC[LayeredDesignDocument] --> RENDER[Canvas Renderer] + RENDER --> PNG[PNG Export] + DOC --> ZIP[JSON + assets zip] + DOC --> PSDMAP[PSD Mapper] + PSDMAP --> PSD[PSD-like Export] + DOC --> EVID[Evidence / Artifact] + PNG --> EVID + ZIP --> EVID + PSD --> EVID +``` + +## 6. Provider 能力分流 + +```mermaid +flowchart TD + REQ[资产请求] --> CAP[Provider Capability Matrix] + CAP -->|支持透明输出| TRANSPARENT[直接请求透明背景] + CAP -->|不支持或不稳定| OPAQUE[生成普通图片] + OPAQUE --> RMBG[RMBG / SAM / Matting] + TRANSPARENT --> ASSET[RGBA Asset] + RMBG --> ASSET + CAP -->|支持 edit / mask| EDIT[局部编辑 / clean plate] + CAP -->|不支持 edit| ALT[备用 inpainting provider] + EDIT --> CLEAN[Clean Plate Asset] + ALT --> CLEAN +``` diff --git a/docs/roadmap/ai-layered-design/flowcharts.md b/docs/roadmap/ai-layered-design/flowcharts.md new file mode 100644 index 000000000..79707f06a --- /dev/null +++ b/docs/roadmap/ai-layered-design/flowcharts.md @@ -0,0 +1,165 @@ +# AI 图层化设计流程图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:固定入口分流、图层计划、mask 质量、Canvas 编辑和导出决策流程,避免实现时混成单条不可维护长链。 + +## 1. 入口分流流程 + +```mermaid +flowchart TD + Start[用户请求] --> HasImage{是否上传已有图片?} + HasImage -->|否| Native[原生分层生成] + HasImage -->|是| WantsEdit{是否要求编辑已有图?} + WantsEdit -->|是| Split[扁平图拆层] + WantsEdit -->|否| Ref[作为参考图生成新设计] + + Native --> Plan[Layer Planner] + Ref --> Plan + Split --> Analyze[VLM / OCR / Segment] + + Plan --> CreateDoc[创建 LayeredDesignDocument] + Analyze --> Confirm[候选图层确认] + Confirm --> CreateDoc + CreateDoc --> Editor[Canvas Editor] +``` + +## 2. Layer Planner 决策流程 + +```mermaid +flowchart TD + Prompt[用户目标] --> Intent[提取设计意图] + Intent --> Format[确定画布尺寸和用途] + Format --> Layers[生成图层列表] + Layers --> TextCheck{是否包含普通文案?} + TextCheck -->|是| TextLayer[创建 TextLayer] + TextCheck -->|否| SkipText[跳过文本层] + Layers --> Bitmap[创建 bitmap asset 请求] + Bitmap --> NeedAlpha{是否需要透明?} + NeedAlpha -->|是| AlphaPlan[标记 alphaMode 需求] + NeedAlpha -->|否| OpaquePlan[普通图层] + TextLayer --> Validate[计划校验] + SkipText --> Validate + AlphaPlan --> Validate + OpaquePlan --> Validate + Validate --> Ready[Layer Plan Ready] +``` + +计划校验至少检查: + +1. 背景层存在。 +2. 普通文案不是 ImageLayer。 +3. 每个 ImageLayer 有 asset 生成或提取来源。 +4. zIndex 不冲突。 +5. 画布尺寸合法。 + +## 3. Mask 质量门禁流程 + +```mermaid +flowchart TD + Mask[候选 mask] --> Score[质量评分] + Score --> Edge{边缘是否稳定?} + Edge -->|否| Refine[Matting / Feather 修边] + Edge -->|是| Area{面积是否合理?} + Refine --> Area + Area -->|过小/过碎| Low[低置信度候选] + Area -->|合理| Alpha{是否需要半透明?} + Alpha -->|是| Soft[Soft alpha 处理] + Alpha -->|否| Rgba[RGBA 图层] + Soft --> Rgba + Low --> UserConfirm[用户确认是否保留] + UserConfirm -->|保留| Rgba + UserConfirm -->|丢弃| Drop[丢弃候选] +``` + +门禁原则: + +1. 不把低质量 mask 静默变成正式图层。 +2. 烟雾、头发、玻璃等半透明元素走 soft alpha。 +3. mask 失败时保留原图备份层。 + +## 4. Clean Plate 流程 + +```mermaid +flowchart TD + Extracted[已提取对象层] --> MergeMask[合并对象移除 mask] + MergeMask --> Inpaint[请求背景修补] + Inpaint --> Check{修补是否可信?} + Check -->|是| Clean[创建 clean background layer] + Check -->|否| Warn[创建背景层并标记修补风险] + Warn --> Manual[允许用户手动重试或保留原图] + Clean --> Editor[进入 Canvas] + Manual --> Editor +``` + +移动图层体验是否可信,取决于 clean plate 是否可用。 + +## 5. Canvas 编辑状态机 + +```mermaid +stateDiagram-v2 + [*] --> Loading + Loading --> Ready: document loaded + Ready --> Dirty: transform / visibility / zIndex changed + Dirty --> Saving: debounce save + Saving --> Ready: save success + Saving --> SaveFailed: save failed + SaveFailed --> Dirty: retry / edit again + Ready --> Regenerating: regenerate layer + Regenerating --> PreviewCandidate: provider success + Regenerating --> Ready: provider failed, keep old asset + PreviewCandidate --> Ready: accept candidate + PreviewCandidate --> Ready: discard candidate + Ready --> Exporting: export + Exporting --> Ready: export success/fail handled +``` + +状态规则: + +1. `Dirty` 状态离开页面要提示。 +2. `Regenerating` 不锁死整个画布,只锁当前层和相关操作。 +3. provider 失败必须回到旧 asset。 + +## 6. 导出决策流程 + +```mermaid +flowchart TD + Export[用户点击导出] --> Dirty{是否有未保存修改?} + Dirty -->|是| Save[先保存 document] + Dirty -->|否| Snapshot[生成 document snapshot] + Save --> SaveOk{保存成功?} + SaveOk -->|否| Stop[停止导出并提示] + SaveOk -->|是| Snapshot + Snapshot --> PNG[渲染 PNG] + Snapshot --> JSON[打包 JSON + assets] + Snapshot --> PSD{是否启用 PSD 试点?} + PSD -->|是| PSDMap[映射 PSD 图层] + PSD -->|否| SkipPSD[跳过 PSD] + PNG --> Artifact[写入 export artifact] + JSON --> Artifact + PSDMap --> Artifact + SkipPSD --> Artifact + Artifact --> Done[导出完成] +``` + +## 7. 分阶段推进流程 + +```mermaid +flowchart LR + P0[P0 文档与边界] --> P1[P1 原生分层生成] + P1 --> Gate1{可重新打开编辑?} + Gate1 -->|否| P1 + Gate1 -->|是| P2[P2 Canvas 编辑] + P2 --> Gate2{单层重生成不破坏其他层?} + Gate2 -->|否| P2 + Gate2 -->|是| P3[P3 扁平图拆层] + P3 --> Gate3{主要对象和 clean plate 可用?} + Gate3 -->|否| P3 + Gate3 -->|是| P4[P4 专业导出] +``` + +阶段门禁: + +1. P1 不通过,不做复杂 Canvas。 +2. P2 不通过,不做任意图拆层。 +3. P3 不通过,不承诺 PSD-like 专业交付。 diff --git a/docs/roadmap/ai-layered-design/implementation-plan.md b/docs/roadmap/ai-layered-design/implementation-plan.md new file mode 100644 index 000000000..2b5521fe9 --- /dev/null +++ b/docs/roadmap/ai-layered-design/implementation-plan.md @@ -0,0 +1,201 @@ +# AI 图层化设计实施计划 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 `LayeredDesignDocument`、原生分层生成、Canvas 编辑、扁平图拆层和导出能力拆成可执行阶段,确保实现持续回到 Lime current 主链。 + +## 1. 实施总原则 + +1. **先原生分层,后扁平拆层** + - 先让新生成结果天然有图层,再处理已有图片的反推问题。 + +2. **先 JSON + PNG,后 PSD** + - 首期先保证项目 JSON 可恢复、PNG 可导出,PSD 后续作为投影。 + +3. **先普通文本可编辑** + - 标题、正文、按钮文案必须是 TextLayer;艺术 Logo 可暂为 ImageLayer。 + +4. **先 provider seam,后模型优化** + - `gpt-image-2`、Gemini、Flux 等都必须通过统一 provider capability 进入,不写死到产品协议。 + +5. **先可验证体验,后模型微调** + - 当前不训练模型,不新增自研 image-to-PSD 路线。 + +## 2. P0:文档与边界落盘 + +目标:让后续实现有稳定事实源。 + +任务: + +1. 新增 `docs/research/ai-layered-design/` 研究拆解。 +2. 新增 `docs/roadmap/ai-layered-design/` 路线图、架构、实施计划和图纸。 +3. 固定 `LayeredDesignDocument` 是 current 设计工程事实源。 +4. 固定 `gpt-image-2` / Gemini 等模型只是 Asset Generator。 +5. 固定首期不自训模型、不承诺完整 PSD。 + +完成标准: + +1. 文档能解释 Lovart 类体验的技术链路。 +2. 文档能解释 Lime 为什么不需要先训练模型。 +3. 文档能给出 P1-P4 的实现顺序。 + +## 3. P1:原生分层生成 draft + +目标:从用户 prompt 生成一个可恢复的多图层设计文档。 + +### 3.1 用户流 + +```text +用户:做一张暗黑冥界女巫游戏海报 + -> Lime 生成 layer plan + -> 分别生成背景、人物、烟雾、Logo + -> 创建真实标题/正文 TextLayer + -> 合成 preview + -> 保存 design.json + assets +``` + +### 3.2 最小能力 + +1. Layer Planner 输出 5-8 个图层。 +2. Asset Generator 支持背景、主体、特效、Logo 四类 bitmap。 +3. 主体层能通过透明输出或抠图得到 RGBA。 +4. 普通文案作为 TextLayer。 +5. Composer 生成预览 PNG。 +6. 保存 `LayeredDesignDocument`。 + +### 3.3 完成标准 + +1. 同一设计重新打开后图层顺序、位置、可见性一致。 +2. preview 与 Canvas 渲染一致。 +3. 每个 ImageLayer 都能追踪 asset、prompt、provider 和 model。 +4. 不存在只保存最终 PNG 的设计项目。 + +## 4. P2:Canvas 图层编辑与单层重生成 + +目标:让用户能像设计工具一样调整图层。 + +### 4.1 用户流 + +```text +用户打开设计项目 + -> 选择角色层 + -> 拖动、缩放、隐藏或上移层级 + -> 点击“重生成此层” + -> Lime 替换角色 asset + -> 其他图层保持不变 +``` + +### 4.2 最小能力 + +1. 图层栏展示名称、缩略图、可见性、锁定态。 +2. Canvas 支持选中、拖拽、缩放。 +3. 属性面板展示位置、尺寸、透明度。 +4. 支持 zIndex 重排。 +5. 支持单层重生成。 +6. 编辑后保存到 `design.json`。 + +### 4.3 完成标准 + +1. 移动图层后刷新页面不丢失位置。 +2. 隐藏图层后导出 PNG 与 Canvas 一致。 +3. 单层重生成失败不会破坏旧图层。 +4. 单层重生成不会改变其他图层的 transform。 +5. 用户可导出当前预览 PNG。 + +## 5. P3:扁平图主要对象拆层 + +目标:用户上传已有图片后,Lime 能拆出主要对象并建立可编辑工程。 + +### 5.1 用户流 + +```text +用户上传海报 flat.png + -> Lime 识别人像、Logo、烟雾、文字和背景 + -> 生成主要对象 mask + -> 抠出 RGBA 图层 + -> 修补 clean background + -> 普通文字转 TextLayer + -> 进入 Canvas 编辑 +``` + +### 5.2 最小能力 + +1. 自动识别 3-8 个候选对象。 +2. 支持用户选择要保留的候选层。 +3. 输出主体 RGBA 图层。 +4. 输出 clean background。 +5. OCR 普通文案并生成 TextLayer。 +6. 标记低置信度图层。 + +### 5.3 完成标准 + +1. 主体移动后原位置没有明显空洞;若修补失败,UI 明确提示。 +2. 普通文本可以编辑内容。 +3. 艺术 Logo 作为 ImageLayer 可移动和替换。 +4. 用户可以回退到原始扁平图。 + +## 6. P4:专业交付与 PSD-like 导出 + +目标:让设计师能把 Lime 生成结果带到专业工具继续修。 + +### 6.1 最小能力 + +1. 导出 PNG。 +2. 导出项目 JSON + assets zip。 +3. 试点导出 PSD:ImageLayer、TextLayer、GroupLayer。 +4. 导出时保留图层名称、顺序、可见性和基础 transform。 + +### 6.2 完成标准 + +1. 导出的 PNG 与 Canvas 当前显示一致。 +2. JSON + assets 能完整恢复设计。 +3. PSD 试点文件能在主流设计工具打开并看到图层列表。 +4. TextLayer 在 PSD 中尽量保留文本语义;无法保留时必须降级为命名清晰的 raster layer。 + +## 7. 验证策略 + +### 7.1 文档阶段 + +P0 只改文档,最低校验: + +```bash +rg -n "ai-layered-design|LayeredDesignDocument|gpt-image-2" docs +``` + +### 7.2 类型与协议阶段 + +实现 `LayeredDesignDocument` 类型后: + +1. 增加类型单测或 schema 校验。 +2. 验证旧 PNG 输出路径仍可作为导出投影。 +3. 若接入媒体任务 artifact,补对应 runtime / artifact 测试。 + +### 7.3 GUI 阶段 + +实现 Canvas Editor 后: + +1. 补 `*.test.tsx` 验证图层栏、选中、隐藏、锁定和导出按钮。 +2. 执行 `npm run verify:local`。 +3. 涉及 Workspace 主路径时执行 `npm run verify:gui-smoke`。 + +### 7.4 命令与 provider 阶段 + +如果新增 Tauri 命令、Bridge 或 mock: + +1. 同步前端调用、Rust 注册、治理目录册和 mock。 +2. 执行 `npm run test:contracts`。 +3. 执行 `npm run governance:legacy-report`。 + +## 8. 退出条件 + +以下情况出现时,不继续推进下一阶段: + +1. 设计项目不能重新打开继续编辑。 +2. 图层编辑只存在前端内存,不落文档。 +3. 普通文案仍默认作为图片烘焙。 +4. 单层重生成会破坏其他图层状态。 +5. Provider 能力被硬编码到产品协议,无法替换模型。 + +一句话: + +**每个阶段都要证明 Lime 正在获得“设计工程能力”,而不是只多了一种图片生成方式。** diff --git a/docs/roadmap/ai-layered-design/prototype.md b/docs/roadmap/ai-layered-design/prototype.md new file mode 100644 index 000000000..4265680f9 --- /dev/null +++ b/docs/roadmap/ai-layered-design/prototype.md @@ -0,0 +1,225 @@ +# AI 图层化设计低保真原型 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:固定 Lime AI 图层化设计的首期界面骨架,确保后续 UI 实现是可编辑设计工作台,而不是聊天消息里的图片预览增强。 + +## 1. 视觉与布局原则 + +本功能属于**宽内容区工作台**,默认面向桌面端设计细调。 + +遵守 Lime UI 视觉语言: + +1. 主表面使用实体底色,不使用大面积半透明磨砂。 +2. 背景氛围弱于内容,避免高饱和渐变抢焦点。 +3. 中文信息优先,英文模型名只作为次级 metadata。 +4. 左图层栏、中央画布、右属性栏形成稳定三栏,不把所有设置塞进弹窗。 +5. 主操作用深色实心按钮,次级操作用描边或浅底。 + +## 2. 信息架构 + +```text +生成主舞台 + -> 图片任务结果卡 + -> 打开图层编辑 + -> AI 图层设计工作台 + -> 图层栏 + -> 画布 + -> 属性栏 + -> 历史 / 证据轻入口 + -> 导出 + +上传扁平图 + -> 智能拆层确认页 + -> AI 图层设计工作台 +``` + +首期不新增一级主导航。入口应该从 `生成` 链路进入,避免把 AI 图层化设计做成平行平台。 + +## 3. 生成结果卡原型 + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ 已生成:暗黑冥界女巫海报 5 个图层 · 可编辑 │ +├────────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────┐ 标题:HADES II 风格海报 │ +│ │ │ 输出:1024 x 1536 │ +│ │ preview.png │ 图层:背景 / 角色 / 烟雾 / Logo / 文案 │ +│ │ │ 模型:gpt-image-2 + RMBG 后处理 │ +│ └────────────────────┘ │ +│ │ +│ [打开图层编辑] [导出 PNG] [重新生成整套] [查看生成证据] │ +└────────────────────────────────────────────────────────────────────┘ +``` + +交互规则: + +1. `打开图层编辑` 是主按钮。 +2. `导出 PNG` 是次按钮,因为 PNG 只是投影结果。 +3. 模型名不作为主卖点,只显示在 metadata。 +4. 如果没有生成 `LayeredDesignDocument`,不能显示“可编辑图层”。 + +## 4. 图层设计工作台原型 + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ← 生成结果 暗黑冥界女巫海报 [保存] [导出] [分享] │ +├───────────────┬──────────────────────────────────────────────┬───────────────┤ +│ 图层 │ 画布 62% 对齐/吸附 │ 属性 │ +│ │ │ │ +│ ☑ Logo │ ┌────────────────────────────┐ │ 名称 │ +│ ☑ 角色 │ │ │ │ 角色 │ +│ ☑ 绿色烟雾 │ │ 海报预览画布 │ │ │ +│ ☑ 背景 │ │ 选中层显示边框和控制点 │ │ 位置 X / Y │ +│ │ │ │ │ 120 / 260 │ +│ + 添加图层 │ └────────────────────────────┘ │ │ +│ │ │ 尺寸 W / H │ +│ 生成历史 │ │ 780 / 980 │ +│ - 角色重生成 │ │ │ +│ - 背景生成 │ │ [重生成此层] │ +│ │ │ [替换素材] │ +└───────────────┴──────────────────────────────────────────────┴───────────────┘ +``` + +布局规则: + +1. 左侧图层栏宽度固定在可读范围,避免压缩图层名。 +2. 中央画布优先占空间,适配桌面宽屏。 +3. 右侧属性栏只显示当前选中层相关设置。 +4. 生成历史是轻入口,不替代 evidence pack。 + +## 5. 图层栏细节原型 + +```text +┌──────────────────────┐ +│ 图层 │ +├──────────────────────┤ +│ ☑ 🔒 Logo │ +│ HADES II 标题图层 │ +│ │ +│ ☑ 角色 │ +│ 白发女巫 · RGBA │ +│ │ +│ ☑ 绿色烟雾 │ +│ effect · screen │ +│ │ +│ ☑ 🔒 背景 │ +│ clean plate │ +└──────────────────────┘ +``` + +图层栏必须表达: + +1. 可见性。 +2. 锁定态。 +3. 图层类型。 +4. alpha / blend 状态。 +5. 低置信度或修补失败提示。 + +## 6. 属性栏原型 + +```text +┌──────────────────────────┐ +│ 角色 │ +│ ImageLayer · generated │ +├──────────────────────────┤ +│ 位置 │ +│ X 120 Y 260 │ +│ W 780 H 980 │ +│ 旋转 0° 透明度 100% │ +├──────────────────────────┤ +│ 生成信息 │ +│ provider: openai │ +│ model: gpt-image-2 │ +│ alpha: embedded / mask │ +│ prompt: 白发女巫... │ +├──────────────────────────┤ +│ [重生成此层] │ +│ [替换素材] [复制图层] │ +└──────────────────────────┘ +``` + +交互规则: + +1. 普通用户默认看到简短 prompt,完整 prompt 放展开区。 +2. `重生成此层` 不改变 layer id、zIndex 和 transform。 +3. 低风险字段可即时保存,高风险 provider 调用走确认动作。 + +## 7. 扁平图拆层确认原型 + +```text +┌────────────────────────────────────────────────────────────────────┐ +│ 智能拆层结果 原图:poster.png │ +├───────────────────────────────┬────────────────────────────────────┤ +│ 原图预览 │ 候选图层 │ +│ ┌─────────────────────────┐ │ ☑ 人物主体 置信度 92% │ +│ │ │ │ ☑ Logo 置信度 81% │ +│ │ flat poster │ │ ☑ 烟雾特效 置信度 76% │ +│ │ │ │ ☑ 背景 clean 已修补 │ +│ └─────────────────────────┘ │ ☐ 小碎片 置信度 31% │ +│ │ │ +│ [查看 mask] [查看修补背景] │ [进入图层编辑] [重新拆层] │ +└───────────────────────────────┴────────────────────────────────────┘ +``` + +规则: + +1. 低置信度候选层默认不选中。 +2. clean plate 失败时不能隐藏风险。 +3. 用户可以先进入编辑,再手动删除错误层。 + +## 8. 单层重生成面板原型 + +```text +┌──────────────────────────────────────────────┐ +│ 重生成:角色 │ +├──────────────────────────────────────────────┤ +│ 保持:位置、大小、层级、整体暗黑幻想风格 │ +│ 调整:让角色姿态更有攻击性,保留白发和绿光 │ +│ │ +│ 参考上下文:使用当前背景和相邻烟雾层 │ +│ 输出方式:生成新 asset,成功后替换当前层 │ +│ │ +│ [取消] [开始重生成] │ +└──────────────────────────────────────────────┘ +``` + +重生成结果必须支持: + +1. 接受。 +2. 丢弃。 +3. 再生成一版。 +4. 回到旧 asset。 + +## 9. 导出弹窗原型 + +```text +┌──────────────────────────────────────┐ +│ 导出设计 │ +├──────────────────────────────────────┤ +│ ☑ PNG 当前画布预览 │ +│ ☑ JSON + assets 可重新打开编辑 │ +│ ☐ PSD 试验性图层导出 │ +│ │ +│ 说明:PNG 只是导出结果,设计工程会继续保留 │ +│ │ +│ [取消] [导出] │ +└──────────────────────────────────────┘ +``` + +导出顺序: + +1. 首期默认勾选 PNG + JSON。 +2. PSD 在 P4 之前不可默认开启。 +3. 导出后 artifact 记录 document version。 + +## 10. 移动端与窄屏原则 + +首期以桌面端为主。窄屏只保证: + +1. 可查看预览。 +2. 可切换图层可见性。 +3. 可导出 PNG。 +4. 不承诺复杂拖拽编辑。 + +不要为了移动端把桌面工作台压成不可用的单列长页。 diff --git a/docs/roadmap/ai-layered-design/sequences.md b/docs/roadmap/ai-layered-design/sequences.md new file mode 100644 index 000000000..c3452d214 --- /dev/null +++ b/docs/roadmap/ai-layered-design/sequences.md @@ -0,0 +1,182 @@ +# AI 图层化设计时序图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:固定首期核心用户流和系统调用顺序,确保生成、编辑、拆层、导出都回到 `LayeredDesignDocument`。 + +## 1. 原生分层生成时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Generate as 生成主舞台 + participant Planner as Layer Planner + participant Router as Provider Router + participant Provider as Image Provider + participant Processor as Matting / Inpaint + participant Doc as Design Document Service + participant Artifact as Artifact / Evidence + + User->>Generate: 输入海报需求 + Generate->>Planner: 请求图层计划 + Planner-->>Generate: 返回 layer plan + Generate->>Router: 请求按层选择 provider/model + Router-->>Generate: 返回 generation plan + Generate->>Provider: 生成背景 / 主体 / 特效 / Logo + Provider-->>Generate: 返回 raw assets + Generate->>Processor: 抠图、alpha、clean plate 处理 + Processor-->>Generate: 返回 normalized assets + Generate->>Doc: 创建 LayeredDesignDocument + Doc-->>Generate: 返回 designId + preview + Generate->>Artifact: 记录 design artifact 与 provider evidence + Generate-->>User: 展示结果卡和“打开图层编辑” +``` + +关键约束: + +1. 生成成功的判断不是 provider 返回图片,而是 document 创建成功。 +2. evidence 记录 provider 调用,artifact 记录设计工程。 + +## 2. 打开与保存编辑时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Canvas as Canvas Editor + participant Doc as Design Document Service + participant Store as Design Store + participant Renderer as Preview Renderer + + User->>Canvas: 打开 designId + Canvas->>Doc: load design + Doc->>Store: 读取 design.json + assets + Store-->>Doc: 返回文档和资源引用 + Doc-->>Canvas: 返回 LayeredDesignDocument + Canvas-->>User: 渲染图层工作台 + + User->>Canvas: 拖动角色层 + Canvas->>Canvas: 本地更新 transform + Canvas->>Doc: 保存 layer patch + Doc->>Store: 写入 design.json + Doc->>Renderer: 更新 preview + Renderer-->>Doc: 返回 latest preview + Doc-->>Canvas: 保存成功 +``` + +关键约束: + +1. Canvas 可以做本地乐观更新,但最终必须保存 patch。 +2. 保存失败时 UI 必须保留未保存状态,不能假装已写入。 + +## 3. 单层重生成时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Canvas as Canvas Editor + participant Doc as Design Document Service + participant Router as Provider Router + participant Provider as Image Provider + participant Processor as Matting Processor + participant Store as Asset Store + participant Evidence as Evidence Pack + + User->>Canvas: 点击“重生成此层” + Canvas->>Doc: 请求 layer context + Doc-->>Canvas: 返回 prompt / bbox / style / neighbors + Canvas->>Router: 选择 provider/model + Router-->>Canvas: 返回 model decision + Canvas->>Provider: 生成替代 asset + Provider-->>Canvas: 返回 raw asset + Canvas->>Processor: 透明通道和边缘处理 + Processor-->>Store: 写入新 asset + Store-->>Doc: 返回 newAssetId + Doc->>Doc: 更新 layer.assetId,保留 transform + Doc->>Evidence: 记录 oldAssetId -> newAssetId + Doc-->>Canvas: 返回更新后的 document + Canvas-->>User: 展示新图层,可接受或回退 +``` + +关键约束: + +1. 重生成失败不能覆盖旧 asset。 +2. 重生成成功只替换资产,不改 layer id、位置和层级。 + +## 4. 扁平图拆层时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as 拆层确认页 + participant Analyzer as VLM / OCR Analyzer + participant Segmenter as SAM / RMBG + participant Inpaint as Inpaint Processor + participant Doc as Design Document Service + + User->>UI: 上传 flat.png + UI->>Analyzer: 识别对象、文字和布局 + Analyzer-->>UI: 返回对象候选和文字区域 + UI->>Segmenter: 请求主要对象 masks + Segmenter-->>UI: 返回 masks + confidence + UI->>Inpaint: 请求 clean plate + Inpaint-->>UI: 返回 clean background 或失败原因 + UI-->>User: 展示候选图层和置信度 + User->>UI: 确认进入图层编辑 + UI->>Doc: 创建 extracted LayeredDesignDocument + Doc-->>UI: 返回 designId +``` + +关键约束: + +1. 低置信度层必须让用户确认。 +2. clean plate 失败不阻断进入编辑,但必须显式提示风险。 + +## 5. 导出时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Canvas as Canvas Editor + participant Doc as Design Document Service + participant Exporter as Exporter + participant Artifact as Artifact Store + + User->>Canvas: 点击导出 + Canvas->>Doc: 获取当前 document version + Doc-->>Canvas: 返回 document snapshot + Canvas->>Exporter: 请求 PNG / JSON / PSD-like + Exporter->>Exporter: 渲染和打包 + Exporter->>Artifact: 写入 export artifact + Artifact-->>Exporter: 返回 export refs + Exporter-->>Canvas: 返回导出结果 + Canvas-->>User: 展示下载和打开位置 +``` + +关键约束: + +1. 导出必须绑定 document version。 +2. PNG、JSON、PSD-like 都是同一份 document 的投影。 + +## 6. Provider 能力降级时序 + +```mermaid +sequenceDiagram + participant Generator as Asset Generator + participant Capability as Capability Resolver + participant Provider as Image Provider + participant Fallback as Local / Secondary Processor + participant Doc as Design Document Service + + Generator->>Capability: 查询模型是否支持透明背景 + Capability-->>Generator: 不支持或未知 + Generator->>Provider: 生成普通图片 + Provider-->>Generator: 返回 opaque asset + Generator->>Fallback: RMBG / SAM 后处理 + Fallback-->>Generator: 返回 RGBA asset + Generator->>Doc: 写入 asset alphaMode=mask +``` + +关键约束: + +1. 能力降级必须体现在 asset metadata。 +2. UI 只显示“透明图层已生成”,不把降级细节暴露成主流程噪音。 diff --git a/docs/roadmap/creaoai/README.md b/docs/roadmap/creaoai/README.md new file mode 100644 index 000000000..e6e398721 --- /dev/null +++ b/docs/roadmap/creaoai/README.md @@ -0,0 +1,343 @@ +# Lime CreoAI 对照开发路线图 + +> 状态:P3A 已落地;P3B discovery 正在推进;P4 继续按 proposal 推进 +> 更新时间:2026-05-05 +> 目标:把 CreoAI 案例里的 “Coding Agent 编码 CLI / API / tools 并长期运行业务” 收敛成 Lime 可执行路线图,补强 skills pipeline 的生成、验证、注册和长期执行闭环。 + +配套研究: + +- [../../research/creaoai/README.md](../../research/creaoai/README.md) +- [../../research/creaoai/architecture-breakdown.md](../../research/creaoai/architecture-breakdown.md) +- [../../research/creaoai/tool-coding-orchestration.md](../../research/creaoai/tool-coding-orchestration.md) +- [../../research/creaoai/lime-gap-analysis.md](../../research/creaoai/lime-gap-analysis.md) +- [../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) +- [../../research/codex-goal/README.md](../../research/codex-goal/README.md) + +配套图纸: + +- [./diagrams.md](./diagrams.md) +- [./prototype.md](./prototype.md) +- [./coding-agent-layer.md](./coding-agent-layer.md) +- [./architecture-review.md](./architecture-review.md) + +相关路线图: + +- [../managed-objective/README.md](../managed-objective/README.md):把 Codex `/goal` 的 thread goal loop 启发收敛为 Lime 的跨 turn 目标推进控制层。 +- [../ai-layered-design/README.md](../ai-layered-design/README.md):AI 图层化设计路线图;它是 generated adapter 的潜在消费方,但 `LayeredDesignDocument`、Canvas Editor 和设计工程协议不归 Skill Forge 定义。 + +## 0. 当前落地状态 + +截至 2026-05-05,CreoAI 路线已经完成到 **P3A:workspace-local file registration**,并开始推进 **P3B:workspace catalog discovery**: + +1. `Capability Draft` 已支持 create / list / get / verify / register 命令链。 +2. verification gate 通过后,draft 才能进入 `verified_pending_registration`。 +3. `capability_draft_register` 只复制标准合规草案到当前 workspace 的 `.agents/skills//`,并记录来源、verification report 与权限摘要。 +4. Skills 工作台只展示草案、验证与注册结果;注册后仍没有“立即运行 / 自动化”入口。 +5. P3B 第一刀固定为 registered skill discovery:显式 `workspaceRoot` 扫描 `.agents/skills`,只投影带 `.lime/registration.json` 的 P3A 注册能力。 +6. P3B 后续仍待实现:runtime binding、Query Loop 可见性和 `tool_runtime` 授权。 + +## 1. 先给结论 + +Lime 不应该另做一个 CreoAI 式平行工具生成系统。 + +Lime 应该做的是: + +**让 Coding Agent 把 API、CLI、网页流程生成并编译成 Lime 标准 Skill / Adapter,再由现有 Query Loop、tool_runtime、Workspace 和 evidence pack 受控执行。** + +一句话北极星: + +**Lime 的 skills pipeline 从“安装和调用技能”升级为“生成、编译、验证、注册并长期运行技能”。** + +## 2. 权限宗旨 + +CreoAI 路线的核心不是“无限放权”,而是: + +**权限永远显式受控,能力逐级开放;限制的是未经验证、未经授权、不可审计的执行,不是限制 agent 的理解、设计和编码能力。** + +固定原则: + +1. Coding Agent 可以大胆理解需求、读文档、设计 adapter、写 draft、修 self-check。 +2. 系统必须管住它真实执行什么、写到哪里、能不能注册、能不能长期跑。 +3. P1A 默认限制 `bash / install / external write`,是为了控制第一阶段 blast radius,不代表长期永远低权限。 +4. 后续只能通过 sandbox、verification gate、permission policy、用户确认和 evidence audit 逐级放开。 +5. 任何外部写操作、花钱、发布、删除、改价、下单,都不能只靠模型自述安全,必须有结构化授权和可回放证据。 + +推荐长期分级: + +```text +Level 0: read-only discovery +Level 1: draft-scoped write +Level 2: fixture dry-run +Level 3: sandbox shell +Level 4: workspace-local verified execution +Level 5: human-confirmed external write +Level 6: policy-approved scheduled external write +``` + +一句话: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 3. 固定主链 + +后续所有实现必须收敛到下面这条主链: + +```text +用户目标 + -> Coding Agent / Skill Forge 识别能力缺口 + -> Coding Agent 探索 API / CLI / docs / website + -> 生成 capability draft:Skill / Adapter / Script / Contract / Test + -> verification gate 校验 contract / permission / dry-run / tests + -> 注册到 workspace-local skill catalog / ServiceSkill 投影 + -> agent_runtime_submit_turn / tool_runtime 统一执行 + -> Managed Objective 判断是否继续、阻塞或完成 + -> automation job / subagent 长期运行 + -> artifact / evidence pack / Workspace UI 统一展示 +``` + +这条主链意味着: + +1. `Coding Agent` 是能力作者,负责探索外部能力并写 adapter / contract / test。 +2. `Skill Forge` 是生成、draft、验证、注册的产品和工程边界,不是执行系统。 +3. `Generated Capability` 只是 draft 态,不是长期 runtime 主类型。 +4. 注册后必须回到现有 Skill / ServiceSkill / Adapter / tool runtime 标准。 +5. `Managed Objective` 只做目标推进控制,不是第四类执行实体。 +6. 长期任务必须复用 runtime queue、automation、subagent、evidence,不新增旁路。 + +## 4. 非目标 + +本路线图明确不做: + +1. 不复制电商运营垂类产品。 +2. 不新增平行 generated tools runtime。 +3. 不绕过 Agent Skills 包标准。 +4. 不让 agent 生成代码后直接长期执行。 +5. 不新增独立 scheduler、queue、artifact、evidence 系统。 +6. 不把外部 API / CLI 原始协议直接升格为 Lime 运行时协议。 +7. 不在首期承诺高风险外部写操作全自动执行。 +8. 不把 Codex `/goal` 照搬成 Lime 的平行 goal runtime。 + +## 5. 产品对象分层 + +### 4.0 Coding Agent / Agent Builder + +`Coding Agent` 是 CreoAI 启发里最核心的一层,负责把用户讲清楚的业务目标变成可验证的能力草案。详细设计见 [./coding-agent-layer.md](./coding-agent-layer.md)。 + +本层可以参考 [../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) 中对 `pi-mono` 的调研,但只参考 coding harness 的工程切面:会话分层、工具 allowlist、可插拔工具后端、事件生命周期和 deterministic test harness。Lime 不引入 pi-style 终端产品、JSONL session 事实源或全仓库 shell/write 权限。 + +它必须完成: + +1. 理解用户目标、成功标准和风险边界。 +2. 探索 API、CLI、docs、website、MCP 或本地代码入口。 +3. 生成 adapter、wrapper、script、contract、permission summary 和 fixture test。 +4. 根据 verification gate 的失败项修复 draft。 +5. 通过验证后提交注册,而不是直接长期执行。 + +固定边界: + +**Coding Agent 是 build-time capability author,不是新的 runtime,也不是 Managed Objective。** + +### 4.1 Skill Forge + +`Skill Forge` 是上游生成阶段,负责: + +1. 从用户目标中识别能力缺口。 +2. 探索 API / CLI / docs / website。 +3. 生成 Skill Bundle、Adapter Spec、script、contract、test 草案。 +4. 触发 verification gate。 +5. 通过后提交注册。 + +固定边界: + +**Skill Forge 不执行长期任务,不定义新的 runtime。** + +### 4.2 Generated Capability Draft + +`Generated Capability Draft` 是生成中间态,至少包含: + +1. 用户目标摘要。 +2. 来源能力说明。 +3. 生成文件清单。 +4. 输入输出 contract。 +5. 权限声明。 +6. 验证状态。 +7. 注册目标。 + +固定边界: + +**Draft 不能被当作 current tool 使用;验证和注册通过后,才投影为 Lime 标准对象。** + +### 4.3 Workspace-local Skill + +通过验证后的能力应落成 workspace-local skill: + +1. 遵守 Agent Skills 包结构。 +2. 可被 Skill Catalog / ServiceSkillCatalog 投影。 +3. 可被 Query Loop 发现和调用。 +4. 可被 workspace UI 展示来源、权限、最近运行和证据。 + +### 4.4 Runtime Binding + +执行绑定继续使用现有语义: + +1. `agent_turn` +2. `browser_assist` +3. `automation_job` +4. `native_skill` + +后续如果需要站点采集能力,先编译为 `SiteAdapterSpec`,再通过现有浏览器 runtime 执行。 + +### 4.5 Managed Objective + +`Managed Objective` 是目标推进控制层,参考 [Codex `/goal` 研究](../../research/codex-goal/README.md),负责: + +1. 保存当前 managed skill / automation job 的目标和成功标准。 +2. 判断是否需要继续下一轮 agent turn。 +3. 在缺输入、阻塞、预算耗尽、完成或失败时停止自动续跑。 +4. 要求 completion audit 消费 artifact / evidence,而不是只靠模型自报。 + +固定边界: + +**Managed Objective 必须挂到 `agent turn / subagent turn / automation job` 之一,不允许成为新的 runtime taxonomy。** + +详细架构、状态机和实施阶段独立维护在 [../managed-objective/README.md](../managed-objective/README.md)。本路线图只描述它与 Skill Forge / generated skill 的衔接关系。 + +## 6. 分阶段路线 + +### P0:文档与边界收口 + +目标:固定研究、路线图、术语和禁止项。 + +交付: + +1. `docs/research/creaoai/` 研究拆解。 +2. `docs/roadmap/creaoai/` 开发计划。 +3. 明确 `Skill Forge` 不新增 runtime。 +4. 明确 generated capability 必须进入 Skill / Adapter 标准。 + +验收: + +1. 文档能解释三层架构。 +2. 文档能解释和现有 skills pipeline 不冲突。 +3. 文档明确 current / deprecated / dead 边界。 + +### P1:workspace-local skill scaffold + +目标:让 agent 可以为一个明确目标生成 workspace-local skill 草案。 + +范围: + +1. 生成 `SKILL.md`。 +2. 生成 `metadata` 或等价 manifest 草案。 +3. 生成 `scripts/`、`examples/`、`tests/` 的最小结构。 +4. 在 Workspace 中展示 draft 状态。 + +验收: + +1. 用户能从对话请求创建本地 skill draft。 +2. draft 清楚标注来源、目标、权限、验证状态。 +3. 未验证 draft 不会进入默认 tool surface。 + +### P2:verification gate + +目标:注册前必须通过结构化校验。 + +最小 gate: + +1. 包结构校验。 +2. 输入输出 contract 校验。 +3. 权限声明校验。 +4. dry-run 或 fixture test。 +5. 高风险权限人工确认。 + +验收: + +1. 缺少 contract 的 draft 不能注册。 +2. 未声明联网、写文件、外部写操作的 draft 不能注册。 +3. 测试失败的 draft 只能保留为 draft。 +4. verification 结果能进入 evidence 或等价运行记录。 + +### P3:registration / runtime binding + +目标:通过验证的 workspace-local skill 先完成可审计注册,再进入现有 catalog 与 tool runtime。 + +范围: + +1. P3A:复制为 `/.agents/skills//`,并记录来源、verification report 与权限摘要。 +2. P3B:注册为 Skill Catalog / ServiceSkillCatalog 可发现项。 +3. P3B:由 Query Loop 注入相关 metadata。 +4. P3B:由 `tool_runtime` 统一裁剪和授权。 +5. P3B / P4:调用记录写入 timeline 与 artifact。 + +验收: + +1. P3A 注册后的 skill 包只在当前 workspace 本地落盘,不修改全局 seeded skill。 +2. P3A 不触发运行、自动化或外部写操作。 +3. P3B 注册后的 skill 可在后续 agent turn 中被发现和使用。 +4. tool surface 仍由现有 runtime 控制。 +5. evidence pack 能追踪 skill 来源、版本、调用结果。 + +### P4:managed execution + +目标:让验证后的 generated skill 可进入 scheduled / managed 任务。 + +范围: + +1. 绑定 `automation_job` 或 subagent team。 +2. 支持暂停、恢复、阻塞、人工输入。 +3. 任务产物进入 workspace artifact。 +4. 长期执行事实进入 evidence pack。 + +验收: + +1. 用户关掉窗口后,任务仍能通过 runtime 状态恢复或明确阻塞。 +2. 任务失败时能看到失败步骤、原因和下一步。 +3. 高风险外部写操作默认要求确认。 +4. Workspace 能展示最近运行、下次运行、证据入口。 + +## 7. 最小可交付场景 + +首个场景不选电商全链路,避免范围失控。 + +推荐首个场景: + +**给一个只读 CLI 或公开 API 生成 workspace-local skill,并定时产出 Markdown 报告。** + +示例任务: + +```text +每天上午 9 点读取某个公开数据源或本地 CLI 输出,生成一份趋势摘要,保存到 workspace,并在失败时提示我补配置。 +``` + +选择理由: + +1. 只读,风险低。 +2. 能覆盖 CLI / API adapter 生成。 +3. 能覆盖 contract、dry-run、注册、artifact、evidence。 +4. 后续可自然扩展到网页、登录态和外部写操作。 + +## 8. 与 AI 图层化设计的关系 + +AI 图层化设计不是 Skill Forge 的子阶段。 + +固定边界: + +1. `LayeredDesignDocument`、Canvas Editor、Layer Planner、设计项目导出,归 [../ai-layered-design/README.md](../ai-layered-design/README.md)。 +2. Skill Forge 只负责生成和验证可复用能力,例如 provider adapter、PSD exporter wrapper、OCR / matting tool wrapper。 +3. 通过验证后的 adapter 必须进入 workspace-local skill / ServiceSkill / tool_runtime 主链。 +4. AI 图层化设计可以消费这些 verified adapter,但不能让它们反向定义设计文档协议。 +5. 不为 AI 图层化设计新增平行 generated tools runtime。 + +## 9. 这一步与现有主线的关系 + +本路线图服务以下现有主线: + +1. `skill-standard.md`:补上自动生成和编译阶段。 +2. `query-loop.md`:所有执行继续走统一 submit turn 和 tool runtime。 +3. `harness-engine-governance.md`:自动执行必须导出证据。 +4. `remote-runtime.md`:未来远程触发只接入 current ingress,不自建 remote runtime。 +5. `task/README.md`:generated skill 的任务画像、模型路由和成本限额仍归 runtime 底层。 + +一句话: + +**这不是新产品旁路,而是把 Lime 现有 agent runtime 从“能用工具”推进到“能生产并治理工具”。** diff --git a/docs/roadmap/creaoai/architecture-review.md b/docs/roadmap/creaoai/architecture-review.md new file mode 100644 index 000000000..875458d82 --- /dev/null +++ b/docs/roadmap/creaoai/architecture-review.md @@ -0,0 +1,533 @@ +# CreoAI / Coding Agent 方案架构 Review + +> 状态:review gate +> 更新时间:2026-05-05 +> 目标:在进入实现前,重新检查 CreoAI 启发下的 Lime 方案是否缺层、缺闭环或误把目标续跑当成完整系统。 + +依赖文档: + +- [./README.md](./README.md) +- [./coding-agent-layer.md](./coding-agent-layer.md) +- [./implementation-plan.md](./implementation-plan.md) +- [./diagrams.md](./diagrams.md) +- [../managed-objective/README.md](../managed-objective/README.md) +- [../../research/creaoai/architecture-breakdown.md](../../research/creaoai/architecture-breakdown.md) +- [../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) + +## 1. Review 结论 + +当前方案已经比最初完整,但仍不能直接进入“大实现”。 + +最关键的修正是: + +**必须先实现 Coding Agent / Skill Forge 的能力生成闭环,再实现 Managed Objective 的长期推进闭环。** + +如果反过来先做 Managed Objective,会得到一个目标续跑器;它能让已有工具多跑几轮,但不能复现 CreoAI 案例里最关键的能力: + +```text +AI 根据用户目标现场写 adapter / wrapper / script + -> 调 CLI / API / docs / website + -> 生成 contract / permission / tests + -> 验证失败后自动修复 + -> 注册成 workspace-local skill + -> 再由 runtime 长期运行 +``` + +一句话: + +**现在可以进入实现,但只能进入 P1A:Coding Agent 生成未验证 skill draft;不能直接做自动续跑或长期任务。** + +## 2. 权限宗旨 Review + +本 review 固定一条不能被后续实现遗忘的原则: + +**权限永远显式受控,能力逐级开放;限制的是未经验证、未经授权、不可审计的执行,不是限制 Coding Agent 的理解、设计、编码和修复能力。** + +这意味着: + +1. P1A 限制 full shell / external write 是阶段性 blast radius 控制,不是长期能力上限。 +2. 后续可以开放 sandbox shell、verified execution、human-confirmed external write 和 policy-approved scheduled write。 +3. 每一级开放都必须有相应的 sandbox、verification、permission policy、用户确认或 evidence audit。 +4. 如果一个实现无法解释“这次放权由哪个 gate 保证安全”,就不应该进入 current 主链。 + +推荐分级: + +```text +Level 0: read-only discovery +Level 1: draft-scoped write +Level 2: fixture dry-run +Level 3: sandbox shell +Level 4: workspace-local verified execution +Level 5: human-confirmed external write +Level 6: policy-approved scheduled external write +``` + +一句话: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 3. 已补齐的关键层 + +### 3.1 已有:Coding Agent / Skill Forge 层 + +来源: + +- [./coding-agent-layer.md](./coding-agent-layer.md) + +已明确: + +1. Coding Agent 是 build-time capability author。 +2. Skill Forge 是 draft / gate / registration 的产品边界。 +3. Draft 未验证前不是 tool、不是 runtime。 +4. Coding Agent 仍通过 Query Loop 和 tool_runtime 执行。 + +### 3.2 已有:Managed Objective 层 + +来源: + +- [../managed-objective/README.md](../managed-objective/README.md) + +已明确: + +1. Managed Objective 是目标推进控制层。 +2. 它只能挂到 `agent turn / subagent turn / automation job`。 +3. 它不能新增 `goal_runtime / objective_scheduler / objective_queue / objective_evidence`。 +4. 完成审计必须消费 artifact / thread_read / evidence pack。 + +### 3.3 已有:三层产品骨架 + +来源: + +- [../../research/creaoai/architecture-breakdown.md](../../research/creaoai/architecture-breakdown.md) + +已明确: + +```text +Coding Agent / Agent Builder + -> Autonomous Execution / Runtime + -> Workspace / Agent App Surface +``` + +这说明方向没错,但实现还缺几个硬边界。 + +## 4. 仍缺的关键闭环 + +### 4.1 Capability Draft 的物理存储与索引 + +当前文档定义了 `GeneratedCapabilityDraft` 概念,但还没锁定: + +1. draft 文件实际放在哪里。 +2. draft manifest 使用什么结构。 +3. draft 与 workspace、session、turn、artifact 的关联键是什么。 +4. draft 如何被 Workspace 查询和展示。 +5. draft 删除、重命名、重新生成时如何处理历史引用。 + +推荐实现前补一条设计: + +```text +workspace-local draft store + -> draft manifest + -> generated files + -> artifact / evidence refs + -> verification status +``` + +首期建议: + +1. 先以 workspace-local 文件目录 + manifest 作为事实源。 +2. 只在 UI / API 中读取 manifest 投影。 +3. 不急着新增复杂数据库表,除非现有 workspace artifact 无法表达。 + +### 4.2 Verification Gate 还缺“行为与权限一致性”设计 + +当前 gate 有结构校验、contract、permission、dry-run,但还不够。 + +真正危险的是: + +```text +manifest 声明 read-only +但 wrapper 实际执行 network write / file delete / shell escape +``` + +因此 gate 至少要补三类检查: + +1. **声明检查** + - manifest 是否声明网络、文件、shell、浏览器、外部写操作。 + +2. **静态扫描** + - wrapper 是否出现危险命令、绝对路径、shell 拼接、未声明网络调用。 + +3. **沙箱 dry-run** + - dry-run 是否能限制文件写入范围、禁止外部写操作、记录实际行为。 + +首期可以做低风险版本: + +1. 只支持只读 CLI。 +2. 禁止 shell 拼接。 +3. 输出只能写 workspace artifact。 +4. 未通过 dry-run 不允许注册。 + +### 4.3 Tool Surface 注册还缺“隔离到可调用”的桥 + +当前方案说 verified skill 注册到 catalog,但还没完全写清: + +```text +verified draft + -> workspace-local skill catalog + -> ServiceSkill 投影 + -> Query Loop metadata + -> tool_runtime 裁剪 + -> evidence 记录来源 +``` + +实现前需要明确: + +1. 注册 API 是新增命令,还是复用现有 skill catalog 入口。 +2. workspace-local skill 如何避免污染全局 seeded skill。 +3. tool_runtime 如何只在当前 workspace 暴露该 skill。 +4. 注册失败如何回滚。 +5. 旧版本 skill 运行中的 automation job 如何处理版本漂移。 + +首期建议: + +1. P1A 不做注册。 +2. P2 gate 通过后只进入 `verified_pending_registration`。 +3. P3 单独做注册和 tool surface 接入。 + +### 4.4 Evidence Pack 还缺 generation / verification 事实链 + +现在 evidence pack 主要面向 runtime execution。 + +但 CreoAI 这条链还需要证明: + +1. Coding Agent 为什么生成这些文件。 +2. 它读取了哪些 source refs。 +3. 它做过哪些 self-check。 +4. verification gate 哪些项通过或失败。 +5. 修复循环改了什么。 +6. 注册时用了哪个 draft 版本。 + +否则后续 audit 只能看到“skill 被调用了”,看不到“skill 从哪来、是否可信”。 + +推荐补一条证据链: + +```text +capability_generation + -> draft_created + -> patch_applied + -> verification_run + -> verification_result + -> registration_result + -> runtime_invocation +``` + +首期建议: + +1. P1A 只把 draft_created / generated_files 写入 artifact 或 timeline。 +2. P2 再把 verification_result 接入 evidence pack。 +3. P3 注册后在 runtime invocation 中带 skill source metadata。 + +### 4.5 Coding Agent 的工具权限还缺最小能力面 + +要让 Coding Agent 写 adapter,不只是 prompt,它需要受控工具面: + +1. 读 API docs / CLI help。 +2. 读写 workspace draft 目录。 +3. 运行只读 dry-run。 +4. 生成 fixture。 +5. 读取测试结果。 + +但每个能力都要受控: + +1. 文件写入范围限制在 draft 目录。 +2. CLI 执行必须 allowlist 或用户确认。 +3. 网络访问必须按 source refs 限制。 +4. 依赖安装首期不要做。 +5. 浏览器登录态首期不要做。 + +否则 Coding Agent 层会变成“让模型随便写和跑代码”。 + +补充对照: + +[../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) 已确认 `pi-mono` 的默认 coding tools 是 `read / write / edit / bash`,另有 read-only tools `read / grep / find / ls`,并支持 `noTools / tools allowlist / customTools`。这说明 Lime 的首期不应该直接给模型完整 coding tools,而应该按 profile 给能力: + +| profile | P1A 策略 | 风险控制 | +| --- | --- | --- | +| `author_readonly` | 允许 | 只读 docs / source refs / CLI help | +| `author_draft_write` | 允许 | 只写 draft root,禁止路径逃逸 | +| `author_dryrun` | 有限允许 | 只运行 fixture / dry-run,禁止外部写 | +| `author_full_shell` | P1A 禁止,后续需升级授权 | 不给未验证 draft 任意 bash / install | +| `author_external_write` | P1A 禁止,后续需升级授权 | 不给未验证 draft 发布 / 下单 / 改价 | + +新增实现门槛: + +1. P1A 需要先定义工具 profile,不是只加 prompt。 +2. Draft 写入必须走受控 file backend,不是直接本地写文件。 +3. CLI 探索必须是 allowlist / dry-run / user-confirmed,不是任意 shell。 +4. 同一 draft 文件需要 patch 顺序或 mutation queue,避免并发覆盖。 + +### 4.6 Workspace UI 还缺“草案态”和“已注册态”的明确分离 + +当前 prototype 有 draft review 和 skill card,但实现时必须强约束: + +1. `draft` 卡片不能有“运行”按钮。 +2. `verified_pending_registration` 只能显示“注册”按钮。 +3. `registered` 才能显示“手动运行 / 创建任务”。 +4. `failed verification` 只能显示“修复 / 查看失败 / 丢弃”。 + +否则用户会误以为 AI 生成的代码已经安全可执行。 + +### 4.7 关闭浏览器 / 关闭 App / 云端执行的边界还没说透 + +视频里的“关掉浏览器还在干”容易误导。 + +Lime 当前是桌面 GUI 产品,需要明确三种情况: + +1. **关闭浏览器页签** + - 如果 Lime App 仍在,automation job 可以继续。 + +2. **关闭 Lime App** + - 本地 runtime 不应承诺继续执行,只能在下次启动后恢复 due job / queued turn。 + +3. **真正 24 小时运行** + - 需要云端 worker / remote runtime / 常驻后台进程,不能由 Managed Objective 单独解决。 + +首期文档应明确: + +**P1-P4 只承诺 app 内 durable state 和重启恢复,不承诺关 App 后仍执行。** + +### 4.8 多 Skill workflow / DAG 还不能现在做 + +CreoAI 电商案例是多能力链:监控、找货、生图、视频、文案、定价、上架。 + +但 Lime 首期如果直接做 DAG,会把范围炸开。 + +当前建议仍然正确: + +1. 先做单 skill draft。 +2. 再做 verification。 +3. 再做注册。 +4. 再做单 automation job + objective。 +5. 最后才考虑多 skill workflow。 + +需要在实现计划里明确: + +**多 step workflow 是后续扩展,不是 P1A / P2 / P3 的隐含需求。** + +### 4.9 安全与供应链还缺明确非目标 + +Coding Agent 写代码时,供应链风险会立即出现: + +1. 生成脚本引入 npm / pip 依赖。 +2. 从外部复制未知代码。 +3. 写入 shell 脚本并执行。 +4. 读取本地敏感文件。 +5. 上传数据到外部 API。 + +首期建议明确禁止: + +1. 自动安装依赖。 +2. 自动读取 secret 文件。 +3. 自动访问未声明域名。 +4. 自动执行外部写操作。 +5. 自动把生成脚本注册成工具。 + +## 5. 关键架构图:完整闭环 + +```mermaid +flowchart TB + User[用户目标] --> CodingAgent[Coding Agent\nclarify / discover / design / generate] + CodingAgent --> DraftStore[Workspace Draft Store\nmanifest / files / source refs] + DraftStore --> SelfCheck[Self Check\nschema / static scan / dry-run] + SelfCheck --> Gate[Verification Gate\ncontract / permission / fixture] + + Gate -->|失败| Repair[Coding Agent Repair Loop] + Repair --> DraftStore + + Gate -->|通过| Pending[verified_pending_registration] + Pending --> Registry[Workspace-local Skill Catalog] + Registry --> ToolSurface[tool_runtime workspace-scoped surface] + + ToolSurface --> ManualRun[Manual Agent Turn] + ToolSurface --> Job[Automation Job] + Job --> Objective[Managed Objective] + Objective --> Runtime[Query Loop / runtime_queue] + Runtime --> Artifact[Artifact] + Runtime --> Evidence[Evidence Pack] + Evidence --> Audit[Completion Audit] + Audit --> Objective +``` + +固定判断: + +1. Coding Agent 层在最前面。 +2. Verification Gate 是 draft 到 registry 的唯一门。 +3. Managed Objective 只出现在 verified skill 之后。 +4. Evidence 贯穿生成、验证、注册、运行和审计。 + +## 6. 推荐实施顺序修正 + +### P0.5:实现前补设计细节 + +进入代码前,先补齐: + +1. draft store 文件结构。 +2. draft manifest schema。 +3. verification gate 最小检查矩阵。 +4. workspace draft UI 状态机。 +5. evidence 事件命名。 + +### P1A:Coding Agent 生成未验证 draft + +只做: + +1. `capability_generation` request metadata。 +2. draft 文件生成。 +3. draft manifest。 +4. Workspace draft review。 +5. 未验证隔离。 + +不做: + +1. 不注册。 +2. 不长期运行。 +3. 不自动续跑。 +4. 不执行外部写操作。 + +### P1B:Draft self-check + +只做: + +1. schema 检查。 +2. 静态权限扫描。 +3. fixture dry-run。 +4. 失败项可视化。 + +### P2:Verification Gate + +只做: + +1. gate API。 +2. verification result。 +3. repair loop 输入。 +4. evidence / artifact 记录。 + +### P3:Registration + +只做: + +1. workspace-local catalog。 +2. ServiceSkill 投影。 +3. tool_runtime workspace-scoped surface。 +4. runtime invocation source metadata。 + +### P4:Managed Objective + automation job + +只做: + +1. verified skill 绑定 automation job。 +2. objective state。 +3. manual continuation。 +4. evidence audit。 + +自动 idle continuation 应继续后移,放到 P5。 + +## 7. 当前是否可以实现 + +可以,但只建议实现 P1A。 + +不建议现在实现: + +1. Managed Objective 自动续跑。 +2. automation job 长期绑定。 +3. tool_runtime 注册。 +4. 外部写操作。 +5. 多 skill workflow。 + +进入 P1A 之前,建议先更新或确认以下文档: + +1. 本 review 文档。 +2. [./coding-agent-layer.md](./coding-agent-layer.md) 的 draft store 细节。 +3. [./implementation-plan.md](./implementation-plan.md) 的 P0.5 / P1A / P1B 拆分。 +4. [./diagrams.md](./diagrams.md) 的完整闭环图。 + +## 8. Test review + +### P1A 最小测试图 + +```text +CODE PATH COVERAGE TARGET +========================= +[+] capability_generation metadata + ├── [GAP] 正常生成 metadata snapshot + ├── [GAP] 缺 workspace_id 拒绝 + └── [GAP] 高风险 source_kind 要求确认 + +[+] draft manifest builder + ├── [GAP] 生成最小 manifest + ├── [GAP] 缺 contract 标记 unverified + └── [GAP] generated_files 路径不能逃出 draft root + +[+] workspace draft projection + ├── [GAP] draft 显示 unverified + ├── [GAP] unverified 不显示运行按钮 + └── [GAP] failed verification 显示修复入口 + +[+] tool surface isolation + ├── [GAP] unverified draft 不进入 default tools + └── [GAP] automation job 不能绑定 unverified draft +``` + +P1A 必须至少补单测覆盖: + +1. manifest builder。 +2. path escape guard。 +3. unverified draft isolation。 +4. Workspace projection 状态。 + +GUI 变更还需要最小 smoke: + +1. 打开 Workspace。 +2. 看到 draft review 卡片。 +3. 确认未验证状态没有运行入口。 + +## 9. NOT in scope + +以下内容本阶段明确不做: + +1. 多 agent 自主扩队:需要更成熟的 subagent governance。 +2. 多 skill DAG workflow:先完成单 skill 能力治理。 +3. 关 App 后云端运行:需要 remote runtime / worker,不属于本路线图首期。 +4. 自动安装依赖:供应链风险过高。 +5. 外部写操作自动执行:必须等权限、审计、人工确认闭环成熟。 +6. 平行 workflow builder:会冲突现有 Skill / Query Loop / evidence 主链。 + +## 10. What already exists + +当前 Lime 已有可复用基础: + +1. Query Loop:`agent_runtime_submit_turn -> runtime_turn -> runtime_queue -> stream_reply_once`。 +2. Tool Runtime:统一裁剪工具面。 +3. Skill 标准:Agent Skill Bundle / ServiceSkill 投影。 +4. Automation Service:durable job 承载。 +5. Evidence Pack:运行事实导出。 +6. Workspace / Artifact:产物展示与持久化主链。 + +这些都应该被复用,不应该重建。 + +## 11. 最终建议 + +推荐决策: + +**先不实现 Managed Objective;先实现 P1A Coding Agent 生成未验证 skill draft。** + +理由: + +1. 这是 CreoAI 案例最核心、也是 Lime 当前缺得最明显的一层。 +2. P1A 风险可控,不碰自动执行和外部写操作。 +3. 它能为后续 verification gate、registration、Managed Objective 提供真实输入。 +4. 它避免把路线图带偏成“goal loop 产品”。 + +一句话: + +**先让 AI 安全地产生工具,再让工具安全地跑,再让目标持续推进。** diff --git a/docs/roadmap/creaoai/coding-agent-layer.md b/docs/roadmap/creaoai/coding-agent-layer.md new file mode 100644 index 000000000..817db6706 --- /dev/null +++ b/docs/roadmap/creaoai/coding-agent-layer.md @@ -0,0 +1,363 @@ +# Coding Agent / Skill Forge 层设计 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 CreoAI 启发中最关键的 “Coding Agent 现场写代码、调 CLI / API、生成 adapter 和测试” 单独定义清楚,避免路线图退化成只有 Managed Objective 的目标续跑器。 + +依赖文档: + +- [./README.md](./README.md) +- [./implementation-plan.md](./implementation-plan.md) +- [./diagrams.md](./diagrams.md) +- [../../research/creaoai/architecture-breakdown.md](../../research/creaoai/architecture-breakdown.md) +- [../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) +- [../../aiprompts/skill-standard.md](../../aiprompts/skill-standard.md) +- [../../aiprompts/query-loop.md](../../aiprompts/query-loop.md) + +## 1. 为什么必须单独成层 + +你指出的问题是对的:如果只有 `Managed Objective`,Lime 得到的是一个“目标续跑控制层”;但 CreoAI 案例最关键的不是续跑本身,而是: + +```text +Coding Agent 根据业务目标 + -> 读取 API / CLI / docs / website + -> 写 adapter / wrapper / script + -> 写 contract / permission / fixture test + -> 修复验证失败 + -> 注册为可复用能力 +``` + +所以 Lime 的完整方案必须有两条互相衔接、但不能混成一条的链: + +1. **Coding Agent / Skill Forge 链** + - 负责生产能力。 + +2. **Managed Objective 链** + - 负责围绕目标持续使用能力。 + +一句话: + +**没有 Coding Agent 层,方案只是在“让已有工具多跑几轮”;有了 Coding Agent 层,才是在“让 AI 生产并治理新工具”。** + +## 2. 层级定位 + +`Coding Agent` 是执行者,`Skill Forge` 是产品与工程边界。 + +二者关系: + +| 名称 | 含义 | 在 Lime 中的边界 | +| --- | --- | --- | +| Coding Agent | 负责理解目标、探索外部能力、写代码、修测试的 agent 行为模式 | 仍通过 `agent_runtime_submit_turn / Query Loop / tool_runtime` 执行 | +| Skill Forge | 承载生成流程、draft、验证、注册和 UI 的产品层 | 不定义新 runtime,不绕过 skill 标准 | +| Generated Capability Draft | Coding Agent 的中间产物 | 未验证前不能进入默认 tool surface | +| Verification Gate | 注册前门禁 | 结构、contract、permission、dry-run、fixture test | +| Workspace-local Skill | 验证后的标准能力 | 进入 Skill Catalog / ServiceSkill 投影 | + +固定边界: + +**Coding Agent 是 build-time capability author,不是 long-running task runner。** + +## 3. 权限宗旨:受控执行,不是低能力 + +本层最容易被误解成“为了安全削弱 Coding Agent”。固定修正: + +**限制的是未经验证、未经授权、不可审计的执行;不限制 agent 理解需求、探索资料、设计 adapter、编写 draft 和修复 self-check 的能力。** + +为什么要这样做: + +1. 通用 coding agent 面向开发者,风险主要是“改坏代码”。 +2. Lime 的 generated capability 未来会进入 skill catalog、automation job 和 evidence 主链,风险会扩展到账号、API、业务数据、外部发布、花钱、删除和长期重复执行。 +3. 如果未验证 draft 能直接跑,错误会从“一次 agent turn”放大为“长期业务自动化事故”。 +4. 因此 Coding Agent 可以大胆生成能力,但系统必须管住它真实执行什么、写到哪里、能否注册、能否长期运行。 + +固定长期原则: + +1. 权限永远显式受控。 +2. 能力可以逐级开放。 +3. 默认 deny 只适用于当前未验证阶段,不代表永远禁止高级能力。 +4. 每次放权都必须有 sandbox、verification、permission policy、用户确认或 evidence audit 支撑。 + +推荐分级: + +```text +Level 0: read-only discovery +Level 1: draft-scoped write +Level 2: fixture dry-run +Level 3: sandbox shell +Level 4: workspace-local verified execution +Level 5: human-confirmed external write +Level 6: policy-approved scheduled external write +``` + +一句话: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 4. Coding Agent 工作循环 + +推荐最小循环: + +```text +clarify + -> discover + -> design + -> generate + -> self-check + -> submit verification + -> repair + -> register +``` + +每一步职责: + +1. `clarify` + - 明确用户目标、成功标准、输入输出、风险等级。 + +2. `discover` + - 读取 CLI help、API docs、OpenAPI schema、网页说明、已有代码入口。 + +3. `design` + - 决定生成哪类能力:Skill Bundle、Adapter Spec、wrapper script、fixture test。 + +4. `generate` + - 写 draft 文件,不注册,不进入默认工具面。 + +5. `self-check` + - 本地静态检查、schema 检查、最小 dry-run。 + +6. `submit verification` + - 把 draft 交给 verification gate。 + +7. `repair` + - 根据 gate 失败项修复文件和测试。 + +8. `register` + - gate 通过后进入 workspace-local skill catalog。 + +## 5. 输入输出契约 + +### 4.1 `CapabilityGenerationRequest` + +概念输入: + +```text +request_id +workspace_id +user_goal +success_criteria[] +source_kind: cli | api | docs | website | mcp | local_code +source_refs[] +risk_policy +permission_expectation +runtime_binding_target? +``` + +约束: + +1. `workspace_id` 必须存在。 +2. `user_goal` 必须能转成能力边界,不能只是“帮我搞增长”。 +3. `source_refs` 必须可追踪,不能只写“网上找的”。 +4. 高风险权限默认需要人工确认。 + +### 4.2 `GeneratedCapabilityDraft` + +概念输出: + +```text +draft_id +workspace_id +request_id +name +description +source_summary +generated_files[] +input_contract_ref +output_contract_ref +permission_summary +runtime_binding_target +verification_status +created_at +updated_at +``` + +约束: + +1. Draft 不是 tool。 +2. Draft 不是 runtime。 +3. Draft 不能被 automation job 自动调用。 +4. Draft 只能进入 verification gate。 + +### 4.3 `CapabilityPatchSet` + +Coding Agent 每次修改 draft 时,应能形成 patch set 摘要: + +```text +patch_id +draft_id +changed_files[] +reason +source_refs[] +self_check_summary +``` + +作用: + +1. 让用户知道 agent 改了什么。 +2. 让 verification gate 能回溯失败和修复。 +3. 让 evidence pack 后续能关联能力来源。 + +## 6. 和 Query Loop 的关系 + +Coding Agent 本身不需要新 runtime。 + +它应该被理解为一类 `agent turn`: + +```text +用户请求生成能力 + -> agent_runtime_submit_turn + -> request_metadata.harness.capability_generation + -> runtime_turn / TurnInputEnvelope + -> tool_runtime 提供受控文件、CLI、docs、workspace 工具 + -> 生成 draft artifact + -> verification gate +``` + +固定规则: + +1. 不新增 `coding_agent_runtime`。 +2. 不新增 generated tool registry。 +3. 不绕过 `tool_runtime` 直接执行外部命令。 +4. 不把 draft 文件当成已注册 skill。 +5. 所有生成动作都要能进入 timeline / artifact / evidence。 + +## 7. 和 Managed Objective 的关系 + +二者是前后关系,不是同一层: + +```text +Coding Agent / Skill Forge + -> 生成并验证 workspace-local skill + -> 注册为可发现能力 + -> 用户创建 automation job + -> Managed Objective 绑定 job / session + -> Query Loop 长期执行并 evidence audit +``` + +固定判断: + +1. Coding Agent 负责“有什么能力可用”。 +2. Managed Objective 负责“这个目标是否还要继续”。 +3. automation job 负责“什么时候后台触发”。 +4. Query Loop / tool_runtime 负责“真实执行”。 +5. evidence pack 负责“事实导出”。 + +禁止混淆: + +1. 不让 Managed Objective 生成 adapter。 +2. 不让 Coding Agent 直接长期运行 job。 +3. 不让 verification gate 变成 scheduler。 +4. 不让 draft 逃过注册直接进入 objective。 + +## 8. pi-mono 参考边界 + +[../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) 说明 `pi-mono` 可以作为本层的 engineering reference,但不能作为 Lime 的新产品形态。 + +### 8.1 可借鉴 + +1. `AgentSession / AgentSessionRuntime / services` 分层。 +2. `read-only tools` 与 `coding tools` 分级。 +3. `noTools / tools allowlist / customTools` 的工具面裁剪。 +4. `BashOperations / EditOperations` 可插拔后端。 +5. agent / turn / message / tool / session lifecycle events。 +6. faux provider + deterministic harness 测试。 + +### 8.2 不可照搬 + +1. 不新增 `coding_agent_runtime`。 +2. 不把 JSONL session 变成 Lime 第二事实源。 +3. 不开放全仓库 `bash / write / edit` 给 draft 生成任务。 +4. 不让 pi-style extension runtime 替代 Lime governance / evidence。 +5. 不把终端命令系统搬进 Lime 前台。 + +### 8.3 P1A 工具面修正 + +因此 P1A 不应被设计成完整 Coding Agent,而应设计成受控的 **Capability Authoring Agent**: + +| 工具档位 | P1A 策略 | 说明 | +| --- | --- | --- | +| `author_readonly` | 开放 | 读 docs、source refs、CLI help、OpenAPI、本地代码片段 | +| `author_draft_write` | 开放但强限制 | 只写 draft root 内 `SKILL.md / manifest / scripts / examples / tests` | +| `author_dryrun` | 有限开放 | 只运行 fixture / dry-run,不允许外部写操作 | +| `author_full_shell` | P1A 禁止,后续需升级授权 | 不允许未验证阶段任意 bash、依赖安装、任意网络访问 | +| `author_external_write` | P1A 禁止,后续需升级授权 | 不允许未验证阶段发布、下单、改价、发消息 | + +固定判断: + +**Lime 这层弱,不代表要补成通用 Coding Agent;先补成可治理的 capability authoring 子集。** + +## 9. 首期实现切片建议 + +如果现在要开始实现 CreoAI 方向,第一刀不应该是自动续跑,而应该是: + +**P1A:Coding Agent 生成 workspace-local skill draft 的最小闭环。** + +最小范围: + +1. 对话中声明 `capability_generation` metadata。 +2. 让 agent 为只读 CLI 生成 draft 文件清单。 +3. draft 至少包含 `SKILL.md`、wrapper、input/output contract、fixture test、permission summary。 +4. Workspace 显示 draft 为 `unverified`。 +5. draft 不进入默认 tool surface。 + +不做: + +1. 不注册 skill。 +2. 不执行长期任务。 +3. 不自动续跑。 +4. 不做外部写操作。 + +验收: + +1. 用户能看到 Coding Agent 生成了哪些文件。 +2. 用户能看到来源、权限和未验证状态。 +3. 未验证 draft 不能被 Query Loop 当作可用 skill 调用。 +4. 后续 P2 verification gate 可以直接消费该 draft。 + +## 10. current / deprecated / dead 边界 + +### current + +1. Coding Agent 作为 `agent turn` 的一种任务模式。 +2. Skill Forge 作为 draft / verification / registration 产品层。 +3. Agent Skill Bundle / Adapter Spec 作为生成目标。 +4. Verification Gate 作为注册门禁。 +5. Workspace-local skill catalog 作为注册投影。 + +### deprecated + +1. 只写 prompt 让 agent “自己生成工具”,但没有 draft / gate / registration 状态。 +2. 把 generated script 直接加入 tool surface。 +3. 把 Coding Agent 写成独立 runtime。 +4. 把外部 API schema 直接变成 Lime runtime 协议。 + +### dead + +1. `coding_agent_runtime`。 +2. `generated_tool_registry`。 +3. `unverified_skill_executor`。 +4. `direct_script_automation`。 + +## 11. 实现前检查清单 + +进入代码实现前,至少确认: + +1. 首期目标是不是 P1A draft 生成,而不是 P4 自动执行。 +2. draft 文件结构是否已和 `skill-standard.md` 对齐。 +3. 生成动作是否仍走 Query Loop。 +4. 未验证 draft 是否有明确隔离。 +5. Workspace 是否只展示 draft,不把它当可执行 skill。 +6. 后续 verification gate 的输入是否已预留,但没有过度实现。 + +一句话: + +**先让 Coding Agent 会安全地产生能力,再让系统安全地运行能力。** diff --git a/docs/roadmap/creaoai/diagrams.md b/docs/roadmap/creaoai/diagrams.md new file mode 100644 index 000000000..3737f7bef --- /dev/null +++ b/docs/roadmap/creaoai/diagrams.md @@ -0,0 +1,297 @@ +# CreoAI 启发下的 Lime 架构图与流程图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 作用:把 Skill Forge、generated capability、skills pipeline、runtime execution 和 evidence 闭环画成可复查图纸。 + +配套原型: + +- [prototype.md](./prototype.md) + +本文负责架构图、流程图、时序图和边界图;产品低保真原型统一放在 `prototype.md`。 + +## 1. 三层架构对照图 + +```mermaid +flowchart TB + User[用户目标 / 约束 / 成功标准] --> CodingAgent[Coding Agent / Agent Builder
探索 API / CLI / docs / website] + CodingAgent --> Forge[Skill Forge
Draft / Gate / Registration 边界] + Forge --> Capability[Generated Capability Draft
Skill / Adapter / Script / Contract / Test] + Capability --> Verify[Verification Gate
schema / permission / dry-run / tests] + Verify --> Registry[Workspace-local Skill Registry
Skill Catalog / ServiceSkill 投影] + Registry --> Objective[Managed Objective
目标 / 成功标准 / 续跑策略] + Objective --> Runtime[Autonomous Execution
Query Loop / tool_runtime / automation / subagent] + Runtime --> Workspace[Workspace / Agent App Surface
artifact / task / memory / evidence] + Workspace --> User + Workspace --> Forge +``` + +固定判断: + +1. `Skill Forge` 是生成阶段,不是 runtime。 +2. `Generated Capability Draft` 验证前不能进入默认工具面。 +3. `Managed Objective` 只做目标推进控制,不是第四类 runtime。 +4. 真实执行必须回到 Lime current runtime。 + +## 1.1 Coding Agent 内部循环图 + +```mermaid +flowchart LR + Clarify[clarify +目标 / 成功标准 / 风险] --> Discover[discover +API / CLI / docs / website] + Discover --> Design[design +Skill / Adapter / Contract] + Design --> Generate[generate +wrapper / script / tests] + Generate --> SelfCheck[self-check +schema / dry-run] + SelfCheck --> Gate[verification gate] + Gate -->|失败| Repair[repair draft] + Repair --> SelfCheck + Gate -->|通过| Register[workspace-local registration] +``` + +固定判断: + +1. Coding Agent 是能力作者,不是长期执行器。 +2. 每一步仍必须通过 Query Loop / tool_runtime 的受控能力完成。 +3. Gate 通过前,draft 不能进入默认 tool surface。 + +## 1.2 Capability Authoring 工具面分级图 + +```mermaid +flowchart TB + Agent[Capability Authoring Agent] --> ReadOnly[author_readonly
docs / source refs / CLI help] + Agent --> DraftWrite[author_draft_write
draft root scoped write / patch] + Agent --> DryRun[author_dryrun
fixture / static scan / dry-run] + + ReadOnly --> Draft[Generated Capability Draft] + DraftWrite --> Draft + DryRun --> SelfCheck[Self-check Result] + SelfCheck --> Draft + + Agent -.P1A 禁止
后续升级授权.-> FullShell[author_full_shell
任意 bash / install] + Agent -.P1A 禁止
后续升级授权.-> ExternalWrite[author_external_write
发布 / 下单 / 改价] +``` + +固定判断: + +1. 参考 pi-mono 的工具分级,但 P1A 比通用 coding harness 更保守。 +2. `author_draft_write` 只能写 draft root,不能写 workspace 任意文件。 +3. `author_dryrun` 只能产生 self-check 事实,不能长期执行任务。 +4. 完整 shell 和外部写操作不是永远禁止,但必须等 sandbox / verification / permission / 人工确认 / evidence audit 闭环成熟后逐级开放。 +5. 限制的是未经验证、未经授权、不可审计的执行,不是限制 agent 的理解、设计和编码能力。 + +## 2. 外部能力编译流程图 + +```mermaid +flowchart LR + Source[Capability Source
API / CLI / Docs / Website / MCP] --> Explore[Agent 探索能力] + Explore --> Adapter[生成 adapter / wrapper / script] + Adapter --> Contract[生成 input / output contract] + Contract --> Permission[生成 permission summary] + Permission --> Tests[生成 examples / fixture / dry-run] + Tests --> Bundle[编译为 Skill Bundle / Adapter Spec] + Bundle --> Gate[Verification Gate] + Gate -->|通过| Register[注册 workspace-local skill] + Gate -->|失败| Draft[保留 draft 并给出修复建议] +``` + +固定判断: + +1. 来源格式只提供原料。 +2. Lime 标准仍是 Skill Bundle / Adapter Spec。 +3. gate 失败只能保留 draft,不能注册。 + +## 3. 与 Query Loop 的边界图 + +```mermaid +flowchart TB + subgraph BuildTime[生成 / 编译阶段] + Goal[用户能力生成目标] + Forge[Skill Forge] + Draft[Capability Draft] + Gate[Verification Gate] + end + + subgraph Runtime[现有运行时主链] + Objective[Managed Objective
控制层,不是 runtime taxonomy] + Submit[agent_runtime_submit_turn] + Turn[runtime_turn / TurnInputEnvelope] + ToolRuntime[tool_runtime] + Queue[runtime_queue / automation] + Stream[stream_reply_once] + end + + subgraph Facts[事实源] + Timeline[timeline] + Artifact[artifact] + Evidence[evidence pack] + ThreadRead[thread read] + end + + Goal --> Forge + Forge --> Draft + Draft --> Gate + Gate -->|注册后| Objective + Objective --> Submit + Submit --> Turn + Turn --> ToolRuntime + ToolRuntime --> Queue + Queue --> Stream + Stream --> Timeline + Stream --> Artifact + Timeline --> Evidence + Artifact --> Evidence + Evidence --> ThreadRead + Evidence --> Objective + + Draft -.验证前禁止进入.-> ToolRuntime + Objective -.不能绕过.-> Queue +``` + +固定判断: + +1. 生成阶段不能绕过 submit turn。 +2. tool surface 仍由 `tool_runtime` 裁剪。 +3. evidence pack 是执行事实源。 +4. Managed Objective 必须消费 evidence / artifact 做完成审计,不能只靠模型自报完成。 + +## 4. Verification gate 时序图 + +```mermaid +sequenceDiagram + participant U as 用户 + participant A as Coding Agent + participant F as Skill Forge + participant V as Verification Gate + participant C as Catalog + participant R as Runtime + participant E as Evidence + + U->>A: 描述要生成的 CLI / API 技能 + A->>F: 生成 draft bundle / adapter / tests + F->>V: 提交结构、contract、权限、dry-run + V-->>F: 返回验证结果 + + alt 验证失败 + F-->>U: 展示失败项与修复建议 + else 验证通过 + F->>C: 注册 workspace-local skill + C-->>U: 显示可用 skill 与权限摘要 + U->>R: 手动运行或创建 managed job + R->>E: 写入调用、产物、验证事实 + end +``` + +## 5. 长期任务执行闭环 + +```mermaid +flowchart TD + Start[Managed Skill Job] --> Objective[加载 Managed Objective
目标 / 成功标准 / 预算] + Objective --> Load[加载 workspace-local skill] + Load --> Policy[检查权限 / sandbox / budget] + Policy --> Execute[tool_runtime 执行] + Execute --> Result{任务结果} + + Result -- 成功 --> Verify[completion audit
结果验证] + Verify --> Artifact[写入 artifact] + Artifact --> Evidence[更新 evidence pack] + Evidence --> Audit{目标是否完成} + Audit -- 已完成 --> Done[completed] + Audit -- 未完成 --> Continue[下一轮 continuation turn] + Continue --> Execute + + Result -- 缺输入 --> NeedsInput[needs_input] + NeedsInput --> User[请求用户补充] + User --> Objective + + Result -- 可恢复失败 --> Retry[retry / resume] + Retry --> Execute + + Result -- 不可恢复失败 --> Failed[failed / blocked] + Failed --> Evidence +``` + +固定判断: + +1. 长期任务必须能明确完成、阻塞或失败。 +2. 失败路径和成功路径都要进入 evidence。 +3. 需要用户输入时不能伪装成自动完成。 +4. continuation turn 只能由 Managed Objective 策略触发,并继续走 Query Loop。 + +## 6. Workspace 可见面图 + +```mermaid +flowchart TB + Workspace[Workspace] --> Skills[Generated Skills] + Workspace --> Jobs[Managed Jobs] + Workspace --> Objectives[Managed Objectives] + Workspace --> Artifacts[Artifacts] + Workspace --> Evidence[Evidence] + + Skills --> SkillCard[Skill Card
来源 / 权限 / 验证 / 版本] + Jobs --> JobCard[Job Card
状态 / 下次运行 / 阻塞 / 操作] + Objectives --> ObjectiveCard[Objective Card
目标 / 成功标准 / audit 状态] + Artifacts --> Output[Output Viewer
报告 / 数据 / 草稿] + Evidence --> Audit[Audit View
调用 / 失败 / 确认 / 回放] + + SkillCard --> Run[手动运行] + SkillCard --> Schedule[创建定时任务] + ObjectiveCard --> Review[查看证据] + JobCard --> Pause[暂停] + JobCard --> Resume[恢复] + JobCard --> Review +``` + +固定判断: + +1. 用户必须能看见 agent 生成了什么能力。 +2. 用户必须能看见能力权限和验证状态。 +3. 用户必须能看见长期任务对应的目标和完成审计状态。 +4. 用户必须能从任务回到 evidence。 + +## 7. current / deprecated 边界图 + +```mermaid +flowchart LR + Current[Current 主链
Skill Bundle / Adapter Spec / ServiceSkill / Query Loop / tool_runtime / automation job / evidence] --> OK[继续强化] + + Deprecated[Deprecated 方向
平行 generated tools runtime / goal runtime / 直接执行脚本 / 单场景 scheduler / 单场景 evidence] --> Stop[停止扩展] + + Source[外部来源
API / CLI / Website / MCP] --> Compile[编译到 Lime 标准] + Compile --> Current + Source -.禁止直接成为 runtime 标准.-> Deprecated + GoalPattern[Codex /goal
persistent objective 参考] --> ObjectivePattern[折回 Managed Objective 控制层] + ObjectivePattern --> Current + GoalPattern -.禁止照搬为第四 runtime.-> Deprecated +``` + +## 8. 与 AI 图层化设计的消费关系图 + +```mermaid +flowchart LR + Forge[Skill Forge] --> Adapter[Verified Adapter / Skill] + Adapter --> ToolRuntime[tool_runtime] + ToolRuntime --> Design[AI Layered Design] + Design --> Doc[LayeredDesignDocument] + + Design -. owns .-> Doc + Forge -. does not own .-> Doc +``` + +固定判断: + +1. Skill Forge 可以生成 provider adapter、PSD exporter、OCR / matting wrapper。 +2. 这些 adapter 通过验证后才能被 AI 图层化设计消费。 +3. `LayeredDesignDocument`、Canvas Editor 和设计导出协议仍归 [../ai-layered-design/README.md](../ai-layered-design/README.md)。 +4. 不允许为了图层化设计新增平行 generated tools runtime。 + +## 9. 后续补图原则 + +后续如果本路线图继续补图,遵守三条规则: + +1. 只画 current 主链,不为平行 generated runtime 画主图。 +2. 图中执行节点必须能对应到 Lime 现有 Query Loop、tool_runtime、workspace 或 evidence 主链。 +3. 如果实现改变事实源或状态机,优先更新本文图纸和 `implementation-plan.md`。 diff --git a/docs/roadmap/creaoai/implementation-plan.md b/docs/roadmap/creaoai/implementation-plan.md new file mode 100644 index 000000000..badcea3db --- /dev/null +++ b/docs/roadmap/creaoai/implementation-plan.md @@ -0,0 +1,514 @@ +# CreoAI 启发下的 Lime 实施计划 + +> 状态:P3A 已落地;P3B discovery 正在推进;P4 继续按 proposal 推进 +> 更新时间:2026-05-05 +> 目标:把 Skill Forge / workspace-local generated skill 的落地拆成可执行阶段,确保实现不偏离 Lime current 主链。 + +依赖文档: + +- [./README.md](./README.md) +- [./coding-agent-layer.md](./coding-agent-layer.md) +- [./architecture-review.md](./architecture-review.md) +- [./diagrams.md](./diagrams.md) +- [./prototype.md](./prototype.md) +- [../managed-objective/README.md](../managed-objective/README.md) + +## 0. 当前实现进度 + +截至 2026-05-05,本计划已经完成到 **P3A:workspace-local file registration**,并开始推进 **P3B:workspace catalog discovery**: + +1. P1A / P2 的最小文件事实源、静态 verification gate 和状态机已经落地。 +2. P3A 已新增 `capability_draft_register`:只允许 `verified_pending_registration`,注册前复核 manifest 文件完整性与 Agent Skills 标准。 +3. 注册结果只落到当前 workspace 的 `.agents/skills//`,并写入 draft 侧 `registration/latest.json` 与 registered skill 侧 `.lime/registration.json`。 +4. 前端 Skills 工作台已经展示注册按钮与注册摘要,但仍不展示运行、自动化或外部写入口。 +5. P3B 第一刀是 workspace-local registered skill discovery:显式传入 `workspaceRoot`,扫描当前项目 `.agents/skills`,只返回带 `.lime/registration.json` 的 P3A 注册能力。 +6. P3B 后续仍要解决 SkillService root、runtime session、Query Loop metadata 与 `tool_runtime` surface 的一致性。 + +## 1. 实施总原则 + +1. **标准优先** + - 生成能力必须编译为 Agent Skill Bundle / Adapter Spec / ServiceSkill 投影。 + +2. **验证先于注册** + - 未通过 verification gate 的能力只能是 draft,不能进入默认 tool surface。 + +3. **执行回到主链** + - 所有运行必须走 Query Loop、tool_runtime、runtime queue、artifact、evidence pack。 + +4. **权限显式** + - 联网、写文件、外部写操作、花钱、发布、删除必须有结构化权限声明。 + +5. **能力逐级开放** + - 首期限制的是未验证、未授权、不可审计的执行;后续可以通过 sandbox、verification gate、permission policy、用户确认和 evidence audit 逐级放开。 + +6. **先低风险闭环** + - 首期只做只读 CLI / API / 文件输出,不做外部发布、下单、改价。 + +权限分级口径固定为: + +```text +Level 0: read-only discovery +Level 1: draft-scoped write +Level 2: fixture dry-run +Level 3: sandbox shell +Level 4: workspace-local verified execution +Level 5: human-confirmed external write +Level 6: policy-approved scheduled external write +``` + +一句话: + +**不是永远限制能力;是永远限制未经验证、未经授权、不可审计的执行。** + +## 2. P0:文档和术语落盘 + +目标:让后续实现有稳定边界。 + +任务: + +1. 新增 `docs/research/creaoai/` 研究拆解。 +2. 新增 `docs/roadmap/creaoai/` 路线图、实施计划和图纸。 +3. 在文档中固定:`Skill Forge` 是生成阶段,不是 runtime。 +4. 在文档中固定:`Generated Capability Draft` 不是长期主类型。 + +完成标准: + +1. 文档能明确回答“是否和 skills pipeline 冲突”。 +2. 文档能明确禁止 generated tools 平行 runtime。 +3. 文档能给出 P1-P4 的实现顺序。 + +## 2.5 P0.5:实现前架构补强 + +目标:进入代码前,先补齐 [./architecture-review.md](./architecture-review.md) 指出的硬边界,避免 P1A 变成不可治理的“生成脚本”。 + +任务: + +1. 固定 draft store 文件结构。 +2. 固定 draft manifest schema。 +3. 固定 verification gate 最小检查矩阵。 +4. 固定 Workspace draft 状态机。 +5. 固定 generation / verification / registration 的 evidence 事件命名。 +6. 固定 Capability Authoring Agent 的工具 profile:`author_readonly / author_draft_write / author_dryrun`。 +7. 参考 [../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md),明确 `author_full_shell / author_external_write` 在 P1A 默认不开放,后续只能通过升级授权放开。 + +完成标准: + +1. 能回答 draft 放在哪里。 +2. 能回答 unverified draft 如何隔离。 +3. 能回答 permission summary 如何和实际行为校验。 +4. 能回答 draft 如何进入后续 verification gate。 +5. 能回答 Coding Agent 的每类工具权限来自哪个 backend、如何被测试。 + +## 3. P1:Coding Agent 生成 Skill draft scaffold + +目标:支持 Coding Agent 从对话生成 workspace-local skill 草案。首期先证明“AI 能安全地产生能力”,不直接进入长期自动执行。 + +### 3.0 为什么 P1 必须先做 Coding Agent + +CreoAI 启发的核心不是已有工具多跑几轮,而是 Coding Agent 能把 CLI / API / docs / website 编译为可复用能力。 + +因此 P1 的最小实现对象应是: + +```text +用户目标 + -> Coding Agent capability_generation turn + -> Generated Capability Draft + -> Workspace draft review +``` + +不是: + +```text +用户目标 + -> Managed Objective + -> 自动续跑 +``` + +固定边界: + +**Managed Objective 可以在 P3.5 / P4 接入,但不能替代 P1 的 Coding Agent 生成层。** + +### 3.1 用户流 + +```text +用户:帮我把这个 CLI 包装成每天生成报告的技能 + -> agent 询问缺失输入 + -> agent 生成 skill draft + -> workspace 展示草案、文件、权限、验证状态 +``` + +### 3.2 Draft 最小内容 + +Draft 至少包含: + +1. `SKILL.md` + - 触发条件。 + - 任务说明。 + - 依赖和 setup。 + - 使用示例。 + - gotchas。 + +2. `metadata` 或等价 manifest + - `name` + - `description` + - `source_kind` + - `permission_summary` + - `runtime_binding_target` + - `verification_status` + +3. `scripts/` + - 最小 adapter 或 wrapper。 + - 禁止把业务状态机写死在单个脚本里。 + +4. `examples/` + - 最小输入样例。 + - 期望输出样例。 + +5. `tests/` + - fixture 或 dry-run 测试。 + +### 3.3 产品要求 + +1. Draft 必须清楚标注“未验证”。 +2. Draft 默认不进入全局 skill catalog。 +3. Draft 默认不可被自动任务调用。 +4. 用户可以查看生成文件和权限声明。 + +完成标准: + +1. 一个只读 CLI 能被生成成 skill draft。 +2. draft 能在 workspace 中被发现。 +3. 未验证 draft 不会出现在默认可调用工具面。 + +### 3.4 Capability Authoring Agent 工具面 + +P1 不做完整独立 Coding Agent。首期只做受控的 `Capability Authoring Agent`,工具面参考 [../../research/pi-mono-coding-agent/README.md](../../research/pi-mono-coding-agent/README.md) 的 read-only / coding tools 分级,但默认更保守: + +| 工具档位 | 首期用途 | 状态 | +| --- | --- | --- | +| `author_readonly` | 读取 source refs、CLI help、OpenAPI、workspace docs | 必须支持 | +| `author_draft_write` | 写 draft root 内文件与 manifest | 必须支持 | +| `author_dryrun` | 执行 fixture / dry-run self-check | 可以最小支持 | +| `author_full_shell` | 任意 bash / install / 访问本机项目 | P1 禁止,后续需 sandbox + 升级授权 | +| `author_external_write` | 发布、下单、改价、发消息 | P1 禁止,后续需人工确认或策略批准 | + +最小验收: + +1. draft 文件路径逃逸会失败。 +2. 未声明网络或写操作的 self-check 会失败。 +3. CLI 探索只允许 allowlist 命令或 dry-run。 +4. 生成失败和 self-check 失败都要进入 draft 状态,而不是静默重试。 + +## 4. P2:Verification gate + +目标:把 generated skill 从“文件草案”变成“可注册能力”。 + +### 4.1 Gate 输入 + +1. Skill bundle 路径。 +2. 目标 runtime binding。 +3. 权限声明。 +4. 输入输出 contract。 +5. 测试或 dry-run 配置。 + +### 4.2 Gate 检查 + +最小检查: + +1. 包结构存在且可解析。 +2. `name / description / setup / examples` 足够完整。 +3. 输入 contract 存在。 +4. 输出 contract 存在。 +5. 权限声明覆盖脚本行为。 +6. dry-run 或 fixture test 通过。 +7. 高风险权限需要人工确认。 + +### 4.3 Gate 输出 + +输出状态: + +1. `draft` +2. `verification_failed` +3. `verified_pending_registration` +4. `registered` + +失败结果必须包含: + +1. 失败检查项。 +2. 修复建议。 +3. 是否可以让 agent 尝试修复。 + +完成标准: + +1. 缺 contract 的 skill 无法注册。 +2. 测试失败的 skill 无法注册。 +3. 权限声明和实际行为不一致时无法注册。 +4. 通过验证的结果能被 evidence / runtime 事实链消费。 + +## 5. P3:Catalog registration + +目标:让通过验证的 skill 进入现有发现与调用主链。 + +实现拆分: + +1. **P3A:workspace-local file registration** + - 只把 `verified_pending_registration` 草案复制为 `/.agents/skills//`。 + - 记录来源、verification report、权限摘要和目标目录。 + - 不触发 Skill reload,不接运行,不接 automation。 + +2. **P3B:workspace catalog discovery / runtime binding** + - 解决 workspace 选择、进程 cwd、SkillService root 与 runtime session 的一致性。 + - 将 workspace-local skill 投影到 Skill Catalog / ServiceSkillCatalog。 + - 通过 Query Loop 和 `tool_runtime` 决定工具可见性。 + +### 5.1 注册位置 + +注册后的能力应投影到: + +1. workspace-local skill catalog。 +2. Skill Catalog / ServiceSkillCatalog 可发现对象。 +3. Query Loop 的 skill launch metadata。 +4. tool_runtime 可裁剪的 tool surface。 + +### 5.2 注册规则 + +1. 只注册 `verified_pending_registration` 状态的 draft。 +2. 注册必须记录来源、版本、校验摘要和权限摘要。 +3. 注册不应修改全局 seeded skill。 +4. workspace-local skill 只在当前 workspace 默认可见。 + +### 5.3 执行规则 + +1. 执行必须走现有 `agent_runtime_submit_turn`。 +2. 工具可见性必须由 `tool_runtime` 决定。 +3. 运行产物必须进入 artifact / timeline。 +4. evidence pack 必须能追踪 skill 来源与调用结果。 + +完成标准: + +1. 注册后的 skill 能在后续对话中被发现。 +2. 注册后的 skill 能被当前 workspace 调用。 +3. 其他 workspace 不会默认获得该 skill。 +4. evidence pack 能看到注册来源和运行事实。 + +## 6. P3.5:Managed Objective 边界 + +目标:在进入长期任务前,先固定“目标推进控制层”不是新的 runtime。 + +参考研究: + +- [../../research/codex-goal/README.md](../../research/codex-goal/README.md) +- [../managed-objective/README.md](../managed-objective/README.md) +- [./coding-agent-layer.md](./coding-agent-layer.md) +- [./architecture-review.md](./architecture-review.md) + +### 6.1 固定定义 + +`Managed Objective` 只回答: + +1. 这个 managed skill / automation job 要完成什么目标。 +2. 当前是否还需要继续下一轮 agent turn。 +3. 当前是完成、暂停、缺输入、阻塞、预算耗尽还是失败。 +4. 完成审计应消费哪些 artifact / evidence。 + +它不回答: + +1. skill 如何生成。 +2. adapter 如何编译。 +3. 工具如何注册。 +4. 后台任务如何调度。 +5. evidence 如何导出。 + +固定边界: + +**Managed Objective 是挂在 `agent session / automation job` 上的控制层,不是第四类执行实体。** + +详细状态机、audit contract、automation owner binding 和自动续跑策略不在本文件展开,统一以 [../managed-objective/architecture.md](../managed-objective/architecture.md) 与 [../managed-objective/implementation-plan.md](../managed-objective/implementation-plan.md) 为准。 + +### 6.2 与现有主链关系 + +```text +workspace-local skill + -> automation job / agent session + -> managed objective state + -> Query Loop agent turn + -> artifact / evidence pack + -> completion audit + -> continue / needs_input / blocked / complete +``` + +实现时必须遵守: + +1. durable 后台承载继续走 `automation job`。 +2. 每轮模型执行继续走 `agent_runtime_submit_turn`。 +3. 工具可见性继续由 `tool_runtime` 决定。 +4. 完成审计必须引用 evidence pack 或等价 runtime facts。 +5. 用户输入、暂停、预算限制优先于自动续跑。 + +### 6.3 P4 前置完成标准 + +进入 P4 前,文档和设计必须能回答: + +1. Managed Objective 绑定到哪个 `automation_job` 或 `agent session`。 +2. 下一轮 continuation turn 由谁触发。 +3. 哪些状态会阻止自动续跑。 +4. 完成审计读取哪些 evidence / artifact。 +5. 哪些场景必须进入 `needs_input / blocked` 而不是继续自动跑。 + +## 7. P4:Managed execution + +目标:把 verified skill 绑定到长期任务。 + +### 7.1 任务形态 + +首期支持: + +1. 手动运行。 +2. 定时运行。 +3. 失败后等待用户输入。 +4. 用户暂停和恢复。 + +暂不支持: + +1. 自动发布到外部平台。 +2. 自动付款、下单、改价。 +3. 跨 workspace 共享 generated skill。 +4. 未确认的外部写操作。 + +### 7.2 状态要求 + +长期任务至少使用以下状态: + +```text +planned +running +needs_input +blocked +verifying +completed +failed +paused +``` + +其中: + +1. `planned / running / paused / failed` 属于任务执行生命周期。 +2. `needs_input / blocked / verifying / completed` 必须能回到 Managed Objective 的完成审计语义。 +3. `completed` 不能只由模型自报,必须有 artifact / evidence / verification 支撑。 + +### 7.3 Workspace 展示 + +Workspace 应展示: + +1. 任务名称和绑定 skill。 +2. 最近运行状态。 +3. 下次运行时间。 +4. 当前阻塞原因。 +5. 最近产物。 +6. evidence 入口。 +7. 暂停、恢复、重新验证操作。 + +完成标准: + +1. 定时任务能运行一个 verified read-only skill。 +2. app 重启后任务状态可恢复或明确标记阻塞。 +3. 失败时用户能看到失败步骤和下一步。 +4. evidence pack 能导出长期运行事实。 + +## 8. 最小验收场景 + +### 场景:只读 CLI 每日报告 + +用户输入: + +```text +把这个只读 CLI 包装成一个技能:每天 9 点运行,生成 Markdown 趋势摘要,保存到当前 workspace。失败时不要重试超过 2 次,提示我检查配置。 +``` + +系统应完成: + +1. 生成 skill draft。 +2. 生成 wrapper script、示例和 fixture test。 +3. 通过 verification gate。 +4. 注册为 workspace-local skill。 +5. 手动运行一次。 +6. 创建 scheduled managed job。 +7. 为该 job 绑定 Managed Objective,记录目标、成功标准和预算。 +8. 产出 Markdown artifact。 +9. evidence pack 可看到调用、产物、验证事实和 completion audit 输入。 + +不要求: + +1. 发布到外部平台。 +2. 操作浏览器登录态。 +3. 连接付费 API。 +4. 跨 workspace 共享。 + +## 9. 验证策略 + +### 9.1 P1 文档与 scaffold + +最小验证: + +1. skill draft 文件结构快照测试。 +2. draft 状态不会进入默认 catalog 的单测。 +3. workspace 展示 draft 状态的组件测试。 + +### 9.2 P2 gate + +最小验证: + +1. contract 缺失失败。 +2. 权限缺失失败。 +3. dry-run 失败阻断注册。 +4. dry-run 通过允许进入 pending registration。 + +### 9.3 P3 registration + +最小验证: + +1. workspace-local catalog 只包含当前 workspace 注册项。 +2. Query Loop 能发现注册 skill。 +3. tool_runtime 仍能裁剪工具面。 +4. evidence pack 包含 skill source metadata。 + +### 9.4 P4 managed execution + +最小验证: + +1. 定时任务状态机单测。 +2. 失败后 `needs_input / blocked` 行为测试。 +3. artifact 写入测试。 +4. evidence pack 导出测试。 +5. GUI 最小 smoke:创建、运行、查看证据。 + +## 10. 实现守卫 + +实现时必须守住以下约束: + +1. 不新增 `GeneratedTool` 作为长期主类型。 +2. 不新增独立 generated tool registry。 +3. 不新增独立 queue / scheduler / evidence。 +4. 不允许未验证 draft 进入默认 tool surface。 +5. 不允许外部写操作在无人工确认时自动执行。 +6. 不允许 adapter 直接成为前台产品入口。 +7. 不允许来源 API / CLI schema 反向定义 Lime runtime 协议。 +8. 不允许 Managed Objective 成为 `agent turn / subagent turn / automation job` 之外的第四类执行实体。 +9. 不允许 generated capability 反向定义领域文档协议,例如 `LayeredDesignDocument`。 +10. 不允许把 AI 图层化设计的 Canvas / document / export 主链搬进 Skill Forge runtime。 + +## 11. 后续扩展顺序 + +完成只读 CLI 每日报告后,再按以下顺序扩展: + +1. 只读 HTTP API adapter。 +2. 只读网页采集 SiteAdapterSpec。 +3. 需要登录态但只读的浏览器流程。 +4. 外部写操作 draft,但默认只 dry-run。 +5. 人工确认后的外部写操作。 +6. 多 skill managed workflow。 +7. 领域型 adapter 生成,例如图片 provider adapter、PSD exporter、OCR / matting wrapper;这些只能作为 AI 图层化设计的辅助能力,不接管 `LayeredDesignDocument` 或 Canvas Editor。 + +一句话: + +**先证明“生成能力可以被治理”,再扩大“能力可以做什么”。** diff --git a/docs/roadmap/creaoai/prototype.md b/docs/roadmap/creaoai/prototype.md new file mode 100644 index 000000000..dd808cff8 --- /dev/null +++ b/docs/roadmap/creaoai/prototype.md @@ -0,0 +1,196 @@ +# CreoAI 启发下的 Skill Forge 产品原型图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 Skill Forge / generated capability / verification gate / workspace-local skill 的用户可见面画成低保真原型,避免路线图只停留在架构文字。 + +依赖文档: + +- [./README.md](./README.md) +- [./implementation-plan.md](./implementation-plan.md) +- [./diagrams.md](./diagrams.md) +- [../managed-objective/prototype.md](../managed-objective/prototype.md) + +## 1. 原型原则 + +Skill Forge 的产品面要回答四个问题: + +1. agent 正在生成什么能力。 +2. 这个能力来自哪个 CLI / API / docs / website。 +3. 验证是否通过,权限是否安全。 +4. 通过后如何进入 workspace-local skill,并被 Managed Objective 长期运行。 + +固定边界: + +1. Draft 未验证前不能进入默认 tool surface。 +2. UI 只能展示 draft / verification / registration 状态,不直接执行生成脚本。 +3. 长期运行入口必须跳到 automation job / Managed Objective,不在 Skill Forge 内自建 runner。 + +## 2. Skill Forge 对话原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Agent Chat · Skill Forge │ +├──────────────────────────────┬───────────────────────────────┤ +│ 用户:把这个只读 CLI 包装成 │ Skill Forge Panel │ +│ 每天生成报告的技能 │ │ +│ │ Source │ +│ Agent:我会先读取 CLI help, │ - kind: cli │ +│ 生成 wrapper、contract、测试 │ - command: trendctl report │ +│ 和权限声明。 │ - risk: read-only │ +│ │ │ +│ [继续生成草案] │ Draft status │ +│ │ - SKILL.md: pending │ +│ │ - wrapper: pending │ +│ │ - contract: pending │ +│ │ - tests: pending │ +└──────────────────────────────┴───────────────────────────────┘ +``` + +## 3. Capability Draft Review 原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Generated Capability Draft · trend-report │ +├──────────────────────────────────────────────────────────────┤ +│ 目标 │ +│ 每天生成 Markdown 趋势摘要 │ +│ │ +│ 生成文件 │ +│ [✓] SKILL.md │ +│ [✓] scripts/trend_report_wrapper.ts │ +│ [✓] examples/input.sample.json │ +│ [✓] tests/fixture.test.ts │ +│ [✓] contract/input.schema.json │ +│ [✓] contract/output.schema.json │ +│ │ +│ 权限摘要 │ +│ - read local config │ +│ - execute local CLI │ +│ - write workspace artifact │ +│ - no network write │ +│ │ +│ 状态:draft · 未验证,不可自动运行 │ +│ [查看 diff] [运行 verification gate] [丢弃草案] │ +└──────────────────────────────────────────────────────────────┘ +``` + +## 4. Verification Gate 原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Verification Gate · trend-report │ +├──────────────────────────────────────────────────────────────┤ +│ Package structure ✓ 通过 │ +│ Input contract ✓ 通过 │ +│ Output contract ✓ 通过 │ +│ Permission declaration ✓ 通过 │ +│ Dry-run fixture ✕ 失败 │ +│ │ +│ 失败原因 │ +│ wrapper 没有处理 CLI exit code 2 │ +│ │ +│ 建议 │ +│ 让 agent 修复 wrapper,并补一个失败 fixture │ +│ │ +│ [让 agent 修复] [查看日志] [保留 draft] │ +└──────────────────────────────────────────────────────────────┘ +``` + +验证通过后的状态: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Verification Gate · trend-report │ +├──────────────────────────────────────────────────────────────┤ +│ 全部检查通过 │ +│ 状态:verified_pending_registration │ +│ │ +│ [注册到当前 Workspace] [查看验证证据] │ +└──────────────────────────────────────────────────────────────┘ +``` + +## 5. Workspace-local Skill Card 原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Skill · trend-report │ +├──────────────────────────────────────────────────────────────┤ +│ 来源:agent generated · current workspace │ +│ 版本:v0.1.0 │ +│ 验证:verified │ +│ 权限:local read / cli execute / workspace write │ +│ Runtime binding:native_skill -> Query Loop tool_runtime │ +│ │ +│ 最近运行:2026-05-05 09:02 · success │ +│ 产物:reports/2026-05-05.md │ +│ │ +│ [手动运行] [创建定时任务] [查看 evidence] [重新验证] │ +└──────────────────────────────────────────────────────────────┘ +``` + +## 6. 创建 Managed Job 原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Create Managed Job from Skill │ +├──────────────────────────────────────────────────────────────┤ +│ Skill │ +│ trend-report · verified │ +│ │ +│ Schedule │ +│ 每天 09:00 │ +│ │ +│ Managed Objective │ +│ [每天生成 Markdown 趋势摘要,连续 7 次成功后完成 ] │ +│ │ +│ Stop conditions │ +│ [✓] 失败超过 2 次进入 blocked │ +│ [✓] 缺配置进入 needs_input │ +│ [✓] 高风险动作需要确认 │ +│ │ +│ [创建 job 和 objective] │ +└──────────────────────────────────────────────────────────────┘ +``` + +固定判断: + +1. Skill Forge 只负责把能力推进到 verified skill。 +2. 长期任务由 automation job 承载。 +3. 是否继续由 Managed Objective 判断。 +4. evidence pack 负责运行事实。 + +## 7. 端到端用户流原型 + +```text +┌────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ +│ 对话生成 │ -> │ Draft Review │ -> │ Verification│ -> │ Skill Card │ +└────────────┘ └─────────────┘ └─────────────┘ └──────┬───────┘ + │ + v +┌────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ +│ Evidence │ <- │ Artifact │ <- │ Query Loop │ <- │ Managed Job │ +└────────────┘ └─────────────┘ └─────────────┘ └──────────────┘ +``` + +这条用户流对应路线图主链: + +```text +Skill Forge -> Draft -> Verification Gate -> Workspace-local Skill -> Automation Job -> Managed Objective -> Query Loop -> Artifact / Evidence +``` + +## 8. 移动端压缩原型 + +```text +┌────────────────────────────┐ +│ trend-report skill │ +├────────────────────────────┤ +│ verified · read-only │ +│ last: success 09:02 │ +│ artifact: 2026-05-05.md │ +│ │ +│ [运行] [定时] [证据] │ +└────────────────────────────┘ +``` + +移动端不展示完整文件树,只保留验证状态、权限摘要、最近运行和核心操作。 diff --git a/docs/roadmap/knowledge/prd.md b/docs/roadmap/knowledge/prd.md index 3e024bfc2..68dbf1426 100644 --- a/docs/roadmap/knowledge/prd.md +++ b/docs/roadmap/knowledge/prd.md @@ -1,15 +1,15 @@ # Lime Agent Knowledge PRD -> 状态:current PRD / architecture plan -> 更新时间:2026-05-01 -> 目标:把团队的 Agent Knowledge 标准接入 Lime,形成本地优先、可维护、可审计、可安全调用的知识包主链。 +> 状态:current PRD / productized knowledge module architecture +> 更新时间:2026-05-05 +> 目标:把团队的 Agent Knowledge 标准接入 Lime,并让普通用户通过现有 Agent 输入框资料图标、File Manager 和首页引导完成项目资料的生成、沉淀、确认与使用闭环。 ## 1. 背景与结论 Lime 现有 `docs/knowledge` 已经沉淀了 Markdown-first 项目知识库、个人 IP 知识库 Builder Skill 原型、Skill 与知识包边界等早期方案。它们证明了一个方向: ```text -业务资料 -> 知识包编译 -> 用户确认 -> 运行时按需引用 -> 输出反馈沉淀 +本地文件 / 对话 / 生成结果 -> 项目资料草稿 -> 整理确认 -> 现有 Agent 使用 -> 新输出 -> 继续沉淀 ``` 但这些方案仍有几个问题: @@ -21,7 +21,15 @@ Lime 现有 `docs/knowledge` 已经沉淀了 Markdown-first 项目知识库、 本 PRD 的结论: -**Lime 接入 Agent Knowledge 标准,把 `KnowledgePack` 作为显式知识资产事实源;首版坚持 Markdown-first,但目录、状态、运行时包裹、编译记录和来源轨迹按 Agent Knowledge 标准落地。** +**Lime 接入 Agent Knowledge 标准,把 `KnowledgePack` 作为显式知识资产事实源;产品上表现为“项目资料”模块,主使用入口回到现有 Agent,资料管理页只负责检查、确认和维护。** + +当前产品判断: + +1. File Manager 是用户把本地资料沉淀为项目资料的自然入口。 +2. 输入框底栏资料图标是用户选择和使用资料的主入口;`@资料` 只作为旧路由和快捷发现的兼容入口。 +3. 首页引导负责把新用户带入“先添加资料,再让 Lime 生成”的路径。 +4. Agent 生成结果必须能继续沉淀为项目资料,形成生成到使用的闭环。 +5. 普通用户界面不展示 packName、metadata、compiled、token、runtime fence、本机完整路径等开发者细节。 固定边界: @@ -36,20 +44,20 @@ Inspiration = 用户认可过、可复用的输出样例 ### 2.1 P0 目标 -1. 用户能把 DOCX、Markdown、TXT、粘贴文本导入为知识包来源。 -2. Lime 通过 Builder Skill 把来源资料编译成 Agent Knowledge pack。 -3. 用户能查看、编辑、确认、归档知识包。 -4. 聊天、场景任务、Skill 调用可以显式选择“使用知识包”。 -5. 运行时只把知识包作为受保护数据上下文注入模型。 -6. 输出能提示基于哪个知识包、哪些内容待确认、哪些事实存在冲突。 -7. 不把真实知识包全文塞进 Skill,也不写入 durable memory。 +1. 用户能从 File Manager 右键或拖入 Markdown / TXT 文件,并沉淀为当前项目资料。 +2. 用户能在输入框底栏通过资料图标选择、启用、创建或沉淀项目资料。 +3. 首页引导能把新用户带入“添加资料 -> 生成内容”的普通用户路径。 +4. 用户能从 Agent 生成结果中选择内容,创建新资料或补充到当前资料。 +5. 资料管理页能查看、确认、设为默认、归档和补充资料,但不是主聊天入口。 +6. 运行时只把已选项目资料作为受保护数据上下文注入现有 Agent。 +7. 不把真实资料全文塞进 Skill,也不写入 durable memory。 ### 2.2 P1 目标 -1. 支持个人 IP、品牌产品、组织 Know-how、增长策略四类 Builder 模板。 -2. 支持 `wiki/` 页面、`compiled/` 运行时视图和来源锚点。 -3. 支持知识包质量检查、风险扫描、缺口清单和重新编译记录。 -4. 支持长知识包的章节选择、摘要模式和 token 成本提示。 +1. 支持个人 IP、品牌产品、组织 Know-how、增长策略四类资料整理模板。 +2. 支持 `wiki/` 页面、`compiled/` 运行时视图和来源锚点,但默认不暴露给普通用户。 +3. 支持资料质量检查、风险扫描、缺口清单和重新整理记录。 +4. 支持长资料的章节选择、摘要模式和成本提示,以用户语言解释影响。 ### 2.3 P2 目标 @@ -114,128 +122,145 @@ Inspiration = 用户认可过、可复用的输出样例 ## 5. 前台信息架构 -用户前台使用创作者语言: +普通用户心智统一为“项目资料”,不在主路径暴露 KnowledgePack、compiled、metadata、token、runtime fence 等工程概念。 ```text -知识库 - - 总览 - - 知识包 - - 导入 +Agent 输入框 + - 底栏资料图标 + - 打开资料中枢 + - 按状态引导添加 / 确认 / 选择 / 使用 / 补充 + - 管理资料 + - 添加项目资料 + - 选择文件导入 + - 粘贴资料整理 + - 从当前对话沉淀 + - @资料 + - 兼容打开同一个资料中枢 + - 不作为普通命令标签或主路径宣传 + - @沉淀资料 + - 创建新资料 + - 补充到当前资料 +``` + +```text +File Manager + - 打开 / 添加到对话 + - 设为项目资料 + - 拖入输入框后选择“作为项目资料使用” +``` + +```text +首页引导 + - 添加资料:打开输入框资料中枢 + - 基于项目资料生成内容 + - 继续最近资料流 +``` + +```text +项目资料管理 + - 全部资料 - 待确认 - - 已归档 + - 已确认可用 + - 补充导入 + - 排障设置 ``` -知识包详情页: - -```text -知识包详情 - - 概览 - - 内容 - - 来源 - - 运行时视图 - - 缺口与风险 - - 编译记录 -``` - -聊天或任务入口: - -```text -使用知识包 - - 不使用 - - 当前项目默认知识包 - - 手动选择知识包 - - 仅使用选中章节 -``` - -开发者或高级诊断入口: - -```text -知识诊断 - - Catalog metadata - - KNOWLEDGE.md guide - - selected compiled context - - source anchors - - risk warnings - - token budget - - resolve trace -``` +高级诊断只作为折叠入口存在,用于排查命令、来源、运行时解析和上下文预算,不进入普通用户默认视图。 ## 6. UI 原型 -### 6.1 知识库总览 +### 6.1 首页引导 ```text ┌──────────────────────────────────────────────────────────────┐ -│ 知识库 [导入资料] │ +│ 青柠一下,灵感即来 │ +│ 说一句目标,Lime 就接着帮你做。 │ ├──────────────────────────────────────────────────────────────┤ -│ 当前项目默认知识包 │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 创始人个人 IP 知识库 ready / 已确认 │ │ -│ │ 用于个人介绍、短视频脚本、沙龙开场、商务话术。 │ │ -│ │ 来源 3 个 · 运行时视图 5 个 · 最近更新 2026-05-01 │ │ -│ │ [打开] [设为默认] [用于生成] │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ 待确认 │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 金花黑茶品牌产品知识包 needs-review │ │ -│ │ 发现 4 个待补充事实,2 条功效表达风险。 │ │ -│ │ [继续确认] [查看风险] │ │ -│ └──────────────────────────────────────────────────────────┘ │ +│ [添加资料] [写作] [调研报告] [更多做法] │ └──────────────────────────────────────────────────────────────┘ ``` -### 6.2 导入与编译向导 +点击 `添加资料` 后,打开现有 Agent 输入框的项目资料浮层,而不是预填一段说明、跳到独立聊天页或自动创建新 Agent。 + +### 6.2 File Manager 沉淀资料 + +```text +┌─────────────────────────────┐ +│ 文件 │ +│ 个人资料.md │ +│ 品牌介绍.txt │ +│ │ +│ 右键菜单 │ +│ 打开 │ +│ 添加到对话 │ +│ 设为项目资料 │ +│ 在系统文件管理器中显示 │ +└─────────────────────────────┘ +``` + +规则:`添加到对话` 是临时引用,`设为项目资料` 是长期沉淀;两者不能混成一个动作。 + +### 6.3 输入框底栏资料图标 ```text ┌──────────────────────────────────────────────────────────────┐ -│ 新建知识包 │ +│ 帮我写一版视频号简介 [资料] [发送] │ ├──────────────────────────────────────────────────────────────┤ -│ 1 选择类型 个人 IP · 品牌产品 · 组织 Know-how · 增长策略 │ -│ 2 添加来源 拖入 DOCX / MD / TXT,或粘贴文本 │ -│ 3 选择 Builder knowledge_builder │ -│ 4 编译预览 wiki 草稿 / 运行时视图 / 待补充清单 │ -│ 5 人工确认 确认后才可默认用于生成 │ +│ 可使用:个人 IP 资料 │ +│ 选择后,本次生成会按项目资料里的事实、语气和边界执行。 │ │ │ -│ [上一步] [开始编译] │ +│ ✓ 个人 IP 资料 已确认 · 默认 │ +│ 品牌产品资料 待确认 │ +│ │ +│ [确认资料] [使用这份资料] │ └──────────────────────────────────────────────────────────────┘ ``` -### 6.3 知识包详情 +底栏资料图标是项目资料的主入口。它不是一次性命令,而是输入框里的持续上下文开关,按当前状态给出下一步: + +- 无资料:主动作是 `添加项目资料`。 +- 有待确认资料:主动作是 `确认资料`。 +- 有已确认资料但未启用:主动作是 `使用这份资料`。 +- 已启用资料:主动作是 `补充资料`,同时可 `关闭资料`。 + +选择或启用资料后,输入框只显示普通用户状态:`项目资料:未使用` 或 `正在使用:资料名称`。 + +`@资料` 只保留为兼容入口:用户从旧路由或 @ 面板触发时,打开同一个资料中枢;它不渲染普通 `资料 ×` 命令标签,也不作为首屏引导文案。 + +### 6.4 Agent 结果沉淀 ```text ┌──────────────────────────────────────────────────────────────┐ -│ 创始人个人 IP 知识库 ready · official │ -├──────────────────────────────────────────────────────────────┤ -│ [概览] [内容] [来源] [运行时视图] [缺口与风险] [编译记录] │ -├──────────────────────────────────────────────────────────────┤ -│ 适用场景 │ -│ - 个人介绍、视频号脚本、商务开场、社群话术。 │ +│ Agent 输出: │ +│ 这里是一版已经生成并被用户认可的脚本草稿…… │ │ │ -│ 当前运行时视图 │ -│ - facts.md 关键事实和不可编造项 │ -│ - voice.md 语气、表达风格、禁忌措辞 │ -│ - stories.md 可引用故事和案例 │ -│ - boundaries.md 合规、隐私、品牌边界 │ -│ │ -│ [编辑 KNOWLEDGE.md] [重新编译] [设为默认] [归档] │ +│ [复制] [继续改] [沉淀为项目资料] │ └──────────────────────────────────────────────────────────────┘ ``` -### 6.4 聊天中使用知识包 +点击 `沉淀为项目资料` 后进入确认面板:`创建新资料` 或 `补充到当前资料`。默认不自动写入默认资料,避免污染长期事实源。 + +### 6.5 项目资料管理 ```text ┌──────────────────────────────────────────────────────────────┐ -│ 写一段东莞企业家沙龙开场白 │ +│ 项目资料管理 [补充导入] │ ├──────────────────────────────────────────────────────────────┤ -│ 知识包:创始人个人 IP 知识库 [更换] [查看引用] │ -│ 使用方式:推荐上下文 · 约 8k tokens │ -│ 提示:1 条事实缺口将标记为待确认 │ -├──────────────────────────────────────────────────────────────┤ -│ [发送] │ +│ 当前项目资料库 │ +│ 2 份项目资料 · 1 份默认 · 1 份待确认 │ +│ │ +│ 个人 IP 资料 已确认 · 默认 │ +│ 用于个人介绍、短视频脚本、商务开场、社群话术。 │ +│ [用于生成] [设为默认] [查看详情] │ +│ │ +│ 品牌产品资料 待确认 │ +│ 发现 4 个待补充事实,2 条表达风险。 │ +│ [继续确认] [补充资料] │ └──────────────────────────────────────────────────────────────┘ ``` +资料管理页只承担检查、确认、默认设置和归档;使用资料回到现有 Agent。 + ## 7. 标准目录与概念模型 ### 7.1 文件结构 @@ -377,11 +402,18 @@ src-tauri/src/commands/knowledge_cmd.rs # 只做 Tauri command 薄适配,不承载领域逻辑 ``` -前端知识域也必须独立于 Memory 和聊天实现: +前端知识域也必须独立于 Memory,同时通过现有 Agent 主链组合: ```text -src/lib/api/knowledge.ts # safeInvoke 网关和命令类型 -src/features/knowledge/ # 后续知识库页面、hooks、view model 和 UI 入口 +src/lib/api/knowledge.ts # safeInvoke 网关和命令类型 +src/features/knowledge/domain/ # 资料类型、状态、用户可见文案、名称归一化 +src/features/knowledge/import/ # 文件读取、清洗、导入编排、错误提示 +src/features/knowledge/use/ # 资料选择、启用、请求 metadata +src/features/knowledge/settle/ # 从文件、对话、生成结果沉淀资料 +src/features/knowledge/components/ # 资料卡、导入面板、确认面板、状态导轨 +src/components/agent/chat/components/Inputbar/knowledge/ + # 输入框项目资料控件 +src/components/agent/chat/workspace/knowledge/ # Workspace 与现有 Agent 发送链路适配 ``` 固定规则: @@ -389,56 +421,74 @@ src/features/knowledge/ # 后续知识库页面、hooks、view model 和 1. `lime-knowledge` 是后端领域事实源。 2. `knowledge_cmd.rs` 只做参数透传、错误返回和 Tauri 注册。 3. 前端页面不得直接裸 `invoke`,只能经 `src/lib/api/knowledge.ts`。 -4. 知识 UI 不挂到 `src/components/memory`,避免把 Knowledge 和 Memory 重新混成一层。 +4. File Manager、首页、输入框资料图标、`@` 兼容入口和消息工具栏只发起资料动作,不承载知识领域逻辑。 +5. 项目资料使用必须回到现有 Agent,不新增独立 Agent。 +6. 知识 UI 不挂到 `src/components/memory`,避免把 Knowledge 和 Memory 重新混成一层。 ## 8. 总体架构 ```mermaid flowchart TB - User["用户 / 团队"] --> UI["知识库 UI"] - UI --> Import["导入向导"] - UI --> Review["人工确认 / 编辑"] - UI --> RuntimePicker["生成时选择知识包"] + User["普通用户"] --> Home["首页引导
添加资料"] + User --> FileManager["File Manager
右键设为项目资料 / 拖入输入框"] + User --> InputbarKnowledge["Agent 输入框底栏资料图标
资料中枢"] + User --> MentionCompat["兼容 @资料
打开同一资料中枢"] + User --> AgentOutput["Agent 生成结果
沉淀为项目资料"] - Import --> Sources["sources/
原始来源和证据"] - BuilderSkill["Builder Skill
模板 / 访谈问题 / 检查表 / 转换脚本"] --> Compiler["Knowledge Compiler"] - Sources --> Compiler - Compiler --> Wiki["wiki/
维护后的主知识"] - Wiki --> Compiled["compiled/
运行时派生视图"] - Wiki --> Indexes["indexes/
可重建候选索引"] - Compiler --> Runs["runs/
导入 / 编译 / lint / 评审记录"] + Home --> EntryOrchestrator["项目资料入口编排
用户动作 / 当前项目 / 来源类型"] + FileManager --> EntryOrchestrator + InputbarKnowledge --> EntryOrchestrator + MentionCompat --> InputbarKnowledge + AgentOutput --> Settle["沉淀资料
创建新资料 / 补充当前资料"] + Settle --> EntryOrchestrator - Wiki --> Review - Compiled --> Resolver["Knowledge Context Resolver"] - Indexes --> Resolver - RuntimePicker --> Resolver + EntryOrchestrator --> Import["导入与清洗
文件正文 / 粘贴文本 / 对话片段"] + Import --> Api["src/lib/api/knowledge.ts"] + Api --> Commands["knowledge_* Tauri commands"] + Commands --> Domain["lime-knowledge crate"] + Domain --> Sources["sources/
原始来源"] + Domain --> Wiki["wiki/
维护后的主知识"] + Domain --> Compiled["compiled/
运行时派生视图"] + Domain --> Runs["runs/
整理 / 检查 / 评审记录"] + + Domain --> Manage["项目资料管理
检查 / 确认 / 设默认 / 归档"] + Manage --> InputbarKnowledge + Manage --> UseInAgent["现有 Agent 输入框
项目资料:未使用 / 正在使用"] + InputbarKnowledge --> UseInAgent + + UseInAgent --> RuntimeMetadata["knowledge_pack request metadata"] + RuntimeMetadata --> Resolver["Knowledge Context Resolver"] + Compiled --> Resolver + Wiki --> Resolver Resolver --> Fenced["受保护知识上下文
知识是数据,不是指令"] Fenced --> Runtime["agent_runtime_submit_turn"] - SceneSkill["Scene Skill
生成步骤和输出格式"] --> Runtime - Memory["Memory
用户偏好 / 长期习惯"] --> Runtime - Inspiration["Inspiration
认可输出 / 可复用样例"] --> Runtime + SceneSkill["Scene Skill
步骤和输出格式"] --> Runtime + Memory["Memory
用户偏好"] --> Runtime + Inspiration["Inspiration
认可输出样例"] --> Runtime Runtime --> Output["内容 / 方案 / 话术 / SOP"] Output --> User + Output --> AgentOutput - classDef source fill:#F8FAFC,stroke:#64748B,color:#0F172A; - classDef product fill:#EFF6FF,stroke:#3B82F6,color:#1E3A8A; + classDef entry fill:#EFF6FF,stroke:#3B82F6,color:#1E3A8A; + classDef product fill:#ECFDF5,stroke:#10B981,color:#064E3B; + classDef data fill:#F8FAFC,stroke:#64748B,color:#0F172A; classDef runtime fill:#FFF7ED,stroke:#F97316,color:#7C2D12; - classDef data fill:#E8FFF6,stroke:#10B981,color:#064E3B; - class UI,Import,Review,RuntimePicker product; - class Sources,Wiki,Compiled,Indexes,Runs data; - class Resolver,Fenced,Runtime runtime; - class BuilderSkill,SceneSkill,Memory,Inspiration,Output source; + class Home,FileManager,InputbarKnowledge,MentionCompat,AgentOutput,UseInAgent entry; + class EntryOrchestrator,Import,Manage,Settle product; + class Sources,Wiki,Compiled,Runs,Domain data; + class Resolver,Fenced,Runtime,RuntimeMetadata runtime; ``` 架构固定判断: -1. `KnowledgePack` 是知识资产事实源。 -2. `Skill` 是方法层,可以生成、维护、校验、查询、应用知识包。 -3. `Memory` 和 `Inspiration` 只能补充偏好与样例,不抢事实源。 +1. `KnowledgePack` 是工程事实源,普通用户看到的是“项目资料”。 +2. File Manager、输入框资料图标、`@资料` 兼容入口、首页引导和 Agent 输出沉淀只是入口,不各自实现资料逻辑。 +3. 资料管理页是维护面板,不是独立聊天页;所有生成使用回到现有 Agent。 4. `Resolver` 是运行时唯一知识上下文组装边界。 5. 模型永远只接收 fenced knowledge context,不直接服从知识正文里的指令。 +6. Memory 和 Inspiration 只能补充偏好与样例,不抢资料事实源。 ## 9. 分层边界 @@ -463,75 +513,104 @@ flowchart LR ## 10. 关键时序 -### 10.1 导入资料并生成知识包 +### 10.1 从 File Manager 或首页添加资料 ```mermaid sequenceDiagram autonumber participant U as 用户 - participant UI as 知识库 UI - participant Import as Import Service - participant Skill as Builder Skill - participant Compiler as Knowledge Compiler + participant Entry as File Manager / 首页引导 + participant Agent as 现有 Agent 输入框 + participant Import as 项目资料导入编排 + participant API as knowledge_import_source + participant Compiler as knowledge_compile_pack participant Pack as KnowledgePack - participant Review as 人工确认 + participant Manage as 项目资料管理 - U->>UI: 新建知识包并添加来源 - UI->>Import: knowledge_import_source(files) - Import->>Pack: 写入 sources/ 与导入记录 - UI->>Skill: 选择 Builder 模板 - Skill->>Compiler: 提供章节模板、访谈问题、质量检查表 - Compiler->>Pack: 生成 KNOWLEDGE.md、wiki/、compiled/ - Compiler->>Pack: 写入 runs/compile-*.json - Pack-->>UI: 返回草稿、缺口、风险和 token 估算 - U->>Review: 编辑并确认 - Review->>Pack: status = ready, trust = user-confirmed + U->>Entry: 选择文件或点击“添加资料” + Entry->>Agent: 打开“添加项目资料”入口 + Agent->>Import: 传入文件正文 / 粘贴文本 / 当前项目 + Import->>Import: 清洗转换注释、生成用户可见资料名 + Import->>API: 导入来源资料 + API->>Pack: 写入 sources/ 与草稿元数据 + Import->>Compiler: 整理为可检查资料 + Compiler->>Pack: 更新 wiki/、compiled/、runs/ + Pack-->>Manage: 返回摘要、缺口、风险和状态 + U->>Manage: 检查并确认 + Manage->>Pack: ready / defaultForWorkspace ``` -### 10.2 运行时解析知识上下文 +### 10.2 通过输入框资料图标使用资料 ```mermaid sequenceDiagram autonumber participant U as 用户 - participant Chat as 聊天 / 场景入口 - participant Catalog as Knowledge Catalog + participant Control as 底栏资料图标 + participant Agent as 现有 Agent 输入框 + participant Catalog as knowledge_list_packs + participant Send as Agent 发送链路 participant Resolver as Knowledge Context Resolver - participant Pack as KnowledgePack participant Runtime as Agent Runtime participant Model as 模型 - U->>Chat: 提交任务并选择知识包 - Chat->>Catalog: knowledge_list_packs(scope) - Catalog-->>Chat: 返回可用知识包和状态 - Chat->>Resolver: knowledge_resolve_context(task, pack, budget) - Resolver->>Pack: 读取 KNOWLEDGE.md、compiled/、必要 wiki 页面 - Pack-->>Resolver: 返回候选上下文和 source anchors - Resolver-->>Chat: 返回 fenced context、warnings、tokenEstimate - Chat->>Runtime: agent_runtime_submit_turn(request + fenced context) - Runtime->>Model: system/developer/user + skill + knowledge data + memory + inspiration + U->>Agent: 输入生成请求并点击资料图标 + Control->>Catalog: 读取当前项目资料状态 + Catalog-->>Control: 返回资料名称、状态、默认标记 + Control-->>U: 展示资料中枢:添加 / 确认 / 选择 / 使用 / 补充 + U->>Control: 选择已确认资料并点击使用 + Control->>Agent: 显示“正在使用:资料名称” + U->>Agent: 点击发送 + Agent->>Send: 携带 knowledge_pack metadata + Send->>Resolver: 按任务解析资料上下文 + Resolver-->>Runtime: 返回 fenced context 与 warnings + Runtime->>Model: 用户请求 + Skill + Knowledge + Memory + Inspiration Model-->>Runtime: 输出草稿 - Runtime-->>Chat: 返回结果、引用提示和待确认项 + Runtime-->>Agent: 展示结果与可沉淀动作 ``` -### 10.3 用户修改后重新编译 +兼容说明:如果用户通过旧路径触发 `@资料`,前端只打开同一个 `Control`,不产生普通命令标签,也不改变发送链路。 + +### 10.3 从生成结果沉淀资料 ```mermaid sequenceDiagram autonumber participant U as 用户 - participant UI as 知识包详情 + participant Output as Agent 输出 + participant Settle as 沉淀资料面板 + participant Import as 项目资料导入编排 participant Pack as KnowledgePack - participant Compiler as Knowledge Compiler + participant Manage as 项目资料管理 + + U->>Output: 认可某段生成结果 + U->>Output: 点击“沉淀为项目资料” + Output->>Settle: 带入选中内容、当前项目、当前资料候选 + U->>Settle: 选择创建新资料或补充当前资料 + Settle->>Import: 提交选中内容 + Import->>Pack: 写入来源并整理草稿 + Pack-->>Manage: 返回待确认资料 + U->>Manage: 检查、确认或继续补充 +``` + +### 10.4 用户修改后重新整理 + +```mermaid +sequenceDiagram + autonumber + participant U as 用户 + participant Manage as 项目资料管理 + participant Pack as KnowledgePack + participant Compiler as knowledge_compile_pack participant Resolver as Knowledge Context Resolver - U->>UI: 编辑 wiki 页面或 KNOWLEDGE.md - UI->>Pack: 保存修改 - UI->>Compiler: knowledge_compile_pack(packName) + U->>Manage: 补充资料、归档或修改状态 + Manage->>Pack: 保存来源和状态变更 + Manage->>Compiler: 重新整理资料 Compiler->>Pack: 更新 compiled/ 与 runs/ - Compiler-->>UI: 返回变更摘要、风险、待确认项 - UI->>Pack: 用户确认 ready / needs-review - Resolver->>Pack: 下一轮读取新版 compiled view + Compiler-->>Manage: 返回变更摘要、风险、待确认项 + U->>Manage: 确认可用或保持待确认 + Resolver->>Pack: 下一轮读取新版资料视图 ``` ## 11. 状态流转 @@ -755,15 +834,17 @@ Builder Skill 不应包含: 后续继续演进的主路径: -1. Agent Knowledge 标准目录结构。 -2. `KnowledgePack`。 -3. `KNOWLEDGE.md`。 -4. `sources/ -> wiki/ -> compiled/ -> runs/`。 -5. `Knowledge Context Resolver`。 -6. fenced knowledge context。 -7. `knowledge_*` 最小命令面。 -8. `knowledge_builder` 内置 Builder Skill。 -9. `docs/roadmap/knowledge/prd.md`。 +1. 现有 Agent 输入框中的项目资料选择与启用。 +2. File Manager 的 `设为项目资料` 和拖入沉淀入口。 +3. 输入框底栏资料图标,以及 `@资料` 兼容入口与 `@沉淀资料` 输入能力入口。 +4. 首页资料引导入口。 +5. Agent 输出上的 `沉淀为项目资料` 动作。 +6. Agent Knowledge 标准目录结构与 `KnowledgePack`。 +7. `KNOWLEDGE.md` 与 `sources/ -> wiki/ -> compiled/ -> runs/`。 +8. `Knowledge Context Resolver` 与 fenced knowledge context。 +9. `knowledge_*` 最小命令面。 +10. `knowledge_builder` 内置 Builder Skill。 +11. `docs/roadmap/knowledge/prd.md` 与 `docs/exec-plans/agent-knowledge-implementation-plan.md`。 ### 17.2 `compat` @@ -790,7 +871,7 @@ Builder Skill 不应包含: 1. 新知识包继续使用 `knowledge.md + pack.json + source/` 自定义格式。 2. 把真实用户知识包全文作为 Skill 本体发布。 3. 把知识包全文写入 durable memory。 -4. 让 UI 本地读取文件并拼装 runtime knowledge prompt。 +4. 让 UI 绕过 `knowledge_*` 和 Resolver,自行拼装 runtime knowledge prompt。 5. 让索引、embedding 或摘要缓存成为事实源。 ### 17.4 `dead` @@ -799,56 +880,59 @@ Builder Skill 不应包含: ## 18. 分阶段路线 -### Phase 1:标准化 Markdown-first MVP +### Phase 1:Markdown-first 项目资料闭环 交付: 1. 新建 `.lime/knowledge/packs//` 标准目录。 2. 支持 `KNOWLEDGE.md` metadata 解析与 catalog。 -3. 支持导入 DOCX、MD、TXT、粘贴文本到 `sources/`。 -4. 支持个人 IP Builder 编译 `wiki/` 和 `compiled/`。 -5. 支持用户编辑、确认、设为默认、归档。 -6. 支持聊天和场景任务选择知识包。 +3. 支持 MD / TXT / 粘贴文本到 `sources/`;DOCX / PDF 后续通过转换能力扩展。 +4. 支持个人 IP、品牌产品、组织 Know-how、增长策略资料整理模板。 +5. 支持用户确认、设为默认、归档、补充导入。 +6. 支持现有 Agent 输入框显式选择和启用项目资料。 7. Runtime 通过 `knowledge_resolve_context` 注入 fenced context。 +8. File Manager、输入框资料图标、`@资料` 兼容入口、首页引导和 Agent 输出沉淀纳入同一资料模块路径。 验收场景: -1. 导入创始人个人 IP 资料,生成标准 Agent Knowledge pack。 -2. 用户确认后,写沙龙开场白能体现知识包事实、故事、语气和边界。 -3. 未确认草稿不默认用于生成。 -4. 知识正文里的“忽略系统规则”等文本不会改变模型规则。 +1. 用户从 File Manager 选择一份 Markdown 资料,沉淀为当前项目资料。 +2. 用户点击输入框资料图标选择已确认资料,输入框显示正在使用,并基于资料生成内容。 +3. 首页新用户可以通过资料引导完成添加资料并回到现有 Agent。 +4. 用户把满意的 Agent 输出继续沉淀为新资料或补充到当前资料。 +5. 未确认草稿不默认用于生成。 +6. 知识正文里的“忽略系统规则”等文本不会改变模型规则。 -### Phase 2:模板扩展与质量检查 +### Phase 2:资料来源扩展与质量检查 交付: -1. 品牌产品 Builder。 -2. 组织 Know-how Builder。 -3. 增长策略 Builder。 -4. 缺口清单、风险扫描、质量 checklist。 -5. `runs/` 编译记录可读。 +1. DOCX / PDF 等常见资料格式转换。 +2. 缺口清单、风险扫描、质量 checklist。 +3. `runs/` 整理记录可读化。 +4. 大文件导入的普通用户提示和分段整理。 验收场景: -1. 金花黑茶品牌产品资料可生成品牌知识包。 +1. 品牌产品资料可生成品牌项目资料。 2. 涉及功效、医疗、绝对化表达时进入风险提示。 3. 组织 SOP 能生成升级路径和不可回答边界。 +4. 超大或不可读取文件不会让用户看到开发者错误。 ### Phase 3:章节选择、摘要与来源锚点 交付: 1. `compiled/brief.md` 和任务相关视图。 -2. 章节级 token 估算。 +2. 章节级成本估算和用户可理解的成本提示。 3. 手动选择章节。 4. source anchors 与输出引用提示。 -5. 大知识包默认摘要或章节模式。 +5. 大资料默认摘要或章节模式。 验收场景: -1. 大知识包不直接全量塞入 prompt。 -2. 输出可展示“基于哪些章节”。 -3. 来源冲突时显示 `disputed` 或待确认项。 +1. 大资料不直接全量塞入 prompt。 +2. 输出可展示“基于哪些资料片段”。 +3. 来源冲突时显示争议或待确认项。 ### Phase 4:规模化治理 @@ -856,25 +940,25 @@ Builder Skill 不应包含: 1. 轻量检索。 2. 冲突检测。 -3. 跨知识包候选选择。 +3. 跨资料候选选择。 4. 维护人、评审状态、变更审计。 -5. 团队共享或知识包市场探索。 +5. 团队共享或资料市场探索。 验收场景: -1. 多知识包候选不会无差别全量注入。 +1. 多份资料候选不会无差别全量注入。 2. 索引可重建,不作为事实源。 -3. 归档、过期、争议知识包不会默认污染生成。 +3. 归档、过期、争议资料不会默认污染生成。 ## 19. 验收标准 ### 19.1 产品验收 -1. 普通用户能理解“导入资料 -> 生成知识包 -> 确认 -> 用于生成”的闭环。 -2. 普通用户不需要理解 RAG、embedding、promptlet、runtime resolver 等术语。 -3. 知识包状态、信任、风险、待补充信息清晰可见。 -4. 聊天中可以明确看到是否使用了知识包。 -5. 用户能编辑、归档、取消默认知识包。 +1. 普通用户能理解“添加资料 -> 整理确认 -> 在 Agent 中使用 -> 生成结果继续沉淀”的闭环。 +2. 普通用户可以从 File Manager、输入框资料图标、首页引导三处自然进入,不需要先理解资料管理页;`@资料` 仅作为兼容快捷入口。 +3. 普通用户不需要理解 RAG、embedding、promptlet、runtime resolver、packName、compiled、metadata 等术语。 +4. 输入框中可以明确看到当前是否使用项目资料,以及正在使用哪一份资料。 +5. 用户能确认、设为默认、归档、补充资料,并能把满意输出沉淀为资料。 ### 19.2 工程验收 @@ -899,7 +983,7 @@ Builder Skill 不应包含: ```bash test -f docs/roadmap/knowledge/prd.md -rg -n "KNOWLEDGE.md|knowledge_import_source|knowledge_resolve_context|fenced|KnowledgePack" docs/roadmap/knowledge/prd.md +rg -n "File Manager|@资料|沉淀为项目资料|knowledge_import_source|knowledge_resolve_context|KnowledgePack" docs/roadmap/knowledge/prd.md ``` 实现阶段: diff --git a/docs/roadmap/managed-objective/README.md b/docs/roadmap/managed-objective/README.md new file mode 100644 index 000000000..79d0872bb --- /dev/null +++ b/docs/roadmap/managed-objective/README.md @@ -0,0 +1,225 @@ +# Lime Managed Objective 路线图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把“一轮 agent turn”升级为“围绕一个业务目标持续推进,直到完成、阻塞、需要输入或耗尽预算”,同时确保执行仍收敛到 Lime current 主链。 + +依赖文档: + +- [../../research/codex-goal/README.md](../../research/codex-goal/README.md) +- [../../aiprompts/query-loop.md](../../aiprompts/query-loop.md) +- [../../aiprompts/task-agent-taxonomy.md](../../aiprompts/task-agent-taxonomy.md) +- [../../aiprompts/state-history-telemetry.md](../../aiprompts/state-history-telemetry.md) +- [../../aiprompts/harness-engine-governance.md](../../aiprompts/harness-engine-governance.md) +- [../creaoai/README.md](../creaoai/README.md) + +配套文档: + +- [./architecture.md](./architecture.md) +- [./implementation-plan.md](./implementation-plan.md) +- [./diagrams.md](./diagrams.md) +- [./prototype.md](./prototype.md) + +## 1. 这套路线图回答什么 + +`Managed Objective` 回答的问题是: + +**当用户给出一个可判断完成的目标后,Lime 如何在多轮 agent turn / subagent turn / automation job 之间持续推进它,并在正确的时候停下来。** + +它不回答: + +1. skill 如何生成。 +2. tool 如何注册。 +3. 模型如何选择。 +4. automation job 如何调度。 +5. evidence pack 如何导出。 + +这些能力分别已有自己的 current 主链。`Managed Objective` 只能消费它们,不能替代它们。 + +这也是为什么实现 CreoAI 方向时,不能只实现 Managed Objective:Coding Agent / Skill Forge 仍然必须作为上游能力生成层单独落地,详见 [../creaoai/coding-agent-layer.md](../creaoai/coding-agent-layer.md)。 + +## 2. 先给结论 + +`Managed Objective` 是 **目标推进控制层**,不是新 runtime。 + +固定判断: + +1. 前台继续走 `agent turn`。 +2. 协作继续走 `subagent turn`。 +3. 后台继续走 `automation job`。 +4. 续跑继续通过 `agent_runtime_submit_turn` / `runtime_queue`。 +5. 完成审计继续消费 `artifact / thread_read / evidence pack`。 +6. Workspace 只展示 objective 状态,不反向定义完成真相。 + +一句话: + +**Managed Objective 让现有执行实体“知道为什么继续、何时停止”,但不新增第四类执行实体。** + +## 3. 固定主链 + +后续所有实现必须收敛到下面这条链: + +```text +用户目标 / 成功标准 + -> 绑定 owner:agent session / subagent session / automation job + -> objective state:目标、状态、预算、阻塞原因、审计摘要 + -> continuation policy:是否允许启动下一轮 + -> agent_runtime_submit_turn / runtime_queue + -> Query Loop / tool_runtime / automation service + -> timeline / artifact / thread_read / evidence pack + -> completion audit + -> continue / needs_input / blocked / budget_limited / completed / failed / paused +``` + +这条主链意味着: + +1. objective state 只保存目标推进状态,不保存另一份执行历史。 +2. continuation policy 只决定是否发起下一轮,不执行工具。 +3. completion audit 只消费 current 事实源,不让模型自报成为唯一依据。 +4. durable 后台能力必须落到 automation job,不允许 objective 自己当 scheduler。 +5. 子代理推进必须仍是 child session / subagent turn,不允许 objective 自己创建团队 runtime。 + +## 4. current / compat / deprecated / dead 分类 + +### current + +后续继续强化的主路径: + +1. `agent_runtime_submit_turn -> runtime_turn -> runtime_queue -> stream_reply_once`。 +2. `agent turn / subagent turn / automation job` 三类一等执行实体。 +3. `SessionDetail / AgentRuntimeThreadReadModel` 状态读模型。 +4. `agent_runtime_export_evidence_pack` 证据事实源。 +5. `automation job` 作为 durable 后台承载。 +6. `Workspace artifact / task center / evidence UI` 作为展示面。 + +### compat + +允许短期存在、但只能做适配的路径: + +1. 将旧 prompt 续写语义映射为明确的 objective metadata。 +2. 将现有 automation payload 适配为 objective owner。 +3. 将已有 thread summary 显示为 objective audit 的辅助上下文。 + +退出条件:这些适配一旦能直接从 current state / evidence 读取,就删除兼容映射。 + +### deprecated + +禁止继续扩展的方向: + +1. 只靠 slash command 实现 `/goal`,但没有持久状态与审计。 +2. 在 Query Loop 外新增 objective runner。 +3. 让 automation job、UI、review 各自判断“目标是否完成”。 +4. 给 objective 新增独立 queue、scheduler、tool registry 或 evidence exporter。 +5. 把 `auto_continue` 当成 persistent objective。 + +### dead + +可以直接否定的方向: + +1. `goal_runtime` 作为第四类 runtime taxonomy。 +2. `objective_evidence` 作为 evidence pack 的平行事实源。 +3. 未绑定 owner 的后台 objective 自动执行。 +4. 未经 artifact / evidence / thread_read 审计的自动完成状态。 + +## 5. 与 Codex `/goal` 的关系 + +[Codex `/goal`](../../research/codex-goal/README.md) 给 Lime 的启发是: + +```text +persistent thread goal(同一会话线程上的持久目标状态) + -> idle continuation + -> completion audit + -> budget / pause / resume / complete +``` + +这条链在研究文档里称为 [thread goal loop](../../research/codex-goal/README.md#11-什么是-thread-goal-loop): + +1. `thread` 是会话线程,不是系统线程。 +2. `goal` 是绑定在该线程上的持久目标状态。 +3. `loop` 是 runtime 在每轮 turn 结束后检查是否要继续发起下一轮 continuation turn。 + +但 Lime 不能照搬: + +1. Codex `/goal` 是 thread-level experimental feature。 +2. 它没有 Lime 的 automation job / workspace artifact / evidence pack 体系。 +3. 它没有 `needs_input / blocked / failed / verifying` 这类业务状态。 +4. 它的完成判断更依赖 prompt discipline,Lime 必须引入结构化 evidence audit。 + +因此本路线图只借鉴 runtime pattern,不复制 command surface。 + +## 6. 与 CreoAI / Skill Forge 的关系 + +CreoAI 路线图关注: + +```text +能力生成 -> Skill / Adapter 编译 -> verification gate -> 注册 -> 执行 +``` + +Managed Objective 关注: + +```text +已存在的执行实体 -> 围绕目标继续推进 -> 审计完成或停止 +``` + +二者关系: + +1. Skill Forge 生产可复用能力。 +2. Managed Objective 驱动这些能力围绕目标持续运行。 +3. automation job / subagent / Query Loop 仍是实际执行者。 +4. evidence pack 仍是完成审计事实源。 + +固定边界: + +**不要把 Managed Objective 塞进 Skill Forge,也不要把 Skill Forge 当作 objective runtime。** + +## 7. 首个推荐场景 + +推荐首个场景仍然保持低风险: + +```text +给一个已验证的只读 workspace-local skill 创建每日目标:每天 9 点生成 Markdown 趋势摘要,直到满足“连续 7 天产出并无失败”或用户暂停。 +``` + +这个场景能覆盖: + +1. objective 绑定 automation job。 +2. 每次运行走 Query Loop。 +3. artifact 保存 Markdown 报告。 +4. evidence pack 记录 skill 调用、产物、失败和审计。 +5. completion audit 判断是否继续。 +6. 失败时进入 `needs_input / blocked`,而不是盲目续跑。 + +首期不做: + +1. 外部发布。 +2. 自动下单或付款。 +3. 自动改价。 +4. 跨 workspace objective。 +5. 多 agent 自主扩队。 + +## 8. 先读顺序 + +建议按下面顺序阅读和实现: + +1. [../../research/codex-goal/README.md](../../research/codex-goal/README.md) +2. [../../aiprompts/task-agent-taxonomy.md](../../aiprompts/task-agent-taxonomy.md) +3. [../../aiprompts/query-loop.md](../../aiprompts/query-loop.md) +4. [../../aiprompts/state-history-telemetry.md](../../aiprompts/state-history-telemetry.md) +5. [./architecture.md](./architecture.md) +6. [./implementation-plan.md](./implementation-plan.md) +7. [./diagrams.md](./diagrams.md) + +## 9. 完成判定 + +这套路线图完成时,Lime 至少应该能做到: + +1. 用户能给 agent session 或 automation job 设置明确目标。 +2. 系统能保存 objective state,并在 app 重启后恢复。 +3. 系统能在安全条件满足时触发下一轮 continuation turn。 +4. 系统能在证据不足、缺输入、阻塞、预算耗尽时停止自动续跑。 +5. 系统能用 evidence pack / artifact / thread_read 支撑完成审计。 +6. Workspace 能展示目标、状态、下一步、阻塞原因和证据入口。 + +一句话: + +**目标推进不是“AI 再努力一点”,而是 runtime 用状态、预算、证据和停止条件把多轮执行管起来。** diff --git a/docs/roadmap/managed-objective/architecture.md b/docs/roadmap/managed-objective/architecture.md new file mode 100644 index 000000000..ce54782f8 --- /dev/null +++ b/docs/roadmap/managed-objective/architecture.md @@ -0,0 +1,274 @@ +# Managed Objective 架构蓝图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:定义 Managed Objective 的分层、状态、输入输出、事实源和禁止越界项。 + +依赖文档: + +- [./README.md](./README.md) +- [../../research/codex-goal/README.md](../../research/codex-goal/README.md) +- [../../aiprompts/query-loop.md](../../aiprompts/query-loop.md) +- [../../aiprompts/task-agent-taxonomy.md](../../aiprompts/task-agent-taxonomy.md) +- [../../aiprompts/harness-engine-governance.md](../../aiprompts/harness-engine-governance.md) + +## 1. 事实源声明 + +从现在开始,`Managed Objective` 只允许向下面这组事实源收敛: + +```text +Objective state + -> owner: agent session / subagent session / automation job + -> execution: agent_runtime_submit_turn / runtime_queue + -> facts: SessionDetail / AgentRuntimeThreadReadModel / artifact / evidence pack + -> projection: Workspace UI / task center / review +``` + +固定规则: + +1. objective state 是目标控制事实源。 +2. execution facts 仍属于 Query Loop / automation / subagent 主链。 +3. audit facts 仍属于 artifact / evidence pack。 +4. UI projection 只能展示,不反向定义完成状态。 + +## 2. 分层总览 + +| 层 | 角色 | 输入 | 输出 | 禁止事项 | +| --- | --- | --- | --- | --- | +| Objective Entry | 创建、暂停、恢复、清除目标 | 用户目标、成功标准、owner | objective draft / active objective | 直接执行工具 | +| Owner Binding | 把目标挂到现有执行实体 | session id、subagent session id、automation job id | owner ref | 新增第四类执行实体 | +| Objective State | 保存目标推进状态 | objective metadata、audit result | status、budget、blocker、next action | 保存第二份 runtime history | +| Continuation Policy | 判断能否续跑 | state、queue、pending input、budget、risk | continue / stop decision | 自建 scheduler / queue | +| Runtime Dispatch | 发起下一轮执行 | continuation request | `agent_runtime_submit_turn` / runtime queue item | 绕过 Query Loop | +| Evidence Audit | 判断是否完成 | artifact、thread_read、evidence pack | audit decision | 只采信模型自报 | +| Workspace Projection | 给用户展示与操作 | objective state、audit summary | goal card / task card / evidence link | 成为事实源 | + +## 3. 核心对象 + +### 3.1 `ManagedObjective` + +概念对象,最小字段建议: + +```text +objective_id +workspace_id +owner_kind: agent_session | subagent_session | automation_job +owner_id +objective_text +success_criteria[] +status +budget_policy +risk_policy +approval_policy +continuation_policy +last_audit_summary +last_evidence_pack_ref? +last_artifact_refs[] +blocker_reason? +created_at +updated_at +``` + +约束: + +1. `owner_kind / owner_id` 必填。 +2. `success_criteria` 必须能被审计,不能只是愿景口号。 +3. `last_evidence_pack_ref` 是审计引用,不是 evidence pack 的替代品。 +4. `continuation_policy` 只描述何时续跑,不包含工具调用实现。 + +### 3.2 `ObjectiveAuditResult` + +概念对象,最小字段建议: + +```text +audit_id +objective_id +decision: continue | completed | needs_input | blocked | budget_limited | failed +checked_criteria[] +evidence_refs[] +artifact_refs[] +thread_read_ref? +summary +next_action? +created_at +``` + +约束: + +1. `completed` 必须有 evidence / artifact / thread_read 支撑。 +2. `needs_input` 必须写清需要用户补什么。 +3. `blocked` 必须写清阻塞来源和可恢复条件。 +4. `failed` 必须写清是否允许用户恢复或重新计划。 + +## 4. 状态模型 + +Managed Objective 状态不是 run 状态,而是目标推进状态。 + +推荐状态: + +```text +active +verifying +needs_input +blocked +budget_limited +paused +completed +failed +``` + +状态语义: + +| 状态 | 语义 | 是否允许自动续跑 | +| --- | --- | --- | +| `active` | 目标可继续推进 | 允许,需通过 policy guard | +| `verifying` | 正在做完成审计 | 不允许启动新执行 | +| `needs_input` | 缺用户输入或配置 | 不允许,等用户响应 | +| `blocked` | 外部依赖、权限或失败阻塞 | 不允许,等解除阻塞 | +| `budget_limited` | token / 时间 / 成本预算耗尽 | 不允许,等用户调整预算 | +| `paused` | 用户暂停 | 不允许,等用户恢复 | +| `completed` | 审计确认完成 | 不允许 | +| `failed` | 不可恢复失败或用户终止 | 不允许,除非显式重开 | + +状态转换: + +```text +active -> verifying -> completed +active -> verifying -> active +active -> needs_input +active -> blocked +active -> budget_limited +active -> paused +active -> failed +paused -> active +needs_input -> active +blocked -> active +budget_limited -> active +completed -> active # 仅限 replace / reopen 新目标 +failed -> active # 仅限用户显式 retry / reopen +``` + +固定边界: + +1. `running` 是 owner 执行状态,不是 objective 状态。 +2. `scheduled` 是 automation job 状态,不是 objective 状态。 +3. `queued` 是 runtime queue 状态,不是 objective 状态。 + +## 5. Continuation Policy + +自动续跑必须同时满足这些 guard: + +1. objective status 是 `active`。 +2. owner 仍存在且未被删除。 +3. 当前没有 active turn。 +4. runtime queue 没有同 owner 的未完成 continuation。 +5. 没有 queued user input。 +6. 没有 pending elicitation / pending approval。 +7. 没有 user pause / interrupt。 +8. budget policy 未耗尽。 +9. risk policy 允许下一步动作。 +10. 最近 audit 没有判定 `needs_input / blocked / completed / failed`。 + +触发来源只能是: + +1. 前一轮 turn 完成。 +2. automation job 到期。 +3. 用户 resume。 +4. 用户补齐输入或解除阻塞。 +5. 手动点击继续。 + +禁止触发来源: + +1. UI 轮询看到状态未完成就直接续跑。 +2. evidence 导出脚本反向触发 runtime。 +3. review / replay / analysis 消费方触发下一轮。 +4. model 自己在普通回复里创建后台 continuation。 + +## 6. Completion Audit + +完成审计必须按下面顺序读事实: + +```text +objective success criteria + -> current owner state + -> SessionDetail / AgentRuntimeThreadReadModel + -> artifacts + -> evidence pack + -> optional model audit prompt + -> ObjectiveAuditResult +``` + +审计规则: + +1. 先把目标转成可检查 criteria。 +2. 每条 criteria 至少要标注 `satisfied / unsatisfied / unknown`。 +3. `unknown` 不能被当作完成。 +4. 外部动作结果必须有 tool call / artifact / evidence 引用。 +5. 模型总结只能作为解释层,不能替代 facts。 +6. 没有足够证据时默认 `continue`、`needs_input` 或 `blocked`,不要默认 `completed`。 + +## 7. 与现有主链的接口 + +### 7.1 Query Loop + +- continuation turn 必须继续走 `agent_runtime_submit_turn`。 +- continuation prompt 只能作为 `request_metadata` / prompt augmentation 的一部分。 +- `TurnInputEnvelope` 必须能看见 objective metadata snapshot。 + +### 7.2 Task / Agent taxonomy + +- objective owner 只能是 `agent turn / subagent turn / automation job` 对应的持久实体。 +- objective 不新增 run source。 +- execution tracker 仍只记录真实执行,不记录“目标想象中的进度”。 + +### 7.3 State / History / Telemetry + +- objective projection 必须消费 `SessionDetail / AgentRuntimeThreadReadModel`。 +- objective audit 引用 evidence pack,不复制 evidence pack。 +- history record / dashboard 只能展示 objective audit 派生结果。 + +### 7.4 Harness Engine + +- `agent_runtime_export_evidence_pack` 是 audit 的结构化输入。 +- audit result 可以成为 evidence 的消费结果,但不能成为 evidence 源。 +- replay / review 可以复用 audit result,但不能重建另一套 completion truth。 + +### 7.5 Automation Service + +- automation job 是 durable owner。 +- due job 可以触发 continuation policy。 +- automation payload 可以包含 objective id,但不能让 objective 自建调度表。 + +## 8. 权限与风险边界 + +Managed Objective 必须继承 runtime 的权限纪律: + +1. 外部写操作默认 `needs_approval`。 +2. 金钱、发布、删除、改价、下单默认不允许自动续跑。 +3. 浏览器登录态缺失应进入 `needs_input`。 +4. API 凭证缺失应进入 `needs_input`。 +5. 连续失败应进入 `blocked` 或 `failed`,不能无限重试。 +6. 预算耗尽应进入 `budget_limited`,不能自动扩预算。 + +## 9. 最小实现边界 + +首期最小可交付不需要: + +1. DAG workflow。 +2. 多 agent 自主扩队。 +3. 独立 objective scripting language。 +4. UI 可视化工作流编辑器。 +5. 外部写操作全自动执行。 + +首期必须具备: + +1. owner binding。 +2. objective state。 +3. continuation guard。 +4. evidence-based audit。 +5. stop conditions。 +6. workspace projection。 + +一句话: + +**Managed Objective 的复杂度应该来自“停止条件和证据”,不是来自新调度器。** diff --git a/docs/roadmap/managed-objective/diagrams.md b/docs/roadmap/managed-objective/diagrams.md new file mode 100644 index 000000000..d8dff30a4 --- /dev/null +++ b/docs/roadmap/managed-objective/diagrams.md @@ -0,0 +1,224 @@ +# Managed Objective 图纸 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:用图固定 Managed Objective 与 Query Loop、automation job、evidence pack、Workspace 的边界。 + +配套原型: + +- [prototype.md](./prototype.md) + +本文负责架构图、状态图、时序图和流程图;产品 UI 原型统一放在 `prototype.md`。 + +## 1. 总体主链图 + +```mermaid +flowchart TB + User[用户目标 / 成功标准] --> Entry[Objective Entry] + Entry --> State[Managed Objective State
目标 / 状态 / 预算 / 阻塞 / audit 摘要] + State --> Owner{Owner Binding} + Owner --> Session[Agent Session] + Owner --> Subagent[Subagent Session] + Owner --> Job[Automation Job] + + State --> Policy[Continuation Policy
guard / budget / risk / pause] + Policy -->|允许继续| Submit[agent_runtime_submit_turn] + Policy -->|停止| Stop[needs_input / blocked / budget / paused / completed] + + Submit --> Queue[runtime_queue] + Queue --> Runtime[Query Loop / tool_runtime] + Runtime --> Facts[timeline / artifact / thread_read] + Facts --> Evidence[evidence pack] + Evidence --> Audit[Completion Audit] + Audit --> State + State --> Workspace[Workspace Projection] + Workspace --> User +``` + +固定判断: + +1. Objective state 只控制目标推进。 +2. Runtime execution 仍属于 Query Loop。 +3. Durable 触发仍属于 automation job。 +4. 完成审计读取 evidence pack 后回写 objective state。 + +## 2. 不是第四类 runtime 图 + +```mermaid +flowchart LR + RuntimeTaxonomy[Current Runtime Taxonomy] --> AgentTurn[agent turn] + RuntimeTaxonomy --> SubagentTurn[subagent turn] + RuntimeTaxonomy --> AutomationJob[automation job] + + Objective[Managed Objective
control layer] --> AgentTurn + Objective --> SubagentTurn + Objective --> AutomationJob + + Dead[dead direction] -.禁止.-> GoalRuntime[goal_runtime] + Dead -.禁止.-> ObjectiveQueue[objective_queue] + Dead -.禁止.-> ObjectiveScheduler[objective_scheduler] + Dead -.禁止.-> ObjectiveEvidence[objective_evidence] +``` + +固定判断: + +**Managed Objective 只能挂到现有执行实体,不能成为第四类 taxonomy。** + +## 3. 状态机图 + +```mermaid +stateDiagram-v2 + [*] --> active + active --> verifying: turn 完成 / 手动审计 + verifying --> completed: evidence 满足全部 criteria + verifying --> active: 未完成且可继续 + active --> needs_input: 缺输入 / 缺配置 / 缺确认 + active --> blocked: 外部依赖失败 / 权限阻塞 + active --> budget_limited: 预算耗尽 + active --> paused: 用户暂停 / interrupt + active --> failed: 不可恢复失败 + + needs_input --> active: 用户补齐输入 + blocked --> active: 阻塞解除 + budget_limited --> active: 用户调整预算 + paused --> active: 用户恢复 + failed --> active: 用户显式 reopen + completed --> active: replace / reopen 新目标 + + completed --> [*] + failed --> [*] +``` + +固定判断: + +1. `running / queued / scheduled` 不属于 objective 状态。 +2. `completed` 必须来自 audit。 +3. `needs_input / blocked / budget_limited / paused` 都会阻止自动续跑。 + +## 4. Manual continuation 时序图 + +```mermaid +sequenceDiagram + participant U as 用户 + participant W as Workspace UI + participant O as Objective State + participant P as Continuation Policy + participant Q as Query Loop + participant E as Evidence Pack + participant A as Audit + + U->>W: 点击继续目标 + W->>O: 读取 active objective + O->>P: 请求 continuation decision + P-->>W: 允许继续 + W->>Q: agent_runtime_submit_turn(objective metadata) + Q-->>E: 导出执行事实 + E->>A: 提供 audit 输入 + A->>O: 写入 audit result + O-->>W: 更新状态与下一步 +``` + +固定判断: + +**手动 continue 也必须走 `agent_runtime_submit_turn`,不能成为 UI 私有执行入口。** + +## 5. Automation owner 时序图 + +```mermaid +sequenceDiagram + participant S as Scheduler Tick + participant J as Automation Job + participant O as Objective State + participant P as Continuation Policy + participant R as Runtime Queue + participant Q as Query Loop + participant E as Evidence Pack + participant A as Audit + + S->>J: 发现 due job + J->>O: 读取绑定 objective + O->>P: 检查 guard / budget / risk + alt 不允许继续 + P-->>J: stop reason + J-->>O: 写入 needs_input / blocked / budget_limited + else 允许继续 + P-->>J: continue request + J->>R: 投递标准 runtime turn + R->>Q: 执行 agent turn + Q->>E: 写入证据 + E->>A: 完成审计 + A->>O: 更新 objective status + O-->>J: 更新 job run 摘要 + end +``` + +固定判断: + +1. scheduler tick 只发现 due job。 +2. automation job 是 durable owner。 +3. objective 不自建 scheduler。 +4. Query Loop 仍执行真实 turn。 + +## 6. Completion audit 流程图 + +```mermaid +flowchart TD + Start[开始 audit] --> Criteria[读取 success criteria] + Criteria --> Thread[读取 AgentRuntimeThreadReadModel] + Thread --> Artifact[读取 artifact refs] + Artifact --> Evidence[读取 evidence pack] + Evidence --> Check{每条 criteria 是否有证据} + + Check -->|全部满足| Complete[completed] + Check -->|部分不满足但可继续| Continue[continue / active] + Check -->|缺用户输入| NeedsInput[needs_input] + Check -->|外部阻塞| Blocked[blocked] + Check -->|预算耗尽| Budget[budget_limited] + Check -->|不可恢复| Failed[failed] + + Complete --> Result[ObjectiveAuditResult] + Continue --> Result + NeedsInput --> Result + Blocked --> Result + Budget --> Result + Failed --> Result +``` + +固定判断: + +1. `unknown` 不能判完成。 +2. 模型总结只解释 evidence,不替代 evidence。 +3. audit result 是 objective state 的输入,不是 evidence pack 的替代品。 + +## 7. 与 CreoAI / Skill Forge 的关系图 + +```mermaid +flowchart TB + Forge[Skill Forge
生成能力] --> Draft[Generated Capability Draft] + Draft --> Gate[Verification Gate] + Gate --> Skill[Workspace-local Skill] + + Skill --> Job[Automation Job / Agent Session] + Job --> Objective[Managed Objective
目标推进控制层] + Objective --> Runtime[Query Loop / tool_runtime] + Runtime --> Evidence[evidence pack / artifact] + Evidence --> Objective + + Objective -.不是.-> ForgeRuntime[Skill Forge Runtime] + Objective -.不是.-> ToolRegistry[Generated Tool Registry] +``` + +固定判断: + +1. Skill Forge 负责生成能力。 +2. Managed Objective 负责推进目标。 +3. 两者都必须回到 current runtime 和 evidence 主链。 + +## 8. 后续改图规则 + +后续如果实现修改了状态机、owner 绑定或 audit 输入,必须同步更新本文。更新时遵守: + +1. 图中不能新增第四类 runtime。 +2. 图中不能让 objective 直接执行 tool。 +3. 图中不能出现 evidence pack 的平行替代品。 +4. 图中不能把 UI 画成完成状态事实源。 diff --git a/docs/roadmap/managed-objective/implementation-plan.md b/docs/roadmap/managed-objective/implementation-plan.md new file mode 100644 index 000000000..ec574edac --- /dev/null +++ b/docs/roadmap/managed-objective/implementation-plan.md @@ -0,0 +1,282 @@ +# Managed Objective 实施计划 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 Managed Objective 拆成可验证阶段,先做受控目标续跑闭环,再扩展到长期业务任务。 + +依赖文档: + +- [./README.md](./README.md) +- [./architecture.md](./architecture.md) +- [./diagrams.md](./diagrams.md) +- [../../aiprompts/query-loop.md](../../aiprompts/query-loop.md) +- [../../aiprompts/task-agent-taxonomy.md](../../aiprompts/task-agent-taxonomy.md) +- [../../aiprompts/state-history-telemetry.md](../../aiprompts/state-history-telemetry.md) + +## 1. 实施总原则 + +1. **不新增 runtime taxonomy** + - 任何执行都必须落到 `agent turn / subagent turn / automation job`。 + +2. **先状态,后自动** + - 先把 objective state 和 audit 写清楚,再允许自动 continuation。 + +3. **先手动续跑,后空闲续跑** + - 首期用手动 continue 验证链路,避免一上来就做后台自动循环。 + +4. **先 evidence audit,后模型总结** + - 完成判断必须优先消费 artifact / thread_read / evidence pack。 + +5. **先低风险任务** + - 首期只做只读 skill / 本地 artifact 产出,不做外部写操作自动化。 + +## 2. P0:文档与边界落盘 + +目标:固定 Managed Objective 的定义和禁止项。 + +任务: + +1. 新增 `docs/roadmap/managed-objective/`。 +2. 在 `docs/aiprompts/query-loop.md` 中声明 continuation 仍走 Query Loop。 +3. 在 `docs/aiprompts/task-agent-taxonomy.md` 中声明 objective 不是第四类执行实体。 +4. 在 `docs/aiprompts/state-history-telemetry.md` 中声明 objective projection 只消费 current 读模型。 +5. 在 `docs/aiprompts/harness-engine-governance.md` 中声明 audit 只消费 evidence pack。 + +完成标准: + +1. 文档能解释 `/goal` 与 Managed Objective 的区别。 +2. 文档能解释 Managed Objective 与 CreoAI / Skill Forge 的区别。 +3. 文档明确 `goal_runtime / objective_evidence / objective_scheduler` 这些方向是 dead。 + +## 3. P1:Objective state scaffold + +目标:让系统能保存和读取目标状态,但不自动续跑。 + +范围: + +1. 定义 objective state 最小结构。 +2. 绑定 `owner_kind / owner_id`。 +3. 支持创建、暂停、恢复、完成、清除。 +4. 在 thread read 或 workspace projection 中显示 objective 摘要。 +5. 记录 `success_criteria / budget_policy / risk_policy`。 + +非目标: + +1. 不启动下一轮 turn。 +2. 不创建 automation job。 +3. 不做后台 idle continuation。 +4. 不执行任何工具。 + +完成标准: + +1. 一个 agent session 能挂一个 active objective。 +2. app 重启后 objective state 可恢复。 +3. pause / resume 能改变状态,但不影响 Query Loop 主链。 +4. 没有 owner 的 objective 不能进入 active。 + +建议验证: + +1. objective create / read / pause / resume / clear 单测。 +2. owner 不存在时拒绝 active 的边界测试。 +3. thread read / workspace projection 快照测试。 + +## 4. P2:Manual continuation turn + +目标:让用户手动触发“继续推进目标”,并确保仍走 Query Loop。 + +范围: + +1. 从 objective 生成 continuation metadata。 +2. 手动 continue 调用 `agent_runtime_submit_turn`。 +3. `TurnInputEnvelope` 记录 objective snapshot。 +4. continuation prompt 要求检查目标和已知证据。 +5. 执行结果进入 timeline / artifact / thread_read。 + +非目标: + +1. 不做自动 idle continuation。 +2. 不做定时后台任务。 +3. 不做模型自动 complete 工具。 + +完成标准: + +1. 用户点击继续后,只产生一轮标准 agent turn。 +2. 这轮 turn 可在 runtime evidence 中追踪到 objective id。 +3. 若有 pending user input 或 paused 状态,手动 continue 被拒绝。 +4. `auto_continue` 文稿续写语义不会被误识别为 objective continuation。 + +建议验证: + +1. continuation metadata 组包测试。 +2. paused / needs_input 拒绝续跑测试。 +3. Query Loop 入口没有新增旁路的契约测试。 + +## 5. P3:Evidence-based completion audit + +目标:把“是否完成”从模型自报升级为 evidence-based audit。 + +范围: + +1. 生成 audit checklist。 +2. 读取 `SessionDetail / AgentRuntimeThreadReadModel`。 +3. 读取相关 artifact refs。 +4. 读取 `agent_runtime_export_evidence_pack`。 +5. 输出 `ObjectiveAuditResult`。 +6. 根据 audit result 更新 objective status。 + +完成标准: + +1. 没有 evidence refs 时不能标记 `completed`。 +2. criteria 为 `unknown` 时不能标记 `completed`。 +3. 缺用户输入时进入 `needs_input`。 +4. 外部依赖失败时进入 `blocked` 或 `failed`。 +5. audit result 能在 Workspace 看到摘要和证据入口。 + +建议验证: + +1. satisfied / unsatisfied / unknown checklist 单测。 +2. evidence 缺失阻断 complete 测试。 +3. needs_input / blocked 状态转换测试。 +4. evidence pack 导出引用测试。 + +## 6. P4:Automation owner binding + +目标:让 durable 后台任务可以绑定 objective,但调度仍属于 automation job。 + +范围: + +1. automation job payload 引用 objective id。 +2. due job 触发 continuation policy。 +3. continuation policy 通过后投递标准 agent turn。 +4. job run 和 objective audit 互相引用,但不复制事实。 +5. job pause / resume 与 objective pause / resume 行为一致。 + +非目标: + +1. 不新增 objective scheduler。 +2. 不新增 objective queue。 +3. 不新增 objective run history。 +4. 不允许 objective 绕过 automation service。 + +完成标准: + +1. 定时任务能推进 active objective。 +2. app 重启后 due job 能恢复或明确 blocked。 +3. 用户输入未处理时不会自动续跑。 +4. 连续失败会停止并进入 `blocked / failed`。 +5. evidence pack 能导出 automation owner 与 objective audit 关系。 + +建议验证: + +1. due job -> continuation policy -> runtime queue 集成测试。 +2. pause / resume 同步测试。 +3. 连续失败 cutoff 测试。 +4. app 重启恢复测试。 + +## 7. P5:Workspace projection + +目标:让用户能理解目标进度、阻塞点和证据。 + +范围: + +1. Objective card:目标、状态、成功标准、下一步。 +2. Job card:owner、最近运行、下次运行、失败次数。 +3. Audit view:criteria、证据引用、产物引用、决策原因。 +4. 操作:pause、resume、manual continue、clear、reopen。 +5. 高风险状态提示:needs_input、blocked、budget_limited。 + +完成标准: + +1. 用户能从 objective 看到 evidence。 +2. 用户能从 automation job 看到 objective。 +3. 用户能从 artifact 回到 objective audit。 +4. UI 不自行推断完成状态,只展示后端 projection。 + +建议验证: + +1. Objective card 组件测试。 +2. paused / blocked / completed 文案快照测试。 +3. GUI smoke:创建目标、手动继续、查看 audit。 + +## 8. P6:受控自动 continuation + +目标:在 P1-P5 稳定后,允许系统在空闲时自动推进目标。 + +范围: + +1. turn finished 后检查 active objective。 +2. 通过 guard 后投递下一轮 continuation。 +3. 尊重 queued input、pending elicitation、pause、budget、risk。 +4. 自动 continuation 有最大轮数、最大耗时、最大成本。 +5. 每轮 continuation 都写入 audit 或 run summary。 + +完成标准: + +1. active objective 能在多轮 turn 中继续推进。 +2. 用户输入插队时自动续跑停止。 +3. budget 耗尽时进入 `budget_limited`。 +4. 完成时进入 `completed`,不再续跑。 +5. evidence pack 能解释每一轮为什么继续或停止。 + +建议验证: + +1. idle continuation guard 单测。 +2. queued input 阻断测试。 +3. budget limit 测试。 +4. completion 停止续跑测试。 +5. 多轮 continuation 端到端 smoke。 + +## 9. 最小验收场景 + +### 场景:只读每日报告目标 + +输入: + +```text +为这个已验证的只读 skill 设置一个目标:每天 9 点生成 Markdown 趋势摘要,连续 7 次成功后完成。失败时最多重试 2 次,之后提醒我检查配置。 +``` + +系统应完成: + +1. 创建 automation job。 +2. 创建并绑定 Managed Objective。 +3. 每次 due job 通过 continuation policy。 +4. 每次执行走 `agent_runtime_submit_turn`。 +5. 产出 Markdown artifact。 +6. evidence pack 记录调用、产物、失败、预算与 audit。 +7. 满足 7 次成功后 audit 标记 `completed`。 +8. 失败超过阈值后进入 `blocked`,并要求用户输入。 + +不要求: + +1. 外部发布。 +2. 跨 workspace 共享。 +3. 多 agent 自主扩队。 +4. 外部写操作自动执行。 + +## 10. 实现守卫 + +实现时必须守住: + +1. 不新增 `goal_runtime`。 +2. 不新增 `objective_scheduler`。 +3. 不新增 `objective_queue`。 +4. 不新增 `objective_evidence_pack`。 +5. 不让 UI 自行判定 completed。 +6. 不让 model 自行创建后台 objective。 +7. 不让 `auto_continue` 冒充 persistent objective。 +8. 不让 unverified skill 被 managed objective 自动执行。 + +## 11. 推荐落地顺序 + +1. P0 文档。 +2. P1 objective state scaffold。 +3. P2 manual continuation。 +4. P3 evidence audit。 +5. P5 workspace projection。 +6. P4 automation owner binding。 +7. P6 自动 continuation。 + +顺序解释: + +**先让目标可见、可停、可审计,再让它自动跑。** diff --git a/docs/roadmap/managed-objective/prototype.md b/docs/roadmap/managed-objective/prototype.md new file mode 100644 index 000000000..97b645a0c --- /dev/null +++ b/docs/roadmap/managed-objective/prototype.md @@ -0,0 +1,231 @@ +# Managed Objective 产品原型图 + +> 状态:proposal +> 更新时间:2026-05-05 +> 目标:把 Managed Objective 在 Workspace、Task Center、Audit Drawer 和创建流程中的用户可见形态画出来,避免只停留在 runtime 概念。 + +依赖文档: + +- [./README.md](./README.md) +- [./architecture.md](./architecture.md) +- [./diagrams.md](./diagrams.md) + +## 1. 原型原则 + +Managed Objective 的 UI 不应该像“又一个自动化配置页”。它应该让用户一眼看懂三件事: + +1. **这个目标要完成什么**。 +2. **系统为什么继续或为什么停下**。 +3. **完成判断背后的证据在哪里**。 + +固定边界: + +1. UI 只展示后端 projection,不自行判断 completed。 +2. UI 的 `继续` 操作只触发 continuation policy,不直接执行 tool。 +3. UI 的 evidence 入口只打开 evidence pack / artifact / thread read 派生视图,不重建第二套证据。 + +## 2. Workspace 目标卡原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Workspace · Objectives │ +├──────────────────────────────────────────────────────────────┤ +│ 目标 │ +│ 每天 9 点生成 Markdown 趋势摘要,连续 7 次成功后完成 │ +│ │ +│ 状态 active │ +│ Owner automation job · daily-trend-report │ +│ 成功标准 0/7 连续成功 · 最近一次成功 2026-05-05 09:02 │ +│ 预算 30 min / run · 2 retries · read-only tools │ +│ 下一步 下次运行 2026-05-06 09:00 │ +│ │ +│ [暂停] [手动继续] [查看审计] [打开产物] │ +└──────────────────────────────────────────────────────────────┘ +``` + +信息优先级: + +1. 目标文本。 +2. 当前状态。 +3. owner 类型和 owner 名称。 +4. 成功标准进度。 +5. 下一步动作。 +6. 暂停 / 继续 / 审计 / 产物入口。 + +## 3. 状态变体原型 + +### 3.1 `needs_input` + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 目标:生成每日趋势摘要 │ +│ 状态:needs_input │ +├──────────────────────────────────────────────────────────────┤ +│ 系统暂停续跑,因为缺少输入: │ +│ - API token 已过期 │ +│ - 最近一次请求返回 401 │ +│ │ +│ 需要你提供: │ +│ [更新凭证] [改为本地文件输入] [取消目标] │ +│ │ +│ 证据:request_log#20260505-0901 · evidence pack │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 3.2 `blocked` + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 目标:生成每日趋势摘要 │ +│ 状态:blocked │ +├──────────────────────────────────────────────────────────────┤ +│ 阻塞原因:连续 2 次 dry-run 失败 │ +│ 最近失败:CLI 返回 exit code 2 │ +│ 建议下一步:检查配置文件路径或重新验证 skill │ +│ │ +│ [重新验证 skill] [手动继续一次] [暂停目标] [查看失败证据] │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 3.3 `completed` + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 目标:生成每日趋势摘要 │ +│ 状态:completed │ +├──────────────────────────────────────────────────────────────┤ +│ 完成原因:7 条成功标准全部满足 │ +│ - 连续 7 次成功:通过 │ +│ - 每次生成 Markdown artifact:通过 │ +│ - 无未处理失败:通过 │ +│ │ +│ [查看审计报告] [打开全部产物] [基于此目标创建新目标] │ +└──────────────────────────────────────────────────────────────┘ +``` + +固定判断: + +1. `needs_input` 强调用户要补什么。 +2. `blocked` 强调为什么系统不能继续。 +3. `completed` 强调哪些证据满足了成功标准。 + +## 4. Task Center 列表原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Task Center │ +├──────────────┬─────────────┬───────────────┬────────────────┤ +│ 任务 │ Objective │ 状态 │ 下一步 │ +├──────────────┼─────────────┼───────────────┼────────────────┤ +│ daily report │ 7-day goal │ active │ 明天 09:00 │ +│ price watch │ monitor │ needs_input │ 等待凭证 │ +│ draft export │ publish prep│ paused │ 用户恢复后继续 │ +└──────────────┴─────────────┴───────────────┴────────────────┘ +``` + +列表只展示 objective projection,不把 task center 变成调度事实源。 + +## 5. Audit Drawer 原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Completion Audit · daily-trend-report │ +├──────────────────────────────────────────────────────────────┤ +│ Decision: continue │ +│ Summary : 已完成 3/7 次成功,仍需继续 4 次 │ +│ │ +│ Criteria │ +│ [✓] 每次运行生成 Markdown artifact │ +│ artifact: workspace://reports/2026-05-05.md │ +│ [✓] 最近一次运行无 tool error │ +│ evidence: evidence_pack/runtime.json │ +│ [ ] 连续 7 次成功 │ +│ current streak: 3 │ +│ [?] 输出摘要是否覆盖所有数据源 │ +│ unknown: 数据源 B 本次为空,需要下一轮确认 │ +│ │ +│ Next action │ +│ 下次 automation job 到期时继续运行。 │ +└──────────────────────────────────────────────────────────────┘ +``` + +审计抽屉必须显示: + +1. audit decision。 +2. 每条 criteria 的状态。 +3. evidence refs。 +4. artifact refs。 +5. unknown / unsatisfied 的下一步。 + +## 6. 创建目标弹窗原型 + +```text +┌──────────────────────────────────────────────────────────────┐ +│ Create Managed Objective │ +├──────────────────────────────────────────────────────────────┤ +│ 目标 │ +│ [每天 9 点生成 Markdown 趋势摘要,连续 7 次成功后完成 ] │ +│ │ +│ Owner │ +│ ( ) 当前 agent session │ +│ ( ) subagent session │ +│ (●) automation job: daily-trend-report │ +│ │ +│ 成功标准 │ +│ [✓] 生成 Markdown artifact │ +│ [✓] 连续 7 次成功 │ +│ [✓] 无未处理失败 │ +│ [+ 添加标准] │ +│ │ +│ 风险策略 │ +│ [✓] 只读工具可自动执行 │ +│ [ ] 外部写操作自动执行 │ +│ [✓] 高风险动作需要确认 │ +│ │ +│ [取消] [创建目标] │ +└──────────────────────────────────────────────────────────────┘ +``` + +创建时必须校验: + +1. owner 必填。 +2. 成功标准至少一条。 +3. 高风险策略默认关闭自动执行。 +4. 未验证 skill 不能绑定自动目标。 + +## 7. 移动端压缩原型 + +```text +┌────────────────────────────┐ +│ Objective │ +├────────────────────────────┤ +│ 每日趋势摘要 │ +│ active · 3/7 success │ +│ owner: daily report │ +│ next: 明天 09:00 │ +│ │ +│ [暂停] [继续] [审计] │ +└────────────────────────────┘ +``` + +移动端只保留:目标、状态、进度、下一步、核心操作。 + +## 8. UI 与 runtime 的边界图 + +```mermaid +flowchart LR + UI[Workspace UI / Task Center] --> Command[Objective command] + Command --> Policy[Continuation Policy] + Policy --> Runtime[agent_runtime_submit_turn / automation job] + Runtime --> Facts[thread_read / artifact / evidence pack] + Facts --> Projection[Objective projection] + Projection --> UI + + UI -.禁止.-> Tool[direct tool execution] + UI -.禁止.-> Complete[local completed decision] + UI -.禁止.-> Scheduler[local scheduler] +``` + +固定判断: + +**原型里的按钮都是控制入口,不是新的执行入口。** diff --git a/docs/roadmap/warp/README.md b/docs/roadmap/warp/README.md index cdf0dc4ce..f03eece80 100644 --- a/docs/roadmap/warp/README.md +++ b/docs/roadmap/warp/README.md @@ -143,11 +143,11 @@ Lime 的 artifact graph 不应只有 `document` / `file`。 通用文件只作为兜底,不作为多模态默认主结果。 -当前 `browser_session` / `browser_snapshot` 不新建平行 task 协议;Browser Assist tool timeline 先通过 evidence `snapshotIndex.browserActionIndex` 进入可查询索引层,并已在 Harness evidence panel 暴露摘要、通过最小 `browser_replay_viewer` 打开复盘。后续完整交互回放、权限 profile 与截图/DOM/network 深层展开继续消费同一事实源。 +当前 `browser_session` / `browser_snapshot` 不新建平行 task 协议;Browser Assist tool timeline 先通过 evidence `snapshotIndex.browserActionIndex` 进入动作索引层,并通过 `snapshotIndex.taskIndex` 进入与媒体任务一致的 `thread_id / turn_id / content_id / entry_key / modality / skill_id / model_id / executor / cost / limit` 查询口径;Replay / grader 也已把该索引写入 `runtimeFacts.modalityTaskIndex`、suite tags 与合同检查,前端已把同一索引转成任务中心可复用的 facets / rows / filters,`HarnessTaskIndexSection` 已消费该查询模型展示“多模态任务索引”,内嵌“任务中心过滤列表”按 entry / content / executor / cost / limit 过滤同一 rows,`HarnessStatusPanel` 只保留挂载面,并可通过最小 `browser_replay_viewer` 打开复盘。后续完整交互回放、独立主任务中心入口、权限 profile 与截图/DOM/network 深层展开继续消费同一事实源。 当前 `transcript` 已绑定到底层 `audio_transcription` contract;`@转写 / @transcribe / @Audio Extractor` 只是上层入口,前端、Rust metadata、`transcription_generate` task file、CLI 回退入口与 `lime-transcription-worker` 会保留同一份 `audio_transcription` runtime contract snapshot。当前闭环已经能写入 `.lime/tasks/transcription_generate/*.json`,在 payload 下生成 `transcript.pending`,通过 OpenAI-compatible transcription provider seam 回写 `transcript.completed/failed`,并把 transcript 状态/路径/来源/语言/格式/Provider 错误纳入 `list_media_task_artifacts`、聊天任务卡、`.lime/runtime/transcription-generate/*.md` 运行时文档、Evidence Pack `snapshotIndex.transcriptIndex` 与 Replay / grader。第四十三刀已让运行时文档读取 `.lime/runtime/transcripts/*` 文本内容,打开任务卡即可看到可复制校对的转写文本;第四十四刀继续解析 JSON / SRT / VTT transcript 的时间轴与说话人,并在聊天轻卡和运行时文档中展示可逐段编辑校对的段落表;第四十五刀复用 ArtifactDocument 保存链路,保存校对稿时写入 `transcriptCorrection*` / `transcriptSegmentsCorrected` metadata,并明确不改写原始 ASR 输出文件;第四十六刀补上 viewer 内“校对稿已保存”状态卡与 `transcriptCorrectionDiffSummary`,让原文/校对稿的文本长度、段落、说话人数差异可见。后续仍需要更专用的逐段 transcript viewer 交互、更多 ASR adapter 与本地离线 ASR 执行器。 -当前 `execution_profile` / `executor_adapter` 已从治理 registry 进入前端 launch metadata、Rust runtime contract snapshot、Evidence Pack、Replay 与 `list_media_task_artifacts` 统一媒体任务索引。任务列表可以直接查询 `profile_key`、`adapter_key`、`executor_kind`、`executor_binding_key`、`limecore_policy_refs` 与最小 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count, policy_evaluation)`;Harness evidence 面板已展示 `LimeCore 策略缺口`,Replay / grader 也会把 `limecorePolicyIndex` 转成 suite tags、failure modes、success criteria 与 blocking checks,直接暴露 refs、missing inputs、pending hit refs、local default decision、profile / adapter 与 `declared_only / limecore_pending` 输入状态;图片、配音、转写媒体 worker 已在进入真实执行器前做最小 adapter preflight。当前默认 `policy_value_hits=[]`、`policy_value_hit_count=0` 只表示真实 LimeCore 控制面命中值尚未接入;如果已有 `status=resolved` 的命中值,resolver seam 会把该 ref 转入 `evaluated_refs` 并从 `missing_inputs / pending_hit_refs` 移除。图片任务执行前的本地 model registry assessment 已成为最小 `model_catalog` hit producer,会写入 `policy_value_hits(status=resolved, value_source=local_model_catalog)`;图片任务进入真实执行器前也会从已解析的 runner config/API key 与 task payload provider/model 生成最小 `provider_offer` hit,写入 `value_source=local_provider_offer`,且不序列化 API key;Browser Assist 与 Web Research 类 launch 现在也会从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hit,写入 `value_source=request_oem_routing`,只解释 tenant/provider/quota/can*invoke/fallback 等路由输入已命中;Workspace send metadata 还会从 OEM Cloud bootstrap snapshot 的 `features` 生成 `tenant_feature_flags` hit,写入 `value_source=oem_cloud_bootstrap_features`,只解释租户功能开关输入已命中且不包含 session token。当前 snapshot 还会携带 `policy_evaluation`:所有 refs resolved 时,最小 `policy_input_evaluator` 才会把已命中的 policy inputs 折叠为 `allow / ask / deny`;仍有 missing inputs 时,顶层 `decision` 继续保持 `local_default_policy / local_defaults_only`,不能解释为真实 tenant / provider / gateway 放行。`thread_read.runtime_summary.limecorePolicy` 已能投影最近一次 runtime contract 的 policy decision explanation,包含顶层 decision、missing/pending refs、hit count 与 evaluator blocking/ask/pending refs;统一媒体任务索引也已汇总 `limecore_policy_evaluation_statuses / decisions / decision_sources / blocking_refs / ask_refs / pending_refs`,每条 snapshot 同步输出 `limecore_policy_evaluation*\*`字段,让任务列表和恢复层无需打开隐藏 task JSON 就能区分 input gap、ask 与 deny;配音与转写任务卡恢复层已消费这些字段并展示`LimeCore 策略输入待命中 / 阻断 / 需确认`meta,图片任务 viewer 与图片消息轻卡也会从 task artifact runtime contract 的`policy_evaluation`展示同一类标签。后续云端 LimeCore policy decision、Browser / 通用 Skill preflight 与更多任务卡可视化继续消费同一事实源,不另开上层`@` 命令事实源。 +当前 `execution_profile` / `executor_adapter` 已从治理 registry 进入前端 launch metadata、Rust runtime contract snapshot、Evidence Pack、Replay 与 `list_media_task_artifacts` 统一媒体任务索引。任务列表可以直接查询 `entry_key`、`thread_id`、`turn_id`、`content_id`、`modality`、`skill_id`、`model_id`、`cost_state`、`limit_state`、`estimated_cost_class`、`limit_event_kind`、`quota_low`、`profile_key`、`adapter_key`、`executor_kind`、`executor_binding_key`、`limecore_policy_refs` 与最小 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count, policy_evaluation)`;其中 `entry_key / thread_id / turn_id / content_id / modality / skill_id / model_id / cost_state / limit_state` 先由媒体任务 payload、runtime contract、runtime summary 与 task profile 归一投影而来,不改变上层 `@` 命令触发语义;Evidence Pack 的 `snapshotIndex.taskIndex` 已把非媒体 runtime contract snapshot 的同组身份、executor、成本/限额字段也归一到同一查询口径,Replay / grader、前端任务中心查询模型、`HarnessTaskIndexSection` 与内嵌任务中心过滤列表已消费该索引作为复盘、过滤和客服诊断验收项;`task index presentation guard` 会阻止该过滤面回流成面板内联平行实现。Harness evidence 面板已展示 `LimeCore 策略缺口`,Replay / grader 也会把 `limecorePolicyIndex` 转成 suite tags、failure modes、success criteria 与 blocking checks,直接暴露 refs、missing inputs、pending hit refs、local default decision、profile / adapter 与 `declared_only / limecore_pending` 输入状态;图片、配音、转写媒体 worker 已在进入真实执行器前做最小 adapter preflight。当前默认 `policy_value_hits=[]`、`policy_value_hit_count=0` 只表示真实 LimeCore 控制面命中值尚未接入;如果已有 `status=resolved` 的命中值,resolver seam 会把该 ref 转入 `evaluated_refs` 并从 `missing_inputs / pending_hit_refs` 移除。图片任务执行前的本地 model registry assessment 已成为最小 `model_catalog` hit producer,会写入 `policy_value_hits(status=resolved, value_source=local_model_catalog)`;图片任务进入真实执行器前也会从已解析的 runner config/API key 与 task payload provider/model 生成最小 `provider_offer` hit,写入 `value_source=local_provider_offer`,且不序列化 API key;Browser Assist 与 Web Research 类 launch 现在也会从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hit,写入 `value_source=request_oem_routing`,只解释 tenant/provider/quota/can*invoke/fallback 等路由输入已命中;Workspace send metadata 还会从 OEM Cloud bootstrap snapshot 的 `features` 生成 `tenant_feature_flags` hit,写入 `value_source=oem_cloud_bootstrap_features`,只解释租户功能开关输入已命中且不包含 session token。当前 snapshot 还会携带 `policy_evaluation`:所有 refs resolved 时,最小 `policy_input_evaluator` 才会把已命中的 policy inputs 折叠为 `allow / ask / deny`;仍有 missing inputs 时,顶层 `decision` 继续保持 `local_default_policy / local_defaults_only`,不能解释为真实 tenant / provider / gateway 放行。`thread_read.runtime_summary.limecorePolicy` 已能投影最近一次 runtime contract 的 policy decision explanation,包含顶层 decision、missing/pending refs、hit count 与 evaluator blocking/ask/pending refs;统一媒体任务索引也已汇总 `limecore_policy_evaluation_statuses / decisions / decision_sources / blocking_refs / ask_refs / pending_refs`,每条 snapshot 同步输出 `limecore_policy_evaluation*\*`字段,让任务列表和恢复层无需打开隐藏 task JSON 就能区分 input gap、ask 与 deny;配音与转写任务卡恢复层已消费这些字段并展示`LimeCore 策略输入待命中 / 阻断 / 需确认`meta,图片任务 viewer 与图片消息轻卡也会从 task artifact runtime contract 的`policy_evaluation`展示同一类标签。后续云端 LimeCore policy decision、Browser / 通用 Skill preflight、独立主任务中心入口与更多任务卡可视化继续消费同一事实源,不另开上层`@` 命令事实源。权限确认方面,Evidence Pack / Replay 已把 `not_requested / requested` 未解决确认作为交付阻断事实;未解决确认现在也会在 prelude 后、模型执行前阻断 turn;`runtime_permission_confirmation:` 会作为真实 `RequestUserInput/elicitation` 写入 timeline 并通过既有 `agent_runtime_respond_action` 完成/拒绝写回,下一轮恢复请求会把 completed response 合并为 `confirmationStatus=resolved/denied` 后再由同一 turn gating 判定。这个闭环仍是本地最小确认/恢复入口,不等于完整权限系统、自动恢复 GUI 或 LimeCore 云授权。显式用户模型锁定方面,`request_model_resolution` 会继续 honored 用户指定模型,但当该模型缺少当前 `routingSlot` 要求能力时会输出 `user_locked_capability_gap`,runtime turn 会在模型执行前阻断并提示切换模型或取消本轮显式锁定,避免把已知不满足 execution profile 的模型继续执行。 ## 4. 目录文档分工 @@ -177,8 +177,8 @@ Lime 的 artifact graph 不应只有 `document` / `file`。 5. `docs/roadmap/warp/entry-binding-inventory.md` 6. `docs/roadmap/warp/task-index-inventory.md` 7. `src/lib/governance/modalityExecutionProfiles.ts` -6. `scripts/check-modality-runtime-contracts.mjs` -7. `npm run governance:modality-contracts` +8. `scripts/check-modality-runtime-contracts.mjs` +9. `npm run governance:modality-contracts` 后续如果继续推进,再按需新增: diff --git a/docs/roadmap/warp/execution-profile.md b/docs/roadmap/warp/execution-profile.md index 10b092375..7e60ecaea 100644 --- a/docs/roadmap/warp/execution-profile.md +++ b/docs/roadmap/warp/execution-profile.md @@ -20,8 +20,9 @@ 10. Skill tool preflight / metadata seed:`src-tauri/crates/agent/src/tools/skill_tool_gate.rs` 11. ServiceSkill compat guard:`src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs` 12. Provider/model resolution:`src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs` +13. Permission turn gating:`src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` -本文件解释字段语义;JSON registry 是校验输入。当前已建立治理事实源与前端 TS resolver,所有 current contract 的 launch metadata 可以携带 `execution_profile` 与 `executor_adapter` 快照;图片、配音、转写媒体 worker 已在进入真实执行器前消费同一快照做最小 profile / adapter / executor binding preflight;Browser Assist 工具层现在也会在真实浏览器动作前消费 `browser_control` runtime contract,校验 `browser_control_profile`、`browser:browser_assist` 与 `browser:browser_assist` binding,失败时返回带合同 metadata 的 `runtime_preflight` 工具错误;`LimeSkillTool` 现在会把 current Skill 主链映射回底层合同,并从治理 JSON 注入 `modality_runtime_contract`、`execution_profile`、`executor_adapter` 与 `executor_binding` metadata,显式传入冲突合同快照时会以 `runtime_preflight` 工具结果阻断进入 Skill 执行器;旧 `lime_run_service_skill` 仍是 compat guard,命中 `voice_generation` 时会携带并校验 `voice_generation_profile`、`service_skill:voice_runtime` adapter 与 `service_skill:voice_runtime` binding,通过后也只返回本地主链提示,不触发云 run/poll。LimeCore policy 也已形成稳定接线:默认 `pending_hit_refs` 指向待接 refs,`policy_value_hits=[]` 与 `policy_value_hit_count=0` 明确表示真实控制面命中值尚未接入;如果同一 snapshot 已携带 `status=resolved` 的 `policy_value_hits`,resolver seam 会把该 ref 计入 `evaluated_refs`,并从 `missing_inputs / pending_hit_refs` 中移除。图片任务执行前的本地 model registry assessment 已先接成最小 `model_catalog` hit producer;图片任务进入真实执行器前也会从已解析的 runner config/API key 与 task payload provider/model 生成最小 `provider_offer` hit,且不序列化 API key;Browser Assist 与 Web Research 类 launch 现在会从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hit,Workspace send metadata 会从 OEM Cloud bootstrap `features` 生成最小 `tenant_feature_flags` hit,且二者都不携带 token;`policy_evaluation` 会在所有 refs resolved 时用最小本地 `policy_input_evaluator` 折叠 `allow / ask / deny`,仍有 missing inputs 时顶层决策继续保持 local default;thread read 已并列暴露 `runtime_summary.limecorePolicy` 与 `runtime_summary.modalityRuntime`,分别解释最近 policy decision input 与同一合同的 profile / adapter / executor binding 摘要;`SessionExecutionRuntimeTaskProfile` 已开始承载同一合同的 `modalityContractKey`、`routingSlot`、`executionProfileKey`、`executorAdapterKey`、`executorKind`、`executorBindingKey`、`permissionProfileKeys` 与 `userLockPolicy`,供运行期事件、前端 execution runtime 类型与 provider/model resolution 消费;`request_model_resolution` 已把 `routingSlot` 映射成最小 runtime model capability requirements,并用它过滤候选池、fallback 候选与多候选自动重选。显式用户模型锁定仍 honored,但能力不匹配会通过 `capability_gap` 暴露 `*_candidate_missing`。`permissionProfileKeys` 也已进入 `lime_runtime.permission_state` 最小权限摘要,并通过 `AgentRuntimeThreadReadModel.permission_state` 结构化暴露完整 required / ask / blocking profile keys 与 notes;`requires_confirmation` 声明态现在还会进入现有 `runtime_status(phase=permission_review)` 事件流,并以 `declared_only=true` 标明它不是真实授权结果。这不是完整权限系统,不执行真实授权、不生成 pending approval、也不阻断 turn。统一媒体任务索引已经暴露 evaluation status / decision / blocking / ask / pending refs;仍不新增 Tauri command 或上层 `@` 事实源;Gateway executor preflight 等 registry 出现明确 `gateway:*` adapter 后再接入。 +本文件解释字段语义;JSON registry 是校验输入。当前已建立治理事实源与前端 TS resolver,所有 current contract 的 launch metadata 可以携带 `execution_profile` 与 `executor_adapter` 快照;图片、配音、转写媒体 worker 已在进入真实执行器前消费同一快照做最小 profile / adapter / executor binding preflight;Browser Assist 工具层现在也会在真实浏览器动作前消费 `browser_control` runtime contract,校验 `browser_control_profile`、`browser:browser_assist` 与 `browser:browser_assist` binding,失败时返回带合同 metadata 的 `runtime_preflight` 工具错误;`LimeSkillTool` 现在会把 current Skill 主链映射回底层合同,并从治理 JSON 注入 `modality_runtime_contract`、`execution_profile`、`executor_adapter` 与 `executor_binding` metadata,显式传入冲突合同快照时会以 `runtime_preflight` 工具结果阻断进入 Skill 执行器;旧 `lime_run_service_skill` 仍是 compat guard,命中 `voice_generation` 时会携带并校验 `voice_generation_profile`、`service_skill:voice_runtime` adapter 与 `service_skill:voice_runtime` binding,通过后也只返回本地主链提示,不触发云 run/poll。LimeCore policy 也已形成稳定接线:默认 `pending_hit_refs` 指向待接 refs,`policy_value_hits=[]` 与 `policy_value_hit_count=0` 明确表示真实控制面命中值尚未接入;如果同一 snapshot 已携带 `status=resolved` 的 `policy_value_hits`,resolver seam 会把该 ref 计入 `evaluated_refs`,并从 `missing_inputs / pending_hit_refs` 中移除。图片任务执行前的本地 model registry assessment 已先接成最小 `model_catalog` hit producer;图片任务进入真实执行器前也会从已解析的 runner config/API key 与 task payload provider/model 生成最小 `provider_offer` hit,且不序列化 API key;Browser Assist 与 Web Research 类 launch 现在会从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hit,Workspace send metadata 会从 OEM Cloud bootstrap `features` 生成最小 `tenant_feature_flags` hit,且二者都不携带 token;`policy_evaluation` 会在所有 refs resolved 时用最小本地 `policy_input_evaluator` 折叠 `allow / ask / deny`,仍有 missing inputs 时顶层决策继续保持 local default;thread read 已并列暴露 `runtime_summary.limecorePolicy` 与 `runtime_summary.modalityRuntime`,分别解释最近 policy decision input 与同一合同的 profile / adapter / executor binding 摘要;`SessionExecutionRuntimeTaskProfile` 已开始承载同一合同的 `modalityContractKey`、`routingSlot`、`executionProfileKey`、`executorAdapterKey`、`executorKind`、`executorBindingKey`、`permissionProfileKeys` 与 `userLockPolicy`,供运行期事件、前端 execution runtime 类型与 provider/model resolution 消费;`request_model_resolution` 已把 `routingSlot` 映射成最小 runtime model capability requirements,并用它过滤候选池、fallback 候选与多候选自动重选。显式用户模型锁定仍 honored,但能力不匹配会通过 `capability_gap` 暴露 `*_candidate_missing`;当 gap 来源为显式用户模型锁定时,`limit_state.status=user_locked_capability_gap` 会进入 runtime status,并在模型执行前阻断。`permissionProfileKeys` 也已进入 `lime_runtime.permission_state` 最小权限摘要,并通过 `AgentRuntimeThreadReadModel.permission_state` 结构化暴露完整 required / ask / blocking profile keys 与 notes;`requires_confirmation` 声明态现在还会进入现有 `runtime_status(phase=permission_review)` 事件流,并以 `declared_only=true` 标明它不是真实授权结果。这不是完整权限系统,也不伪造 `ApprovalRequest`;未解决确认会在 prelude 状态发出后、模型执行前阻断 turn,Evidence Pack / Replay 也会把 `not_requested / requested` 这类未解决确认标为交付阻断事实,不能再把声明态需确认权限当成成功证据。当 `confirmationStatus=not_requested` 且没有 request id 时,runtime 会写入真实 `runtime_permission_confirmation:` / `RequestUserInput(elicitation)` action_required;用户响应复用既有 `agent_runtime_respond_action` 写回 completed response,下一轮恢复请求再把 response 合并成 `confirmationStatus=resolved/denied` 并交回同一 turn gating。统一媒体任务索引已经暴露 evaluation status / decision / blocking / ask / pending refs;仍不新增 Tauri command 或上层 `@` 事实源;Gateway executor preflight 等 registry 出现明确 `gateway:*` adapter 后再接入。 ## 2. 固定原则 @@ -151,7 +152,7 @@ sequenceDiagram 后续继续补: -1. Rust / Agent 运行时真实 `ExecutionProfile` merge:thread read 已能从最近 `runtime_contract` 投影 `modalityRuntime` 摘要,`SessionExecutionRuntimeTaskProfile` 也已开始承载 profile / adapter / binding、权限 profile 与用户锁定策略摘要;`routingSlot` 已进入 provider/model resolution 的最小模型能力 enforcement,非显式用户锁定路径会优先选满足 slot 的候选模型,显式用户模型锁定路径会保留锁定模型并输出 capability gap。`permissionProfileKeys` 已进入 `SessionExecutionRuntimePermissionState`,`lime_runtime.permission_state`、`runtime_summary.permissionStatus / permissionAskCount / permissionBlockingCount` 与 `AgentRuntimeThreadReadModel.permission_state` 能解释声明权限、需确认权限和空阻断清单;`requires_confirmation` 已产生 `permission_review` runtime status,供事件流观察声明态权限确认需求。后续还需把该摘要接入真实权限授权、用户锁定 gap 接入确认/阻断执行,并补更完整的 runtime decision explanation。 +1. Rust / Agent 运行时真实 `ExecutionProfile` merge:thread read 已能从最近 `runtime_contract` 投影 `modalityRuntime` 摘要,`SessionExecutionRuntimeTaskProfile` 也已开始承载 profile / adapter / binding、权限 profile 与用户锁定策略摘要;`routingSlot` 已进入 provider/model resolution 的最小模型能力 enforcement,非显式用户锁定路径会优先选满足 slot 的候选模型,显式用户模型锁定路径会保留锁定模型并输出 capability gap。`permissionProfileKeys` 已进入 `SessionExecutionRuntimePermissionState`,`lime_runtime.permission_state`、`runtime_summary.permissionStatus / permissionAskCount / permissionBlockingCount` 与 `AgentRuntimeThreadReadModel.permission_state` 能解释声明权限、需确认权限和空阻断清单;`requires_confirmation` 已产生 `permission_review` runtime status,供事件流观察声明态权限确认需求;Evidence / Replay、Handoff / Analysis 与 Review decision 写回边界已把 `not_requested / requested` 未解决确认判为 `blocked` / blocking check / 交付阻断提示,不再把声明态需确认权限当成功交付证据。真实 turn 阻断、最小 `runtime_permission_confirmation:*` 确认恢复闭环,以及显式用户模型锁定 capability gap 的执行前阻断已接入;后续还需把该摘要接入完整权限授权系统、用户锁定 gap 的确认式恢复,并补更完整的 runtime decision explanation 与 GUI 自动恢复。 2. LimeCore policy snapshot:已把 `limecore_policy_refs` 与最小 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_source=local_default_policy, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count)` 写入 runtime contract、Evidence Pack、Replay / grader 与统一媒体任务索引;当前 `allow` 只代表本地默认策略没有阻断 current 路由。默认 `policy_inputs` 仍标记为 `declared_only / limecore_pending`;当某条 snapshot 仍是 `policy_value_hits=[]` / `policy_value_hit_count=0` 时,只代表这条 snapshot 尚未携带对应控制面命中值。如果已有 `status=resolved` hit,同一 resolver seam 会把对应 input 标为 `resolved`,用 hit 的 `value_source` 解释来源,并自动收缩 `missing_inputs / pending_hit_refs`。当前图片任务已能从本地 model registry assessment 生成 `model_catalog` hit,并在进入真实执行器前从已解析的 runner config/API key 与 payload provider/model 生成 `provider_offer` hit;Browser Assist 与 Web Research 类 launch 已能从 `harness.oem_routing` 生成 `gateway_policy` hit;Workspace send metadata 已能从 OEM Cloud bootstrap `features` 生成 `tenant_feature_flags` hit;最小 `policy_input_evaluator` 已能在所有 refs resolved 时输出 `allow / ask / deny`;thread read 已能通过 `runtime_summary.limecorePolicy` 暴露最近一次 policy decision explanation,统一媒体任务索引也已汇总 evaluation status / decision / source 与 blocking / ask / pending refs;配音/转写任务卡恢复层、图片 viewer 和图片消息轻卡已开始展示 input gap / deny / ask meta,云端 LimeCore evaluator 与更完整 GUI 展示仍待后续接入。 3. GUI / evidence 可视化:Harness evidence 已能展示 `LimeCore 策略缺口`,包括 refs、missing inputs、local default decision、profile / adapter 与 `declared_only / limecore_pending` 输入状态;Replay / grader 已把这些 gap 纳入可复盘验收;配音/转写任务卡恢复层、图片 viewer 与图片消息轻卡已显示 `LimeCore 策略输入待命中 / 阻断 / 需确认` meta,更多任务卡与云端真实 allow / ask / deny 解释继续后置。 4. Executor registry 运行时化:图片、配音、转写媒体 worker 已从同一事实源执行最小 preflight;Browser Assist 工具层已在真实浏览器动作前校验 `browser_control` profile / adapter / binding,并把错误结果作为 `runtime_preflight` 合同阻断写回工具 metadata;`LimeSkillTool` 已覆盖 current Skill 主线的 metadata seed 与显式冲突合同阻断,避免 `pdf_extract`、`web_research`、`text_transform`、`audio_transcription` 只停留在上层 launch prompt;旧 `lime_run_service_skill` 已收成 `voice_generation` compat guard,只校验 `service_skill:voice_runtime` 合同并返回本地主链提示;后续继续扩展到真实 Gateway adapter,并补更完整 allow / ask / deny 解释。 diff --git a/docs/roadmap/warp/implementation-plan.md b/docs/roadmap/warp/implementation-plan.md index 49368750c..b8fb821bd 100644 --- a/docs/roadmap/warp/implementation-plan.md +++ b/docs/roadmap/warp/implementation-plan.md @@ -168,7 +168,7 @@ ## Phase 3:ModalityExecutionProfile -当前落点:见 [execution-profile.md](./execution-profile.md)、`src/lib/governance/modalityExecutionProfiles.json` 与 `src/lib/governance/modalityExecutionProfiles.ts`;最小 profile / executor adapter registry、前端 launch metadata、Rust runtime contract snapshot、Evidence / Replay、统一媒体任务索引快照、图片/配音/转写媒体 worker 的最小 adapter preflight、Browser Assist 真实动作前 preflight、`LimeSkillTool` current Skill 合同 metadata seed / 显式冲突合同阻断、旧 `lime_run_service_skill` 的 `voice_generation` compat guard,以及 LimeCore policy refs/snapshot 种子已落地;`pending_hit_refs` / `policy_value_hits` / `policy_value_hit_count` 已为真实 policy 命中值预留稳定接线,传入 `status=resolved` hit 时会把对应 ref 计入 `evaluated_refs` 并收缩 `missing_inputs / pending_hit_refs`;图片任务执行前已能从本地 model registry assessment 生成 `model_catalog` hit,并在进入真实执行器前从已解析的 runner config/API key 与 payload provider/model 生成 `provider_offer` hit;Browser Assist 与 Web Research 类 launch 已能从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hit,Workspace send metadata 也能从 OEM Cloud bootstrap `features` 生成最小 `tenant_feature_flags` hit;最小 `policy_input_evaluator` 已能在所有 refs resolved 时输出 `allow / ask / deny`,`thread_read.runtime_summary.limecorePolicy` 与统一媒体任务索引也已能投影最近一次 policy decision explanation 和 evaluator blocking / ask / pending refs;`thread_read.runtime_summary.modalityRuntime` 已开始投影同一合同的 profile / adapter / executor binding 摘要,`SessionExecutionRuntimeTaskProfile` 也已开始承载同一合同的 profile / adapter / binding、权限 profile 与用户锁定策略摘要;provider/model resolution 已消费 `TaskProfile.routingSlot` 做最小模型能力 enforcement,候选池、fallback 与自动重选会排除不满足 runtime slot 的模型,显式用户锁定仍 honored 但输出 capability gap;`lime_runtime.permission_state` 已把 `permissionProfileKeys` 推进为最小权限摘要,`runtime_summary` 同步暴露 permission status/ask/blocking count,`AgentRuntimeThreadReadModel.permission_state` 也已结构化暴露完整 required/ask/blocking profile keys 与 notes,`requires_confirmation` 会额外产生 `runtime_status(phase=permission_review, declared_only=true)` 事件,前端协议解析也会保留该 phase 与权限 metadata,不再降级为普通 routing;Evidence Pack 与 Replay runtime facts 现在也导出同一 `permissionState`,Replay 会把声明态需确认权限列为 blocking check,且会把 `confirmationStatus=denied` 判为明确阻断、`resolved` 不再误报为仍需确认;Evidence Pack 的 `permissionState` coverage 会把 `denied` 标为 blocked、把 `resolved` 解释为已通过,`knownGaps` 与 `summary.md` 也会把 denied 权限确认显示为人眼可见的阻断风险,Handoff bundle、Analysis handoff 与 Review decision 也会把 `denied / resolved` 同步进交接摘要、外部分析简报、结构化 context、copy prompt、人工审核记录、前端 API 顶层返回模型、Harness 人工审核卡片与人工审核填写弹窗,并在 `denied` 时由 GUI、Rust save API、前端 API 回归与浏览器 mock 四侧阻止保存 `accepted` 结论,防止审计/回放/交接/审核/API/GUI 读取或写回误判真实授权状态;`permissionState` 已预置 `confirmationStatus / confirmationRequestId / confirmationSource`,当前 profile 声明态显式标为 `not_requested / null / declared_profile_only`,live `permission_review` event 也会携带这组确认状态,且 thread read 会在同一线程存在真实 tool `ApprovalRequest` 时派生 `requested / resolved / denied`、真实 request id 与 `runtime_action_required` 来源;配音/转写任务卡恢复层、图片 viewer 与图片消息轻卡已开始消费这些 refs 生成 policy evaluation meta;真实 `gateway:*` adapter preflight、权限 enforcement、用户确认/阻断执行、云端 policy evaluator 与更完整 GUI 可视化仍待继续。 +当前落点:见 [execution-profile.md](./execution-profile.md)、`src/lib/governance/modalityExecutionProfiles.json` 与 `src/lib/governance/modalityExecutionProfiles.ts`;最小 profile / executor adapter registry、前端 launch metadata、Rust runtime contract snapshot、Evidence / Replay、统一媒体任务索引快照、图片/配音/转写媒体 worker 的最小 adapter preflight、Browser Assist 真实动作前 preflight、`LimeSkillTool` current Skill 合同 metadata seed / 显式冲突合同阻断、旧 `lime_run_service_skill` 的 `voice_generation` compat guard,以及 LimeCore policy refs/snapshot 种子已落地;`pending_hit_refs` / `policy_value_hits` / `policy_value_hit_count` 已为真实 policy 命中值预留稳定接线,传入 `status=resolved` hit 时会把对应 ref 计入 `evaluated_refs` 并收缩 `missing_inputs / pending_hit_refs`;图片任务执行前已能从本地 model registry assessment 生成 `model_catalog` hit,并在进入真实执行器前从已解析的 runner config/API key 与 payload provider/model 生成 `provider_offer` hit;Browser Assist 与 Web Research 类 launch 已能从请求侧 `harness.oem_routing` 生成最小 `gateway_policy` hit,Workspace send metadata 也能从 OEM Cloud bootstrap `features` 生成最小 `tenant_feature_flags` hit;最小 `policy_input_evaluator` 已能在所有 refs resolved 时输出 `allow / ask / deny`,`thread_read.runtime_summary.limecorePolicy` 与统一媒体任务索引也已能投影最近一次 policy decision explanation 和 evaluator blocking / ask / pending refs;`thread_read.runtime_summary.modalityRuntime` 已开始投影同一合同的 profile / adapter / executor binding 摘要,`SessionExecutionRuntimeTaskProfile` 也已开始承载同一合同的 profile / adapter / binding、权限 profile 与用户锁定策略摘要;provider/model resolution 已消费 `TaskProfile.routingSlot` 做最小模型能力 enforcement,候选池、fallback 与自动重选会排除不满足 runtime slot 的模型,显式用户锁定仍 honored 但输出 capability gap,且 `explicit_model_lock` gap 会被标记为 `user_locked_capability_gap` 并在模型执行前阻断;`lime_runtime.permission_state` 已把 `permissionProfileKeys` 推进为最小权限摘要,`runtime_summary` 同步暴露 permission status/ask/blocking count,`AgentRuntimeThreadReadModel.permission_state` 也已结构化暴露完整 required/ask/blocking profile keys 与 notes,`requires_confirmation` 会额外产生 `runtime_status(phase=permission_review, declared_only=true)` 事件,前端协议解析也会保留该 phase 与权限 metadata,不再降级为普通 routing;Evidence Pack 与 Replay runtime facts 现在也导出同一 `permissionState`,Replay 会把声明态需确认权限列为 blocking check,且会把 `confirmationStatus=denied` 判为明确阻断、`resolved` 不再误报为仍需确认;Evidence Pack 的 `permissionState` coverage 会把 `denied`、`not_requested` 与 `requested` 标为 blocked、把 `resolved` 解释为已通过,`knownGaps` 与 `summary.md` 也会把 denied 或未解决权限确认显示为人眼可见的交付阻断风险,Handoff bundle、Analysis handoff 与 Review decision 也会把 `denied / resolved` 同步进交接摘要、外部分析简报、结构化 context、copy prompt、人工审核记录、前端 API 顶层返回模型、Harness 人工审核卡片与人工审核填写弹窗,并在 `denied / not_requested / requested` 未解决确认时由 GUI、Rust save API、前端 API 回归与浏览器 mock 四侧阻止保存 `accepted` 结论,防止审计/回放/交接/审核/API/GUI 读取或写回误判真实授权状态;`permissionState` 已预置 `confirmationStatus / confirmationRequestId / confirmationSource`,当前 profile 声明态显式标为 `not_requested / null / declared_profile_only`,live `permission_review` event 也会携带这组确认状态,且 thread read 会在同一线程存在真实 tool `ApprovalRequest` 时派生 `requested / resolved / denied`、真实 request id 与 `runtime_action_required` 来源;runtime turn 现在会在 prelude 后、模型执行前阻断未 resolved 的 `requires_confirmation`,并把 turn 标为 failed,不伪造 `ApprovalRequest`;最小用户确认恢复也已接入:`runtime_permission_confirmation:*` 会写入真实 `RequestUserInput/elicitation`,复用 `agent_runtime_respond_action` 完成/拒绝写回,下一轮恢复请求会把 completed response 合并成 `resolved/denied` 后再通过同一 turn gating;配音/转写任务卡恢复层、图片 viewer 与图片消息轻卡已开始消费这些 refs 生成 policy evaluation meta;真实 `gateway:*` adapter preflight、同 turn 自动恢复/完整权限 GUI、用户锁定 gap 确认式恢复、云端 policy evaluator 与更完整可视化仍待继续。 ### 目标 @@ -264,7 +264,7 @@ ## Phase 5:Executor Adapter 与 Browser typed action -当前落点:见 [execution-profile.md](./execution-profile.md) 与 `src/lib/governance/modalityExecutionProfiles.json` 的 `executor_adapters`;最小 adapter registry 已覆盖 `image_generation`、`browser_control`、`pdf_extract`、`voice_generation`、`audio_transcription`、`web_research`、`text_transform`,前端 runtime contract snapshot、Rust runtime contract snapshot、Evidence / Replay 与统一媒体任务索引已携带 `executor_adapter` 与最小 LimeCore policy snapshot,其中默认 `policy_value_hits=[]` / `policy_value_hit_count=0` 明确不伪造真实 policy 命中,传入 `status=resolved` hit 时只解释“该控制面输入已命中”;图片/配音/转写媒体 worker 已消费同一事实源做执行前检查,图片 worker 还会在真实执行器前写回最小 `provider_offer` hit;Browser Assist 工具层也已在真实浏览器动作前消费同一 `browser_control` runtime contract 校验 profile / adapter / binding,失败时返回 `runtime_preflight` 工具错误并保留合同 metadata;通用 Skill 主链已由 `LimeSkillTool` 注入底层合同 metadata 并阻断显式冲突合同;旧 `lime_run_service_skill` 对 `voice_generation` 只做 `service_skill:voice_runtime` compat guard,不恢复云 run/poll;thread read 已能通过 `runtime_summary.modalityRuntime` 暴露最近合同的 profile / adapter / binding 摘要,`lime_runtime.task_profile` 也会携带 `executionProfileKey`、`executorAdapterKey`、`executorKind`、`executorBindingKey`、`routingSlot`、`permissionProfileKeys` 与 `userLockPolicy` 供运行期事件和 provider/model resolution 消费;`routingSlot` 现在会映射为 runtime model capability requirements,影响候选池计数、catalog fallback、自动重选与 `capability_gap`;`lime_runtime.permission_state` 会从同一 `permissionProfileKeys` 生成最小权限摘要,只记录声明权限、需确认权限与空阻断清单,并通过 thread read 结构化读取面暴露完整 keys 与 notes,且在需确认时发出 declared-only `permission_review` runtime status;前端 runtime event contract 已接受该 phase 并保留权限 metadata。Browser Assist 与 Web Research 类 launch 也会从 `harness.oem_routing` 写回最小 `gateway_policy` hit,Workspace send metadata 会从 OEM Cloud bootstrap `features` 写回最小 `tenant_feature_flags` hit,所有 refs resolved 时会由最小 `policy_input_evaluator` 折叠为 `allow / ask / deny`,并通过 thread read 与统一媒体任务索引暴露 evaluator explanation;真实 `gateway:*` adapter preflight 仍待继续,不能把通道 ingress 当成 executor adapter。 +当前落点:见 [execution-profile.md](./execution-profile.md) 与 `src/lib/governance/modalityExecutionProfiles.json` 的 `executor_adapters`;最小 adapter registry 已覆盖 `image_generation`、`browser_control`、`pdf_extract`、`voice_generation`、`audio_transcription`、`web_research`、`text_transform`,前端 runtime contract snapshot、Rust runtime contract snapshot、Evidence / Replay 与统一媒体任务索引已携带 `executor_adapter` 与最小 LimeCore policy snapshot,其中默认 `policy_value_hits=[]` / `policy_value_hit_count=0` 明确不伪造真实 policy 命中,传入 `status=resolved` hit 时只解释“该控制面输入已命中”;图片/配音/转写媒体 worker 已消费同一事实源做执行前检查,图片 worker 还会在真实执行器前写回最小 `provider_offer` hit;Browser Assist 工具层也已在真实浏览器动作前消费同一 `browser_control` runtime contract 校验 profile / adapter / binding,失败时返回 `runtime_preflight` 工具错误并保留合同 metadata;通用 Skill 主链已由 `LimeSkillTool` 注入底层合同 metadata 并阻断显式冲突合同;旧 `lime_run_service_skill` 对 `voice_generation` 只做 `service_skill:voice_runtime` compat guard,不恢复云 run/poll;thread read 已能通过 `runtime_summary.modalityRuntime` 暴露最近合同的 profile / adapter / binding 摘要,`lime_runtime.task_profile` 也会携带 `executionProfileKey`、`executorAdapterKey`、`executorKind`、`executorBindingKey`、`routingSlot`、`permissionProfileKeys` 与 `userLockPolicy` 供运行期事件和 provider/model resolution 消费;`routingSlot` 现在会映射为 runtime model capability requirements,影响候选池计数、catalog fallback、自动重选与 `capability_gap`;`lime_runtime.permission_state` 会从同一 `permissionProfileKeys` 生成最小权限摘要,只记录声明权限、需确认权限与空阻断清单,并通过 thread read 结构化读取面暴露完整 keys 与 notes,且在需确认时发出 declared-only `permission_review` runtime status;前端 runtime event contract 已接受该 phase 并保留权限 metadata,Evidence / Replay 已把未解决确认作为交付阻断事实。Browser Assist 与 Web Research 类 launch 也会从 `harness.oem_routing` 写回最小 `gateway_policy` hit,Workspace send metadata 会从 OEM Cloud bootstrap `features` 写回最小 `tenant_feature_flags` hit,所有 refs resolved 时会由最小 `policy_input_evaluator` 折叠为 `allow / ask / deny`,并通过 thread read 与统一媒体任务索引暴露 evaluator explanation;真实 `gateway:*` adapter preflight 仍待继续,不能把通道 ingress 当成 executor adapter。 ### 目标 @@ -306,7 +306,7 @@ Browser Assist 必须收成: ## Phase 6:LimeCore 目录与策略接线 -当前落点:central runtime contract helper、前端 runtime contract resolver、Evidence Pack 与 `list_media_task_artifacts` 已携带 `limecore_policy_refs` 和最小本地默认 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_source=local_default_policy, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count)`;这个 `allow` 只表示本地默认策略没有阻断 current 路由。默认 `policy_inputs` 是 `declared_only / limecore_pending` 输入清单,`pending_hit_refs` 指向等待真实值的 refs,`policy_value_hits=[]` / `policy_value_hit_count=0` 明确不伪造真实 tenant / provider / gateway 放行;如果已有 `status=resolved` 命中值,同一 seam 会把对应 ref 写入 `evaluated_refs`,将 input 标为 `resolved` 并使用 hit 的 `value_source`。当前已先把图片任务的本地 model registry assessment 接成 `model_catalog` hit producer,把已解析 runner config/API key 与 payload provider/model 接成最小 `provider_offer` hit producer,把请求侧 `harness.oem_routing` 接成 Browser Assist / Web Research 的最小 `gateway_policy` hit producer,并把 OEM Cloud bootstrap snapshot `features` 接成请求侧 `tenant_feature_flags` hit producer;`policy_evaluation` 会在所有 refs resolved 时用最小本地 `policy_input_evaluator` 折叠 `allow / ask / deny`,`thread_read.runtime_summary.limecorePolicy` 会把最近一次 runtime contract 的 policy decision explanation 投影给上层读取,`thread_read.runtime_summary.modalityRuntime` 会把同一合同的 profile / adapter / executor binding 摘要投影给上层读取,`lime_runtime.task_profile` 已开始携带同一合同的权限 profile、用户锁定策略摘要与 `routingSlot`,provider/model resolution 已用这些字段做最小模型能力 enforcement,`lime_runtime.permission_state` 已把权限 profile 声明推进成 runtime 可读摘要,并由 `AgentRuntimeThreadReadModel.permission_state`、Evidence Pack 与 Replay runtime facts 结构化暴露;当声明态需要确认时,runtime 事件流也会暴露 `permission_review` 状态,`confirmationStatus=not_requested` 明确说明尚未生成真实审批请求;当同一 thread read 中已经存在真实 tool `ApprovalRequest` 时,读取模型会把 confirmation 派生为 `requested / resolved / denied` 并记录真实 request id;`list_media_task_artifacts.modality_runtime_contracts` 也会汇总 evaluation status / decision / decision source 与 blocking / ask / pending refs;配音/转写任务卡恢复层已把 input gap / deny / ask 投影为轻卡 meta,图片 viewer 与图片消息轻卡也会从 task artifact runtime contract 展示同一 policy evaluation meta。这些 hit、evaluator、thread read 摘要、TaskProfile 摘要、权限摘要、模型能力 gap、任务索引 explanation 与任务卡/viewer meta 都只解释已命中的控制面输入,不代表 LimeCore 云 run/poll 或云默认执行。 +当前落点:central runtime contract helper、前端 runtime contract resolver、Evidence Pack 与 `list_media_task_artifacts` 已携带 `limecore_policy_refs` 和最小本地默认 `limecore_policy_snapshot(status=local_defaults_evaluated, decision=allow, decision_source=local_default_policy, decision_scope=local_defaults_only, policy_inputs, missing_inputs, pending_hit_refs, policy_value_hits, policy_value_hit_count)`;这个 `allow` 只表示本地默认策略没有阻断 current 路由。默认 `policy_inputs` 是 `declared_only / limecore_pending` 输入清单,`pending_hit_refs` 指向等待真实值的 refs,`policy_value_hits=[]` / `policy_value_hit_count=0` 明确不伪造真实 tenant / provider / gateway 放行;如果已有 `status=resolved` 命中值,同一 seam 会把对应 ref 写入 `evaluated_refs`,将 input 标为 `resolved` 并使用 hit 的 `value_source`。当前已先把图片任务的本地 model registry assessment 接成 `model_catalog` hit producer,把已解析 runner config/API key 与 payload provider/model 接成最小 `provider_offer` hit producer,把请求侧 `harness.oem_routing` 接成 Browser Assist / Web Research 的最小 `gateway_policy` hit producer,并把 OEM Cloud bootstrap snapshot `features` 接成请求侧 `tenant_feature_flags` hit producer;`policy_evaluation` 会在所有 refs resolved 时用最小本地 `policy_input_evaluator` 折叠 `allow / ask / deny`,`thread_read.runtime_summary.limecorePolicy` 会把最近一次 runtime contract 的 policy decision explanation 投影给上层读取,`thread_read.runtime_summary.modalityRuntime` 会把同一合同的 profile / adapter / executor binding 摘要投影给上层读取,`lime_runtime.task_profile` 已开始携带同一合同的权限 profile、用户锁定策略摘要与 `routingSlot`,provider/model resolution 已用这些字段做最小模型能力 enforcement,`lime_runtime.permission_state` 已把权限 profile 声明推进成 runtime 可读摘要,并由 `AgentRuntimeThreadReadModel.permission_state`、Evidence Pack 与 Replay runtime facts 结构化暴露;当声明态需要确认时,runtime 事件流也会暴露 `permission_review` 状态,`confirmationStatus=not_requested` 明确说明尚未生成真实审批请求,且未 resolved 的 `requires_confirmation` 会在 prelude 后、模型执行前阻断 turn;Evidence / Replay 会把 `not_requested / requested` 未解决确认作为交付阻断事实;当同一 thread read 中已经存在真实 tool `ApprovalRequest` 时,读取模型会把 confirmation 派生为 `requested / resolved / denied` 并记录真实 request id;`list_media_task_artifacts.modality_runtime_contracts` 也会汇总 evaluation status / decision / decision source 与 blocking / ask / pending refs;配音/转写任务卡恢复层已把 input gap / deny / ask 投影为轻卡 meta,图片 viewer 与图片消息轻卡也会从 task artifact runtime contract 展示同一 policy evaluation meta。这些 hit、evaluator、thread read 摘要、TaskProfile 摘要、权限摘要、模型能力 gap、任务索引 explanation 与任务卡/viewer meta 都只解释已命中的控制面输入,不代表 LimeCore 云 run/poll 或云默认执行。 ### 目标 @@ -382,7 +382,7 @@ Browser Assist 必须收成: ## Phase 8:任务索引与复盘 -当前落点:见 [task-index-inventory.md](./task-index-inventory.md) 与 `src/lib/governance/modalityArtifactGraph.json` 的 `task_index_fields`;机器守卫已要求所有 current / partial artifact kind 至少携带 `task_id / contract_key / artifact_kind / status / created_at / updated_at`,且索引字段不得重复。`thread_id / turn_id / content_id / entry_key / model_id / executor_kind / cost_state / limit_state / limecore_policy_snapshot` 仍按 inventory 标为未完整稳定化,不能伪装成全量完成。 +当前落点:见 [task-index-inventory.md](./task-index-inventory.md) 与 `src/lib/governance/modalityArtifactGraph.json` 的 `task_index_fields`;机器守卫已要求所有 current / partial artifact kind 至少携带 `task_id / contract_key / artifact_kind / status / created_at / updated_at`,且索引字段不得重复。媒体任务类 artifact 进一步要求 `entry_key / thread_id / turn_id / content_id / modality / skill_id / model_id / cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low / executor_kind / executor_binding_key / limecore_policy_snapshot_status`,`list_media_task_artifacts.modality_runtime_contracts` 已把 payload 中的 `entry_key / entry_source` 投影为 snapshot `entry_key` 与聚合 `entry_keys`,把 `thread_id / turn_id / content_id` 投影为 snapshot 身份锚点与聚合 `thread_ids / turn_ids / content_ids`,把 `modality / skill_id / model_id` 投影为 snapshot 查询字段与聚合 `modalities / skill_ids / model_ids`,把 `cost_state / limit_state`、`runtime_summary` 与 `task_profile` 中已有摘要投影为 `cost_states / limit_states / estimated_cost_classes / limit_event_kinds / quota_low_count`,并把 runtime contract executor binding 投影为聚合 `executor_kinds / executor_binding_keys`,用于按入口、运行身份、模态、技能、模型、成本/限额和执行器查询图片、音频与转写任务;Evidence Pack 也已新增 `modalityRuntimeContracts.snapshotIndex.taskIndex`,把 Browser / PDF / Web Research / Text Transform / Voice Service 等非媒体 runtime contract snapshot 的同组身份锚点、executor、成本/限额摘要归一到同一索引对象;Replay / grader 已消费该索引,`runtimeFacts.modalityTaskIndex`、suite tags、success criteria、blocking checks 与 `grader.md` 都会要求保留同一身份、executor 与 cost/limit 摘要;前端 `src/lib/agentRuntime/modalityTaskIndexPresentation.ts` 已把该索引转换为任务中心可复用的 facets / rows / filters,`HarnessTaskIndexSection` 消费同一查询模型展示“多模态任务索引”和内嵌“任务中心过滤列表”,按 entry / content / executor / cost / limit 过滤同一 rows;`HarnessStatusPanel` 只保留挂载面,`check-modality-runtime-contracts.mjs` 已用 `task index presentation guard` 固定 helper / section / panel 的 current 边界。policy snapshot 只稳定 status/decision 摘要,不复制完整云策略对象。后续如新增独立主任务中心入口,只能复用这套 rows,不能再建平行索引。 ### 目标 @@ -404,12 +404,15 @@ Browser Assist 必须收成: 12. `status` 13. `cost_state` 14. `limit_state` -15. `limecore_policy_snapshot` -16. `created_at / updated_at` +15. `estimated_cost_class` +16. `limit_event_kind` +17. `quota_low` +18. `limecore_policy_snapshot` +19. `created_at / updated_at` ### 验收 -1. 能按 modality、contract、entry、executor、artifact kind 过滤任务。 +1. 能按 modality、contract、entry、executor、cost/limit、artifact kind 过滤任务。 2. 能从 artifact 回到原 turn。 3. 能从失败任务看到模型路由、权限、LimeCore policy 和 evidence。 4. 复盘能消费同一索引,不另建事实源。 diff --git a/docs/roadmap/warp/task-index-inventory.md b/docs/roadmap/warp/task-index-inventory.md index 06075a81b..a38211210 100644 --- a/docs/roadmap/warp/task-index-inventory.md +++ b/docs/roadmap/warp/task-index-inventory.md @@ -24,41 +24,40 @@ 同时,`task_index_fields` 不允许重复字段。这个守卫保证 task index 至少能按任务、合同、产物类型、状态和时间恢复,不会退回只能打开隐藏 JSON 的人工排查。 +媒体任务 Phase 8 索引守卫:`image_task / image_output / audio_task / audio_output / transcript` 必须声明 `entry_key / thread_id / turn_id / content_id / modality / skill_id / model_id / cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low / executor_kind / executor_binding_key / limecore_policy_snapshot_status`。运行期 `list_media_task_artifacts.modality_runtime_contracts` 必须把 payload 中的 `entry_key / entry_source` 投影为稳定 `entry_key` 与聚合 `entry_keys`,把 `thread_id / turn_id / content_id` 投影为 snapshot 身份锚点与聚合 `thread_ids / turn_ids / content_ids`,把 `modality / skill_id / model_id` 投影为 snapshot 查询字段与聚合 `modalities / skill_ids / model_ids`,把 `cost_state / limit_state`、`runtime_summary` 与 `task_profile` 中已有的成本/限额摘要投影为 `cost_states / limit_states / estimated_cost_classes / limit_event_kinds / quota_low_count`,并把 executor binding 投影为聚合 `executor_kinds / executor_binding_keys`。这一步只稳定查询字段,不改变上层 `@` 命令触发语义,也不把本地默认 policy snapshot 伪造成云端 LimeCore 决策。 + ## 3. 当前索引覆盖 -| Artifact kind | 状态 | Contract | 核心索引 | 领域扩展 | -| --- | --- | --- | --- | --- | -| `image_task` | current | `image_generation` | 已覆盖 | 图片任务 payload / runtime contract | -| `image_output` | current | `image_generation` | 已覆盖 | 图片输出关联 | -| `audio_task` | current | `voice_generation` | 已覆盖 | `audio_output_status` | -| `audio_output` | current | `voice_generation` | 已覆盖 | `audio_output_status / audio_output_path` | -| `transcript` | partial | `audio_transcription` | 已覆盖 | `transcript_*` source / language / error code | -| `browser_session` | partial | `browser_control` | 已覆盖 | `browser_session_id / action_count / last_url` | -| `browser_snapshot` | partial | `browser_control` | 已覆盖 | `observation_count / screenshot_count / last_url` | -| `pdf_extract` | partial | `pdf_extract` | 已覆盖 | `source_path` | -| `report_document` | partial | `pdf_extract / web_research / text_transform` | 已覆盖 | `source_count` | -| `presentation_document` | planned | 未进入 current | 已预留 | 后续 presentation contract | -| `webpage_artifact` | planned | `web_research` | 已预留 | `url` | -| `generic_file` | current compat | `text_transform` | 已覆盖 | `path`,只能兜底 | +| Artifact kind | 状态 | Contract | 核心索引 | 领域扩展 | +| ----------------------- | -------------- | --------------------------------------------- | -------- | ------------------------------------------------- | +| `image_task` | current | `image_generation` | 已覆盖 | 图片任务 payload / runtime contract | +| `image_output` | current | `image_generation` | 已覆盖 | 图片输出关联 | +| `audio_task` | current | `voice_generation` | 已覆盖 | `audio_output_status` | +| `audio_output` | current | `voice_generation` | 已覆盖 | `audio_output_status / audio_output_path` | +| `transcript` | partial | `audio_transcription` | 已覆盖 | `transcript_*` source / language / error code | +| `browser_session` | partial | `browser_control` | 已覆盖 | `browser_session_id / action_count / last_url` | +| `browser_snapshot` | partial | `browser_control` | 已覆盖 | `observation_count / screenshot_count / last_url` | +| `pdf_extract` | partial | `pdf_extract` | 已覆盖 | `source_path` | +| `report_document` | partial | `pdf_extract / web_research / text_transform` | 已覆盖 | `source_count` | +| `presentation_document` | planned | 未进入 current | 已预留 | 后续 presentation contract | +| `webpage_artifact` | planned | `web_research` | 已预留 | `url` | +| `generic_file` | current compat | `text_transform` | 已覆盖 | `path`,只能兜底 | ## 4. 仍未宣称完成的字段 -Phase 8 目标字段中的以下项当前只在部分链路、metadata 或 Evidence/Replay 中可见,还没有统一成所有 artifact kind 的稳定 task index 字段: +Phase 8 目标字段中,`entry_key / thread_id / turn_id / content_id / modality / skill_id / model_id / cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low / executor_kind / executor_binding_key / limecore_policy_snapshot_status` 已先在媒体任务索引中稳定:`src/lib/governance/modalityArtifactGraph.json` 的媒体 artifact kind 已声明这些字段,Rust snapshot 与浏览器 mock 也会从 task payload / runtime contract / runtime summary / task profile 回填同名查询字段和聚合维度。 -- `thread_id` -- `turn_id` -- `content_id` -- `entry_key` -- `modality` -- `skill_id` -- `model_id` -- `executor_kind` -- `cost_state` -- `limit_state` -- `limecore_policy_snapshot` +Evidence Pack 侧已新增 `modalityRuntimeContracts.snapshotIndex.taskIndex`:Browser / PDF / Web Research / Text Transform / Voice Service 等非媒体 tool trace snapshot 会从 `runtime_contract`、`entry_source`、thread item、metadata、`runtime_summary` 与 `task_profile` 中提取 `thread_id / turn_id / content_id / entry_key / modality / skill_id / model_id / executor_kind / executor_binding_key / cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low`,并用同一索引对象暴露聚合数组与 `items[]`。Replay / grader 已开始消费同一 `taskIndex`:`input.json.runtimeContext.runtimeFacts.modalityTaskIndex` 会输出 compact 摘要,suite tags 会标记 `modality-task-index / modality-task-identity / modality-task-cost-limit`,`expected.json` 与 `grader.md` 会要求保留身份锚点、executor 维度与成本/限额摘要。前端已新增 `src/lib/agentRuntime/modalityTaskIndexPresentation.ts`,把 Evidence `taskIndex` 转成任务中心可复用的 facets / rows / exact filters,`src/components/agent/chat/components/HarnessTaskIndexSection.tsx` 负责消费这层查询模型展示“多模态任务索引”和内嵌“任务中心过滤列表”,按 entry / content / executor / cost / limit 过滤同一 rows;`HarnessStatusPanel` 只负责挂载该 section,不再内联 taskIndex 查询/列表逻辑。`scripts/check-modality-runtime-contracts.mjs` 已补 `task index presentation guard`,检查 helper、section 与 panel 之间的 current 边界,防止后续把任务中心过滤重新写回巨型面板或绕过共享 rows。当前审计 Evidence、Replay、客服诊断、任务中心查询消费层与媒体任务列表使用同一查询口径;这一步不新增命令、不改变上层 `@` 入口,也不把本地默认 LimeCore policy 解释成云端策略结论。 -这些字段不能用文档伪造成已完整覆盖;后续应逐步从现有 `runtime_contract`、`entry_source`、`task_profile`、`limecore_policy_snapshot` 与 Evidence `snapshotIndex` 中回填到统一 task index。 +以下项仍不能宣称全量完成: + +- `thread_id / turn_id / content_id / entry_key`(Evidence、Replay、Harness 诊断与任务中心过滤列表已消费同一 rows;后续只剩把相同 rows 搬到更独立的主任务中心入口) +- `modality / skill_id / model_id / executor_kind / executor_binding_key`(Evidence、Replay、Harness 诊断与任务中心过滤列表已消费同一 rows;后续只剩把相同 rows 搬到更独立的主任务中心入口) +- `cost_state / limit_state / estimated_cost_class / limit_event_kind / quota_low`(Evidence、Replay、Harness 诊断与任务中心过滤列表已消费已有摘要;更多 executor 只允许回填真实 runtime 摘要,不允许造假) +- `limecore_policy_snapshot`(媒体索引已稳定 snapshot status / decision 摘要,完整 snapshot 对象仍不作为查询字段复制) + +这些字段不能用文档伪造成已完整覆盖;后续如果新增独立主任务中心入口,也必须直接消费 `modalityTaskIndexPresentation` 的 rows 与媒体任务索引,而不是另建事实源。`HarnessStatusPanel` 现在不再作为 taskIndex 查询实现点,只保留挂载面。 ## 5. 下一刀建议 -下一步优先把 `entry_source` 归一为可查询 `entry_key`,并把 `executor_kind / executor_binding_key / limecore_policy_snapshot` 从 `list_media_task_artifacts.modality_runtime_contracts.snapshots[]` 提升为稳定 task index 查询维度。 +下一步优先做 Phase 8 收口验收:如果产品上需要独立主任务中心入口,只允许复用 `HarnessTaskIndexSection` / `modalityTaskIndexPresentation` 的 rows;否则应回到权限 enforcement、云端 policy evaluator 或 Gateway adapter 这些尚未完成的主链。 diff --git a/knowledge-ux-current-home.md b/knowledge-ux-current-home.md deleted file mode 100644 index 8cef4dfd7..000000000 --- a/knowledge-ux-current-home.md +++ /dev/null @@ -1,64 +0,0 @@ -- generic [ref=e2]: - - generic [ref=e14]: - - complementary [ref=e15]: - - generic [ref=e16]: - - generic [ref=e17]: - - button "返回 Lime 首页" [ref=e18] [cursor=pointer]: - - img "Lime" [ref=e20] - - generic [ref=e21]: Lime - - button "邀请好友" [ref=e22] [cursor=pointer]: - - img [ref=e23] - - generic [ref=e27]: 邀请好友 - - button "折叠导航栏" [ref=e28] [cursor=pointer]: - - img [ref=e29] - - button "搜索任务" [ref=e32] [cursor=pointer]: - - img [ref=e33] - - generic [ref=e36]: 搜索任务 - - generic [ref=e37]: - - generic [ref=e38]: - - button "新建任务" [ref=e39] [cursor=pointer]: - - img [ref=e40] - - generic [ref=e41]: 新建任务 - - button "我的方法" [ref=e42] [cursor=pointer]: - - img [ref=e43] - - generic [ref=e45]: 我的方法 - - button "灵感库" [ref=e46] [cursor=pointer]: - - img [ref=e47] - - generic [ref=e59]: 灵感库 - - button "知识库" [ref=e60] [cursor=pointer]: - - img [ref=e61] - - generic [ref=e63]: 知识库 - - generic [ref=e64]: - - generic [ref=e65]: - - generic [ref=e66] - - generic [ref=e72] - - button "归档" [ref=e78] [cursor=pointer]: - - img [ref=e79] - - text: 归档 - - generic [ref=e81]: - - button "快速切换外观" [ref=e84] [cursor=pointer]: - - img [ref=e85] - - button "打开用户菜单" [ref=e92] [cursor=pointer]: - - generic [ref=e93]: - - generic [ref=e94]: 开 - - generic [ref=e95]: 开源使用 - - generic [ref=e96]: - - generic [ref=e97]: 本地可用 - - img [ref=e98] - - main [ref=e100]: - - generic [ref=e103]: - - generic [ref=e104]: - - generic [ref=e107]: - - generic [ref=e108] - - button "展开工作区菜单" [ref=e110] [cursor=pointer] - - generic [ref=e115]: - - button "新对话" [ref=e117] [cursor=pointer] - - button "新建对话" [ref=e121] [cursor=pointer] - - generic [ref=e130]: - - generic [ref=e131]: - - generic [ref=e133] - - link "向下滑,看看 Lime 可以帮你做什么": - - /url: "#home-skill-gallery-screen" - - region "Lime 可执行任务示例" [ref=e207]: - - generic [ref=e209] - - region "Notifications alt+T" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index de4bc9c9e..f196af9cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.27.0", + "version": "1.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.27.0", + "version": "1.28.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index c807073c1..f9df06b2a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.27.0", + "version": "1.28.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -79,6 +79,7 @@ "smoke:agent-runtime-tool-surface-page": "node scripts/agent-runtime-tool-surface-page-smoke.mjs", "smoke:agent-service-skill-entry": "node scripts/agent-service-skill-entry-smoke.mjs", "smoke:knowledge-gui": "node scripts/knowledge-gui-smoke.mjs", + "smoke:design-canvas": "node scripts/design-canvas-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/check-command-contracts.mjs b/scripts/check-command-contracts.mjs index d3daafe15..f20720cd4 100644 --- a/scripts/check-command-contracts.mjs +++ b/scripts/check-command-contracts.mjs @@ -361,6 +361,9 @@ function main() { const runtimeGatewayCommands = new Set( agentCommandCatalog.runtimeGatewayCommands ?? [], ); + const capabilityDraftCommands = new Set( + agentCommandCatalog.capabilityDraftCommands ?? [], + ); const deferredCommands = new Set(knownDeferredRegistrationReasons.keys()); @@ -390,6 +393,12 @@ function main() { !registeredCommands.has(command) && !deferredCommands.has(command), ), ); + const capabilityDraftMissingRegistrations = new Set( + [...capabilityDraftCommands].filter( + (command) => + !registeredCommands.has(command) && !deferredCommands.has(command), + ), + ); console.log("[command-contracts] frontend commands:", frontendCommands.size); console.log( @@ -456,6 +465,14 @@ function main() { ); } + if (capabilityDraftMissingRegistrations.size > 0) { + hasError = true; + printCommandGroup( + "capability draft 命令缺少 Rust 注册", + capabilityDraftMissingRegistrations, + ); + } + if (hasError) { process.exitCode = 1; return; diff --git a/scripts/check-modality-runtime-contracts.mjs b/scripts/check-modality-runtime-contracts.mjs index 218aab31b..97d6d7ac1 100755 --- a/scripts/check-modality-runtime-contracts.mjs +++ b/scripts/check-modality-runtime-contracts.mjs @@ -10,6 +10,12 @@ const CAPABILITY_MATRIX_PATH = const ARTIFACT_GRAPH_PATH = "src/lib/governance/modalityArtifactGraph.json"; const EXECUTION_PROFILE_PATH = "src/lib/governance/modalityExecutionProfiles.json"; +const TASK_INDEX_PRESENTATION_PATH = + "src/lib/agentRuntime/modalityTaskIndexPresentation.ts"; +const HARNESS_TASK_INDEX_SECTION_PATH = + "src/components/agent/chat/components/HarnessTaskIndexSection.tsx"; +const HARNESS_STATUS_PANEL_PATH = + "src/components/agent/chat/components/HarnessStatusPanel.tsx"; const REQUIRED_DOCS = [ "docs/roadmap/warp/runtime-fact-map.md", "docs/roadmap/warp/contract-schema.md", @@ -20,6 +26,11 @@ const REQUIRED_DOCS = [ "docs/roadmap/warp/task-index-inventory.md", "docs/roadmap/warp/evolution-guide.md", ]; +const REQUIRED_TASK_INDEX_PRESENTATION_EXPORTS = [ + "buildModalityTaskIndexFacets", + "buildModalityTaskIndexRows", + "filterModalityTaskIndexRows", +]; const LIFECYCLES = new Set(["current", "compat", "deprecated", "dead"]); const MODALITIES = new Set([ @@ -132,6 +143,30 @@ const REQUIRED_ARTIFACT_INDEX_FIELDS = new Set([ "created_at", "updated_at", ]); +const MEDIA_TASK_ARTIFACT_KINDS_REQUIRING_PHASE8_FIELDS = new Set([ + "image_task", + "image_output", + "audio_task", + "audio_output", + "transcript", +]); +const REQUIRED_MEDIA_TASK_PHASE8_INDEX_FIELDS = new Set([ + "entry_key", + "thread_id", + "turn_id", + "content_id", + "modality", + "skill_id", + "model_id", + "cost_state", + "limit_state", + "estimated_cost_class", + "limit_event_kind", + "quota_low", + "executor_kind", + "executor_binding_key", + "limecore_policy_snapshot_status", +]); const ENTRY_KINDS = new Set([ "command", "button_action", @@ -158,7 +193,11 @@ const PHASE7_REQUIRED_ENTRY_BINDINGS = new Map([ function collectUniqueObjects(errors, collection, keyName, label) { const result = new Map(); - pushIf(errors, !isNonEmptyArray(collection), `${label} must be a non-empty array`); + pushIf( + errors, + !isNonEmptyArray(collection), + `${label} must be a non-empty array`, + ); if (!Array.isArray(collection)) { return result; } @@ -179,7 +218,11 @@ function collectUniqueObjects(errors, collection, keyName, label) { if (!isNonEmptyString(key)) { return; } - pushIf(errors, result.has(key), `${prefix}.${keyName} is duplicated: ${key}`); + pushIf( + errors, + result.has(key), + `${prefix}.${keyName} is duplicated: ${key}`, + ); result.set(key, item); }); @@ -209,7 +252,13 @@ function pushIf(errors, condition, message) { } } -function validateEnumArray(errors, contractKey, fieldName, values, allowedValues) { +function validateEnumArray( + errors, + contractKey, + fieldName, + values, + allowedValues, +) { pushIf( errors, !isNonEmptyArray(values), @@ -228,9 +277,17 @@ function validateEnumArray(errors, contractKey, fieldName, values, allowedValues } } -function validateStringArray(errors, contractKey, fieldName, values, options = {}) { +function validateStringArray( + errors, + contractKey, + fieldName, + values, + options = {}, +) { const { allowEmpty = false } = options; - const invalidArray = allowEmpty ? !Array.isArray(values) : !isNonEmptyArray(values); + const invalidArray = allowEmpty + ? !Array.isArray(values) + : !isNonEmptyArray(values); pushIf( errors, invalidArray, @@ -435,7 +492,10 @@ function validatePhase7EntryBindingCoverage(errors, registry) { } } - for (const [contractKey, requiredEntryKeys] of PHASE7_REQUIRED_ENTRY_BINDINGS) { + for (const [ + contractKey, + requiredEntryKeys, + ] of PHASE7_REQUIRED_ENTRY_BINDINGS) { const contract = registry.contracts.find( (candidate) => isPlainObject(candidate) && candidate.contract_key === contractKey, @@ -544,13 +604,9 @@ function validateCapabilityMatrix(matrix) { role.capability_keys, new Set(capabilityMap.keys()), ); - validateStringArray( - errors, - slot, - "fallback_slots", - role.fallback_slots, - { allowEmpty: true }, - ); + validateStringArray(errors, slot, "fallback_slots", role.fallback_slots, { + allowEmpty: true, + }); if (Array.isArray(role.fallback_slots)) { for (const fallbackSlot of role.fallback_slots) { pushIf( @@ -683,6 +739,15 @@ function validateArtifactGraph(graph) { `artifact ${kind}.task_index_fields must include ${requiredField}`, ); } + if (MEDIA_TASK_ARTIFACT_KINDS_REQUIRING_PHASE8_FIELDS.has(kind)) { + for (const requiredField of REQUIRED_MEDIA_TASK_PHASE8_INDEX_FIELDS) { + pushIf( + errors, + !indexFields.has(requiredField), + `artifact ${kind}.task_index_fields must include ${requiredField} for Phase 8 media task index projection`, + ); + } + } } } @@ -709,7 +774,9 @@ function validateContractArtifactGraph(errors, contract, artifactGraphRefs) { for (const artifactKind of contract.artifact_kinds) { const artifact = artifactGraphRefs.artifactMap.get(artifactKind); if (!artifact) { - errors.push(`${contractKey}.artifact_kinds references artifact not defined in graph: ${artifactKind}`); + errors.push( + `${contractKey}.artifact_kinds references artifact not defined in graph: ${artifactKind}`, + ); continue; } @@ -742,7 +809,10 @@ function resolveExecutorAdapterKey(executor) { if (!isPlainObject(executor)) { return null; } - if (!isNonEmptyString(executor.executor_kind) || !isNonEmptyString(executor.binding_key)) { + if ( + !isNonEmptyString(executor.executor_kind) || + !isNonEmptyString(executor.binding_key) + ) { return null; } return `${executor.executor_kind}:${executor.binding_key}`; @@ -760,7 +830,11 @@ function validateSubset(errors, label, actualValues, requiredValues) { } function validateArtifactPolicy(errors, label, policy, artifactGraphRefs) { - pushIf(errors, !isPlainObject(policy), `${label}.artifact_policy must be an object`); + pushIf( + errors, + !isPlainObject(policy), + `${label}.artifact_policy must be an object`, + ); if (!isPlainObject(policy)) { return; } @@ -794,7 +868,12 @@ function validateArtifactPolicy(errors, label, policy, artifactGraphRefs) { ); } -function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraphRefs) { +function validateExecutionProfiles( + registry, + profiles, + matrixRefs, + artifactGraphRefs, +) { const errors = []; pushIf(errors, profiles.version !== 1, "executionProfiles.version must be 1"); pushIf( @@ -946,7 +1025,12 @@ function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraph profile.executor_adapter_keys, new Set(adapterMap.keys()), ); - validateArtifactPolicy(errors, profileKey, profile.artifact_policy, artifactGraphRefs); + validateArtifactPolicy( + errors, + profileKey, + profile.artifact_policy, + artifactGraphRefs, + ); validateEnumArray( errors, profileKey, @@ -972,7 +1056,12 @@ function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraph profile.evidence_events, EVIDENCE_EVENTS, ); - validateStringArray(errors, profileKey, "audit_fields", profile.audit_fields); + validateStringArray( + errors, + profileKey, + "audit_fields", + profile.audit_fields, + ); pushIf( errors, !isNonEmptyString(profile.notes), @@ -1094,9 +1183,21 @@ function validateExecutionProfiles(registry, profiles, matrixRefs, artifactGraph function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) { const errors = []; pushIf(errors, registry.version !== 1, "registry.version must be 1"); - pushIf(errors, registry.status !== "current", "registry.status must be current"); - pushIf(errors, !isNonEmptyString(registry.owner), "registry.owner must be set"); - pushIf(errors, !isNonEmptyArray(registry.contracts), "registry.contracts must be a non-empty array"); + pushIf( + errors, + registry.status !== "current", + "registry.status must be current", + ); + pushIf( + errors, + !isNonEmptyString(registry.owner), + "registry.owner must be set", + ); + pushIf( + errors, + !isNonEmptyArray(registry.contracts), + "registry.contracts must be a non-empty array", + ); if (!Array.isArray(registry.contracts)) { return errors; } @@ -1116,7 +1217,8 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) { ); pushIf( errors, - isNonEmptyString(contract.contract_key) && looksLikeEntryKey(contract.contract_key), + isNonEmptyString(contract.contract_key) && + looksLikeEntryKey(contract.contract_key), `${contractKey}.contract_key must be a bottom-layer key, not an entry key`, ); pushIf( @@ -1136,8 +1238,18 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) { !MODALITIES.has(contract.modality), `${contractKey}.modality is unknown: ${String(contract.modality)}`, ); - validateStringArray(errors, contractKey, "runtime_identity", contract.runtime_identity); - validateStringArray(errors, contractKey, "input_context_kinds", contract.input_context_kinds); + validateStringArray( + errors, + contractKey, + "runtime_identity", + contract.runtime_identity, + ); + validateStringArray( + errors, + contractKey, + "input_context_kinds", + contract.input_context_kinds, + ); validateEnumArray( errors, contractKey, @@ -1145,8 +1257,18 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) { contract.required_capabilities, matrixRefs.capabilityKeys, ); - validateEnumArray(errors, contractKey, "permission_profile_keys", contract.permission_profile_keys, PERMISSIONS); - pushIf(errors, !isNonEmptyString(contract.routing_slot), `${contractKey}.routing_slot must be set`); + validateEnumArray( + errors, + contractKey, + "permission_profile_keys", + contract.permission_profile_keys, + PERMISSIONS, + ); + pushIf( + errors, + !isNonEmptyString(contract.routing_slot), + `${contractKey}.routing_slot must be set`, + ); pushIf( errors, isNonEmptyString(contract.routing_slot) && @@ -1154,19 +1276,72 @@ function validateContractRegistry(registry, matrixRefs, artifactGraphRefs) { `${contractKey}.routing_slot is not defined in capability matrix: ${String(contract.routing_slot)}`, ); validateExecutor(errors, contract); - validateStringArray(errors, contractKey, "truth_source", contract.truth_source); - validateEnumArray(errors, contractKey, "artifact_kinds", contract.artifact_kinds, ARTIFACT_KINDS); - validateEnumArray(errors, contractKey, "viewer_surface", contract.viewer_surface, VIEWER_SURFACES); - validateEnumArray(errors, contractKey, "evidence_events", contract.evidence_events, EVIDENCE_EVENTS); - validateEnumArray(errors, contractKey, "limecore_policy_refs", contract.limecore_policy_refs, LIMECORE_POLICY_REFS); - validateStringArray(errors, contractKey, "fallback_policy", contract.fallback_policy); + validateStringArray( + errors, + contractKey, + "truth_source", + contract.truth_source, + ); + validateEnumArray( + errors, + contractKey, + "artifact_kinds", + contract.artifact_kinds, + ARTIFACT_KINDS, + ); + validateEnumArray( + errors, + contractKey, + "viewer_surface", + contract.viewer_surface, + VIEWER_SURFACES, + ); + validateEnumArray( + errors, + contractKey, + "evidence_events", + contract.evidence_events, + EVIDENCE_EVENTS, + ); + validateEnumArray( + errors, + contractKey, + "limecore_policy_refs", + contract.limecore_policy_refs, + LIMECORE_POLICY_REFS, + ); + validateStringArray( + errors, + contractKey, + "fallback_policy", + contract.fallback_policy, + ); - pushIf(errors, !isPlainObject(contract.detour_policy), `${contractKey}.detour_policy must be an object`); + pushIf( + errors, + !isPlainObject(contract.detour_policy), + `${contractKey}.detour_policy must be an object`, + ); if (isPlainObject(contract.detour_policy)) { - validateStringArray(errors, contractKey, "detour_policy.allowed", contract.detour_policy.allowed, { allowEmpty: true }); - validateStringArray(errors, contractKey, "detour_policy.denied", contract.detour_policy.denied); + validateStringArray( + errors, + contractKey, + "detour_policy.allowed", + contract.detour_policy.allowed, + { allowEmpty: true }, + ); + validateStringArray( + errors, + contractKey, + "detour_policy.denied", + contract.detour_policy.denied, + ); } - pushIf(errors, !isNonEmptyString(contract.owner_surface), `${contractKey}.owner_surface must be set`); + pushIf( + errors, + !isNonEmptyString(contract.owner_surface), + `${contractKey}.owner_surface must be set`, + ); validateEntryBindings(errors, contract); validateContractArtifactGraph(errors, contract, artifactGraphRefs); } @@ -1187,7 +1362,86 @@ function validateRequiredDocs() { }); } -function renderSuccess(registry, matrix, graph, contractReport, profileReport) { +function readRequiredTextFile(errors, filePath) { + const absolutePath = path.resolve(process.cwd(), filePath); + if (!fs.existsSync(absolutePath)) { + errors.push(`required task index source file is missing: ${filePath}`); + return ""; + } + return fs.readFileSync(absolutePath, "utf8"); +} + +function validateTaskIndexPresentationGuard() { + const errors = []; + const presentationSource = readRequiredTextFile( + errors, + TASK_INDEX_PRESENTATION_PATH, + ); + const sectionSource = readRequiredTextFile( + errors, + HARNESS_TASK_INDEX_SECTION_PATH, + ); + const panelSource = readRequiredTextFile(errors, HARNESS_STATUS_PANEL_PATH); + + for (const exportName of REQUIRED_TASK_INDEX_PRESENTATION_EXPORTS) { + pushIf( + errors, + !presentationSource.includes(`export function ${exportName}`), + `${TASK_INDEX_PRESENTATION_PATH} must export ${exportName} as the Phase 8 taskIndex query fact source`, + ); + pushIf( + errors, + !sectionSource.includes(exportName), + `${HARNESS_TASK_INDEX_SECTION_PATH} must consume ${exportName} instead of rebuilding taskIndex UI state`, + ); + } + + pushIf( + errors, + !sectionSource.includes( + 'from "@/lib/agentRuntime/modalityTaskIndexPresentation"', + ), + `${HARNESS_TASK_INDEX_SECTION_PATH} must import the shared taskIndex presentation helpers`, + ); + pushIf( + errors, + !sectionSource.includes("任务中心过滤列表"), + `${HARNESS_TASK_INDEX_SECTION_PATH} must keep the task center filter surface attached to shared taskIndex rows`, + ); + pushIf( + errors, + !panelSource.includes( + 'import { HarnessTaskIndexSection } from "./HarnessTaskIndexSection";', + ) || !panelSource.includes(" contract.lifecycle === "current", ).length; @@ -1200,6 +1454,10 @@ function renderSuccess(registry, matrix, graph, contractReport, profileReport) { ` artifact kinds: ${graph.artifact_kinds.length}`, ` entry bindings: ${contractReport.entryBindingReport.entryBindingCount}`, ` task index core fields: ${REQUIRED_ARTIFACT_INDEX_FIELDS.size}`, + ` media phase8 index fields: ${REQUIRED_MEDIA_TASK_PHASE8_INDEX_FIELDS.size}`, + ` task index presentation guard: ${ + taskIndexPresentationReport.errors.length === 0 ? "current" : "failed" + }`, ` execution profiles: ${profileReport.profileCount}`, ` executor adapters: ${profileReport.adapterCount}`, ` registry: ${CONTRACT_PATH}`, @@ -1227,12 +1485,14 @@ function main() { matrixReport, graphReport, ); + const taskIndexPresentationReport = validateTaskIndexPresentationGuard(); const errors = [ ...validateRequiredDocs(), ...matrixReport.errors, ...graphReport.errors, ...contractReport.errors, ...profileReport.errors, + ...taskIndexPresentationReport.errors, ]; if (errors.length > 0) { @@ -1243,7 +1503,16 @@ function main() { process.exit(1); } - console.log(renderSuccess(registry, matrix, graph, contractReport, profileReport)); + console.log( + renderSuccess( + registry, + matrix, + graph, + contractReport, + profileReport, + taskIndexPresentationReport, + ), + ); } main(); diff --git a/scripts/design-canvas-smoke.mjs b/scripts/design-canvas-smoke.mjs new file mode 100644 index 000000000..74547513c --- /dev/null +++ b/scripts/design-canvas-smoke.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { chromium } from "playwright"; + +const DEFAULTS = { + appUrl: "http://127.0.0.1:1420/", + healthUrl: "http://127.0.0.1:3030/health", + invokeUrl: "http://127.0.0.1:3030/invoke", + timeoutMs: 180_000, + intervalMs: 1_000, +}; + +const ACTION_TIMEOUT_MS = 45_000; +const POST_HEALTH_SETTLE_MS = 1_000; + +function printHelp() { + console.log(` +Lime Design Canvas Smoke + +用途: + 通过真实 Lime 页面验证 canvas:design Artifact 能进入 LayeredDesignDocument + 图层设计画布,并能完成基础图层选择与移动交互。 + +用法: + npm run smoke:design-canvas + +选项: + --app-url 前端地址,默认 http://127.0.0.1:1420/ + --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke + --timeout-ms 总超时,默认 180000 + --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 === "--app-url" && argv[index + 1]) { + options.appUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + + 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 < 30_000) { + throw new Error("--timeout-ms 必须是 >= 30000 的数字"); + } + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + if (!options.appUrl || !options.healthUrl || !options.invokeUrl) { + throw new Error("--app-url、--health-url、--invoke-url 均不能为空"); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function logStage(label) { + console.log(`[smoke:design-canvas] stage=${label}`); +} + +function pickStringField(target, ...keys) { + if (!target || typeof target !== "object") { + return ""; + } + + for (const key of keys) { + const value = target[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return ""; +} + +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:design-canvas] 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:design-canvas] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`, + ); +} + +async function invoke(options, cmd, args) { + const response = await fetch(options.invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + signal: AbortSignal.timeout(Math.min(options.timeoutMs, 180_000)), + }); + + 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 resolveDefaultWorkspace(options) { + const defaultProject = await invoke(options, "get_or_create_default_project"); + assert( + defaultProject && typeof defaultProject === "object", + "get_or_create_default_project 返回为空", + ); + + const projectId = pickStringField(defaultProject, "id"); + assert(projectId, "默认 workspace 缺少 id"); + + const ensuredWorkspace = await invoke(options, "workspace_ensure_ready", { + id: projectId, + }); + const rootPath = + pickStringField(ensuredWorkspace, "rootPath", "root_path") || + pickStringField(defaultProject, "rootPath", "root_path"); + assert(rootPath, "默认 workspace 缺少 rootPath"); + + return { + projectId, + rootPath, + }; +} + +function buildSmokeUrl(options, workspace) { + const url = new URL("/design-canvas-smoke", options.appUrl); + url.searchParams.set("projectRootPath", workspace.rootPath); + url.searchParams.set("projectId", workspace.projectId); + return url.toString(); +} + +async function waitForText(page, label, text) { + try { + await page.getByText(text).first().waitFor({ + state: "visible", + timeout: ACTION_TIMEOUT_MS, + }); + } catch (error) { + const bodyText = await page.locator("body").innerText().catch(() => ""); + throw new Error( + `[smoke:design-canvas] ${label} 等待失败,缺少文本 ${JSON.stringify( + text, + )};页面文本片段: ${JSON.stringify(bodyText.slice(0, 1200))}`, + ); + } +} + +async function runPageFlow(options, smokeUrl) { + const userDataDir = fs.mkdtempSync( + path.join(os.tmpdir(), `lime-design-canvas-smoke-${process.pid}-`), + ); + const launchOptions = { + headless: true, + viewport: { width: 1440, height: 980 }, + }; + let context = null; + + try { + context = await chromium.launchPersistentContext(userDataDir, { + ...launchOptions, + channel: "chrome", + }); + } catch (chromeError) { + console.warn( + `[smoke:design-canvas] Chrome channel 启动失败,尝试 Playwright 自带 Chromium: ${ + chromeError instanceof Error ? chromeError.message : String(chromeError) + }`, + ); + context = await chromium.launchPersistentContext(userDataDir, launchOptions); + } + + const page = context.pages()[0] ?? (await context.newPage()); + const consoleErrors = []; + + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + page.on("pageerror", (error) => { + consoleErrors.push(error.stack || error.message); + }); + + try { + logStage("open-design-canvas-page"); + await page.goto(smokeUrl, { + waitUntil: "domcontentloaded", + timeout: options.timeoutMs, + }); + + logStage("wait-design-canvas"); + await page + .locator('[data-testid="design-canvas-smoke-page"]') + .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + await page + .locator('[data-testid="design-canvas"]') + .waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS }); + + await waitForText(page, "smoke 标题", "canvas:design 专属 GUI Smoke"); + await waitForText(page, "artifact 类型", "canvas:design"); + await waitForText(page, "事实源标记", "LayeredDesignDocument"); + await waitForText(page, "画布标题", "Smoke 图层设计海报"); + await waitForText(page, "图层栏", "图层"); + await waitForText(page, "属性栏", "属性"); + await waitForText(page, "生成入口", "生成全部图片层"); + await waitForText(page, "刷新入口", "刷新生成结果"); + await waitForText(page, "单层重生成入口", "重生成当前层"); + await waitForText(page, "导出入口", "导出设计工程"); + + logStage("interact-layer"); + await page.getByRole("button", { name: "选择图层 主标题" }).click({ + timeout: ACTION_TIMEOUT_MS, + }); + await waitForText(page, "主标题选中", "主标题"); + await page.getByRole("button", { name: "右移", exact: true }).click({ + timeout: ACTION_TIMEOUT_MS, + }); + await page.getByRole("button", { name: "隐藏", exact: true }).click({ + timeout: ACTION_TIMEOUT_MS, + }); + await page.getByRole("button", { name: "显示", exact: true }).click({ + timeout: ACTION_TIMEOUT_MS, + }); + + if (consoleErrors.length > 0) { + throw new Error( + `[smoke:design-canvas] 页面存在 ${consoleErrors.length} 条 console error: ${JSON.stringify( + consoleErrors.slice(0, 5), + )}`, + ); + } + } finally { + await context.close().catch(() => undefined); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +} + +async function main() { + if (typeof fetch !== "function") { + throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); + } + + const options = parseArgs(process.argv.slice(2)); + + logStage("wait-health"); + await waitForHealth(options); + await sleep(POST_HEALTH_SETTLE_MS); + + logStage("resolve-default-workspace"); + const workspace = await resolveDefaultWorkspace(options); + const smokeUrl = buildSmokeUrl(options, workspace); + + await runPageFlow(options, smokeUrl); + + console.log( + `[smoke:design-canvas] 通过 project=${workspace.projectId} root=${workspace.rootPath}`, + ); +} + +main().catch((error) => { + console.error( + error instanceof Error ? error.message : String(error || "unknown error"), + ); + process.exit(1); +}); diff --git a/scripts/knowledge-gui-smoke.mjs b/scripts/knowledge-gui-smoke.mjs index f743654b4..3ba8983a4 100644 --- a/scripts/knowledge-gui-smoke.mjs +++ b/scripts/knowledge-gui-smoke.mjs @@ -49,6 +49,18 @@ const SECONDARY_PACK = { ].join("\n"), }; +const AGENT_RESULT_MESSAGE = { + id: "smoke-agent-result-knowledge", + title: "对话结果资料", + content: [ + "# 对话结果资料", + "", + "- 事实:该结果来自当前 Agent 对话,用于验证生成结果可以沉淀成项目资料。", + "- 适用场景:用户拿到一段可复用结论后,可以一键保存,随后在项目资料管理页检查确认。", + "- 风险提示:沉淀后仍需人工确认,避免把临时分析当成长期事实。", + ].join("\n"), +}; + function printHelp() { console.log(` Lime Knowledge GUI Smoke @@ -299,6 +311,34 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) { } } +async function seedAgentResultForKnowledgeCapture(page, options) { + await page.evaluate( + ({ projectId, message }) => { + const now = new Date().toISOString(); + sessionStorage.setItem( + `aster_messages_${projectId}`, + JSON.stringify([ + { + id: message.id, + role: "assistant", + content: message.content, + timestamp: now, + }, + ]), + ); + sessionStorage.removeItem(`aster_curr_sessionId_${projectId}`); + sessionStorage.removeItem(`aster_last_sessionId_${projectId}`); + sessionStorage.removeItem(`aster_thread_turns_${projectId}`); + sessionStorage.removeItem(`aster_thread_items_${projectId}`); + sessionStorage.removeItem(`aster_curr_turnId_${projectId}`); + }, + { + projectId: options.projectId, + message: AGENT_RESULT_MESSAGE, + }, + ); +} + async function createSmokeProject(options) { const projectName = `Knowledge GUI Smoke ${process.pid}`; const project = await invoke(options, "workspace_create", { @@ -314,6 +354,13 @@ async function createSmokeProject(options) { } options.projectId = projectId; options.projectName = String(project?.name || projectName); + const projectRootPath = String( + project?.rootPath || project?.root_path || "", + ).trim(); + if (projectRootPath) { + options.workingDir = projectRootPath; + fs.mkdirSync(options.workingDir, { recursive: true }); + } } async function cleanupSmokeProject(options) { @@ -394,14 +441,14 @@ async function runPlaywrightGuiFlow(options) { await waitForPageText(page, "首页加载", ["青柠一下,灵感即来"], options.timeoutMs); logStage("open-knowledge-page"); - await clickPageControl(page, { ariaLabel: "知识库" }); + await clickPageControl(page, { ariaLabel: "项目资料" }); logStage("wait-knowledge-overview"); await waitForPageText( page, "知识库总览加载", [ - "项目资料管理", + "项目资料", "日常使用入口", "回到 Agent", "全部资料", @@ -424,7 +471,10 @@ async function runPlaywrightGuiFlow(options) { await waitForPageText( page, "Agent 页面加载", - ["项目资料:未使用", "请基于当前项目资料生成内容"], + [ + `正在使用:${DEFAULT_PACK.title}`, + "请基于当前项目资料生成内容", + ], options.timeoutMs, ); } catch (error) { @@ -438,8 +488,51 @@ async function runPlaywrightGuiFlow(options) { throw error; } + logStage("return-knowledge-before-agent-result"); + await clickPageControl(page, { ariaLabel: "项目资料" }); + + logStage("prepare-agent-result"); + await seedAgentResultForKnowledgeCapture(page, options); + await clickPageControl(page, { text: "用于生成" }); + + logStage("wait-agent-result"); + await waitForPageText( + page, + "Agent 结果样本加载", + [ + `正在使用:${DEFAULT_PACK.title}`, + "沉淀为项目资料", + "事实:该结果来自当前 Agent 对话", + ], + options.timeoutMs, + ); + + logStage("capture-agent-result"); + await clickPageControl(page, { ariaLabel: "沉淀为项目资料" }); + + logStage("wait-agent-result-captured"); + await waitForPageText( + page, + "Agent 结果沉淀完成", + ["项目资料已整理", AGENT_RESULT_MESSAGE.title], + options.timeoutMs, + ); + logStage("return-knowledge-page"); - await clickPageControl(page, { ariaLabel: "知识库" }); + await clickPageControl(page, { ariaLabel: "项目资料" }); + + logStage("wait-captured-agent-result"); + await waitForPageText( + page, + "沉淀资料进入管理页", + [ + "项目资料", + "全部资料", + AGENT_RESULT_MESSAGE.title, + "继续确认", + ], + options.timeoutMs, + ); logStage("open-import-view"); await clickPageControl(page, { text: "补充导入" }); @@ -518,12 +611,12 @@ async function main() { await waitForHealth(options); await sleep(POST_HEALTH_SETTLE_MS); - logStage("seed-knowledge-packs"); - await seedKnowledgePacks(options); - logStage("create-smoke-project"); await createSmokeProject(options); + logStage("seed-knowledge-packs"); + await seedKnowledgePacks(options); + await runPlaywrightGuiFlow(options); console.log("[smoke:knowledge-gui] 通过"); } finally { diff --git a/scripts/release-updater-manifest.test.mjs b/scripts/release-updater-manifest.test.mjs index d69a853db..45f3ccedc 100644 --- a/scripts/release-updater-manifest.test.mjs +++ b/scripts/release-updater-manifest.test.mjs @@ -303,7 +303,7 @@ describe("GitHub release asset staging", () => { "arm-sig", ); writeFile( - path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.27.0_aarch64.dmg"), + path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.28.0_aarch64.dmg"), ); writeFile(path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz")); writeFile( @@ -311,7 +311,7 @@ describe("GitHub release asset staging", () => { "x64-sig", ); writeFile( - path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.27.0_x64.dmg"), + path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.28.0_x64.dmg"), ); writeFile(latestPath, "{}"); @@ -319,29 +319,29 @@ describe("GitHub release asset staging", () => { assetsDir, extraAssets: [latestPath], outDir, - version: "v1.27.0", + version: "v1.28.0", }); expect(copied.map((item) => item.name).sort()).toEqual( [ - "Lime_1.27.0_aarch64.app.tar.gz", - "Lime_1.27.0_aarch64.app.tar.gz.sig", - "Lime_1.27.0_aarch64.dmg", - "Lime_1.27.0_x64.app.tar.gz", - "Lime_1.27.0_x64.app.tar.gz.sig", - "Lime_1.27.0_x64.dmg", + "Lime_1.28.0_aarch64.app.tar.gz", + "Lime_1.28.0_aarch64.app.tar.gz.sig", + "Lime_1.28.0_aarch64.dmg", + "Lime_1.28.0_x64.app.tar.gz", + "Lime_1.28.0_x64.app.tar.gz.sig", + "Lime_1.28.0_x64.dmg", "latest.json", ].sort(), ); expect( fs.readFileSync( - path.join(outDir, "Lime_1.27.0_aarch64.app.tar.gz.sig"), + path.join(outDir, "Lime_1.28.0_aarch64.app.tar.gz.sig"), "utf8", ), ).toBe("arm-sig"); expect( fs.readFileSync( - path.join(outDir, "Lime_1.27.0_x64.app.tar.gz.sig"), + path.join(outDir, "Lime_1.28.0_x64.app.tar.gz.sig"), "utf8", ), ).toBe("x64-sig"); diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 21165c7fa..fec04e7cd 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -1424,6 +1424,27 @@ async function main() { options.timeoutMs + 30_000, ); + runCommand( + npmCommand, + [ + "run", + "smoke:design-canvas", + "--", + "--app-url", + options.appUrl, + "--health-url", + options.healthUrl, + "--invoke-url", + options.invokeUrl, + "--timeout-ms", + String(options.timeoutMs), + "--interval-ms", + String(options.intervalMs), + ], + "smoke:design-canvas", + options.timeoutMs + 30_000, + ); + await cleanupStaleGuiSmokeChromeProfiles(options, { label: "收尾清理本轮 smoke Chrome profiles", required: true, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f53ccf6e6..59b7d54c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.27.0" +version = "1.28.0" dependencies = [ "anyhow", "arboard", @@ -5171,7 +5171,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.27.0" +version = "1.28.0" dependencies = [ "anyhow", "aster-core", @@ -5200,7 +5200,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.27.0" +version = "1.28.0" dependencies = [ "chrono", "futures", @@ -5217,7 +5217,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.27.0" +version = "1.28.0" dependencies = [ "clap", "lime-core", @@ -5229,7 +5229,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.27.0" +version = "1.28.0" dependencies = [ "async-trait", "lime-core", @@ -5245,7 +5245,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.27.0" +version = "1.28.0" dependencies = [ "aster-models", "async-trait", @@ -5298,7 +5298,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.27.0" +version = "1.28.0" dependencies = [ "aes", "axum 0.7.9", @@ -5328,7 +5328,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.27.0" +version = "1.28.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5348,7 +5348,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.27.0" +version = "1.28.0" dependencies = [ "chrono", "hex", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.27.0" +version = "1.28.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5377,7 +5377,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.27.0" +version = "1.28.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5408,7 +5408,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.27.0" +version = "1.28.0" dependencies = [ "async-trait", "lime-core", @@ -5427,7 +5427,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.27.0" +version = "1.28.0" dependencies = [ "anyhow", "async-stream", @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.27.0" +version = "1.28.0" dependencies = [ "aster-core", "async-stream", @@ -5526,7 +5526,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.27.0" +version = "1.28.0" dependencies = [ "axum 0.7.9", "futures", @@ -5541,7 +5541,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.27.0" +version = "1.28.0" dependencies = [ "anyhow", "aster-core", @@ -5586,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.27.0" +version = "1.28.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5604,7 +5604,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.27.0" +version = "1.28.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b9fde07ca..701754590 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.27.0" +version = "1.28.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -198,7 +198,7 @@ version = "2.4" [package] name = "lime" -version = "1.27.0" +version = "1.28.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index aa882987f..4b3a5f556 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1126,6 +1126,13 @@ pub fn run() { commands::skill_cmd::create_skill_scaffold_for_app, commands::skill_cmd::import_local_skill_for_app, commands::skill_cmd::inspect_remote_skill, + // Capability Draft commands + commands::capability_draft_cmd::capability_draft_create, + commands::capability_draft_cmd::capability_draft_list, + commands::capability_draft_cmd::capability_draft_get, + commands::capability_draft_cmd::capability_draft_verify, + commands::capability_draft_cmd::capability_draft_register, + commands::capability_draft_cmd::capability_draft_list_registered_skills, // Skill Execution commands commands::skill_exec_cmd::execute_skill, commands::skill_exec_cmd::list_executable_skills, diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs index f164dc35c..224cd15b7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -120,6 +120,83 @@ fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) { } } +fn build_permission_confirmation_response( + request: &AgentRuntimeRespondActionRequest, +) -> serde_json::Value { + serde_json::json!({ + "confirmed": request.confirmed, + "response": request.response, + "userData": request.user_data, + "source": "runtime_permission_confirmation", + }) +} + +fn complete_runtime_permission_confirmation_request( + app: &AppHandle, + event_name: Option<&str>, + db: &DbConnection, + request: &AgentRuntimeRespondActionRequest, +) -> Result<(), String> { + let mut item = { + let conn = lime_core::database::lock_db(db)?; + lime_core::database::dao::agent_timeline::AgentTimelineDao::get_item( + &conn, + &request.request_id, + ) + .map_err(|error| format!("读取权限确认请求失败: {error}"))? + .ok_or_else(|| format!("权限确认请求不存在: {}", request.request_id))? + }; + + let lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + .. + } = item.payload + else { + return Err("权限确认请求不是 RequestUserInput,拒绝写回".to_string()); + }; + if !is_runtime_permission_confirmation_request_id(&request_id) { + return Err("请求 ID 不是运行时权限确认请求,拒绝写回".to_string()); + } + + let now = chrono::Utc::now().to_rfc3339(); + item.status = lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed; + item.completed_at = Some(now.clone()); + item.updated_at = now; + item.payload = + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + response: Some(build_permission_confirmation_response(request)), + }; + + { + let conn = lime_core::database::lock_db(db)?; + lime_core::database::dao::agent_timeline::AgentTimelineDao::upsert_item(&conn, &item) + .map_err(|error| format!("写回权限确认请求失败: {error}"))?; + } + + if let Some(event_name) = event_name.filter(|value| !value.trim().is_empty()) { + if let Err(error) = app.emit( + event_name, + &RuntimeAgentEvent::ItemCompleted { item: item.clone() }, + ) { + tracing::warn!( + "[AsterAgent] 发送权限确认完成事件失败: event_name={}, error={}", + event_name, + error + ); + } + emit_action_resume_runtime_status(app, event_name); + } + + Ok(()) +} + async fn load_runtime_workspace_settings_or_default( db: &DbConnection, session_id: &str, @@ -241,6 +318,15 @@ pub async fn agent_runtime_respond_action( db: State<'_, DbConnection>, request: AgentRuntimeRespondActionRequest, ) -> Result<(), String> { + if is_runtime_permission_confirmation_request_id(&request.request_id) { + return complete_runtime_permission_confirmation_request( + &app, + normalize_optional_text(request.event_name.clone()).as_deref(), + db.inner(), + &request, + ); + } + match request.action_type { AgentRuntimeActionType::ToolConfirmation => { confirm_runtime_action_internal( diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 25125bab7..65d481abe 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -747,12 +747,26 @@ fn latest_pending_tool_confirmation_request( }) } +fn latest_pending_permission_confirmation_request( + pending_requests: &[AgentRuntimeRequestView], +) -> Option<&AgentRuntimeRequestView> { + pending_requests.iter().find(|request| { + request.status == "pending" && is_runtime_permission_confirmation_request_id(&request.id) + }) +} + struct RuntimePermissionConfirmationProjection { status: &'static str, request_id: String, source: &'static str, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum RuntimeConfirmationItemKind { + ToolApproval, + RuntimePermission, +} + fn approval_response_confirmed(response: &serde_json::Value) -> Option { match response { serde_json::Value::Bool(value) => Some(*value), @@ -768,28 +782,47 @@ fn latest_resolved_tool_confirmation_item( detail: &SessionDetail, ) -> Option { detail.items.iter().rev().find_map(|item| { - let lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest { - request_id, - action_type, - response, - .. - } = &item.payload - else { - return None; + let (request_id, response, kind) = match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::ApprovalRequest { + request_id, + action_type, + response, + .. + } if is_tool_confirmation_request(action_type) => { + (request_id, response, RuntimeConfirmationItemKind::ToolApproval) + } + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput { + request_id, + response, + .. + } if is_runtime_permission_confirmation_request_id(request_id) => { + ( + request_id, + response, + RuntimeConfirmationItemKind::RuntimePermission, + ) + } + _ => return None, }; - if !is_tool_confirmation_request(action_type) { - return None; - } - let confirmed = response.as_ref().and_then(approval_response_confirmed); + let confirmed = match kind { + RuntimeConfirmationItemKind::ToolApproval => { + response.as_ref().and_then(approval_response_confirmed) + } + RuntimeConfirmationItemKind::RuntimePermission => { + runtime_permission_confirmation_response_confirmed(response.as_ref()) + } + }; match item.status { lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed => { + let status = match confirmed { + Some(false) => "denied", + Some(true) => "resolved", + None if kind == RuntimeConfirmationItemKind::RuntimePermission => "requested", + None => "resolved", + }; Some(RuntimePermissionConfirmationProjection { - status: if confirmed == Some(false) { - "denied" - } else { - "resolved" - }, + status, request_id: request_id.clone(), source: "runtime_action_required", }) @@ -823,16 +856,18 @@ fn apply_pending_confirmation_to_permission_state( if !permission_state .notes .iter() - .any(|note| note == "真实工具确认请求已完成") + .any(|note| note == "真实权限确认请求已完成") { permission_state .notes - .push("真实工具确认请求已完成".to_string()); + .push("真实权限确认请求已完成".to_string()); } return Some(permission_state); } - let Some(request) = latest_pending_tool_confirmation_request(pending_requests) else { + let Some(request) = latest_pending_permission_confirmation_request(pending_requests) + .or_else(|| latest_pending_tool_confirmation_request(pending_requests)) + else { return Some(permission_state); }; @@ -842,11 +877,11 @@ fn apply_pending_confirmation_to_permission_state( if !permission_state .notes .iter() - .any(|note| note == "真实工具确认请求已进入 action_required 队列") + .any(|note| note == "真实权限确认请求已进入 action_required 队列") { permission_state .notes - .push("真实工具确认请求已进入 action_required 队列".to_string()); + .push("真实权限确认请求已进入 action_required 队列".to_string()); } Some(permission_state) @@ -3405,6 +3440,57 @@ mod tests { ); } + #[test] + fn permission_state_should_project_pending_runtime_permission_confirmation_request() { + let permission_state = lime_agent::SessionExecutionRuntimePermissionState { + status: "requires_confirmation".to_string(), + required_profile_keys: vec!["read_files".to_string()], + ask_profile_keys: vec!["read_files".to_string()], + blocking_profile_keys: Vec::new(), + decision_source: "modality_execution_profile".to_string(), + decision_scope: "declared_profile".to_string(), + confirmation_status: Some("not_requested".to_string()), + confirmation_request_id: None, + confirmation_source: Some("declared_profile_only".to_string()), + notes: Vec::new(), + }; + let pending_requests = vec![AgentRuntimeRequestView { + id: "runtime_permission_confirmation:turn-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: Some("runtime_permission_confirmation:turn-1".to_string()), + request_type: "elicitation".to_string(), + status: "pending".to_string(), + title: Some("确认运行时权限".to_string()), + payload: None, + decision: None, + scope: None, + created_at: Some(seconds_ago(3)), + resolved_at: None, + }]; + + let projected = apply_pending_confirmation_to_permission_state( + Some(permission_state), + &pending_requests, + None, + ) + .expect("应保留 permission_state"); + + assert_eq!(projected.confirmation_status.as_deref(), Some("requested")); + assert_eq!( + projected.confirmation_request_id.as_deref(), + Some("runtime_permission_confirmation:turn-1") + ); + assert_eq!( + projected.confirmation_source.as_deref(), + Some("runtime_action_required") + ); + assert!(projected + .notes + .iter() + .any(|note| note == "真实权限确认请求已进入 action_required 队列")); + } + #[test] fn thread_read_should_project_resolved_tool_approval_into_permission_confirmation_state() { let mut detail = build_session_detail( diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index fc9c6c0db..878a88533 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -143,6 +143,78 @@ const WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS: &[&str] = &[ "PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK", ]; const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback"; +pub(crate) const RUNTIME_PERMISSION_CONFIRMATION_REQUEST_PREFIX: &str = + "runtime_permission_confirmation:"; + +pub(crate) fn is_runtime_permission_confirmation_request_id(request_id: &str) -> bool { + request_id + .trim() + .starts_with(RUNTIME_PERMISSION_CONFIRMATION_REQUEST_PREFIX) +} + +fn runtime_permission_confirmation_text_is_denial(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return false; + } + let normalized = trimmed.to_ascii_lowercase(); + matches!( + normalized.as_str(), + "deny" | "denied" | "reject" | "rejected" | "no" | "false" + ) || trimmed.contains("拒绝") + || trimmed.contains("不允许") +} + +fn runtime_permission_confirmation_value_is_denial(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Bool(value) => !*value, + serde_json::Value::String(value) => { + if runtime_permission_confirmation_text_is_denial(value) { + return true; + } + serde_json::from_str::(value) + .ok() + .is_some_and(|parsed| runtime_permission_confirmation_value_is_denial(&parsed)) + } + serde_json::Value::Array(values) => values + .iter() + .any(runtime_permission_confirmation_value_is_denial), + serde_json::Value::Object(object) => object + .get("answer") + .or_else(|| object.get("decision")) + .or_else(|| object.get("confirmed")) + .or_else(|| object.get("approved")) + .is_some_and(runtime_permission_confirmation_value_is_denial), + _ => false, + } +} + +pub(crate) fn runtime_permission_confirmation_response_confirmed( + response: Option<&serde_json::Value>, +) -> Option { + let response = response?; + match response { + serde_json::Value::Bool(value) => Some(*value), + serde_json::Value::Object(object) => { + let explicit = object + .get("confirmed") + .and_then(serde_json::Value::as_bool) + .or_else(|| object.get("approved").and_then(serde_json::Value::as_bool)); + if explicit == Some(false) { + return Some(false); + } + let answer_denied = object + .get("userData") + .or_else(|| object.get("response")) + .is_some_and(runtime_permission_confirmation_value_is_denial); + if answer_denied { + return Some(false); + } + explicit + } + _ => None, + } +} const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created"; const DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS: usize = 8; const SOCIAL_IMAGE_DEFAULT_MODEL: &str = "gemini-3-pro-image-preview"; 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 fe94f38a3..7a7716223 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 @@ -59,6 +59,7 @@ struct ResolvedRuntimeProviderSelection { estimated_cost_class: Option, pricing: Option, capability_gap: Option, + capability_gap_source: Option, fallback_chain: Vec, } @@ -2399,6 +2400,20 @@ fn build_limit_state( } } +fn runtime_limit_status_for_selection( + selection: &ResolvedRuntimeProviderSelection, +) -> &'static str { + if selection.capability_gap.is_some() + && selection.capability_gap_source.as_deref() == Some("explicit_model_lock") + { + "user_locked_capability_gap" + } else if selection.candidate_count <= 1 { + "single_candidate_only" + } else { + "normal" + } +} + fn build_cost_state( selection: Option<&ResolvedRuntimeProviderSelection>, fallback_cost_class: Option, @@ -2472,7 +2487,7 @@ fn build_permission_state( notes.push("当前 task profile 未声明 permissionProfileKeys。".to_string()); } else { notes.push( - "permissionProfileKeys 已进入运行时判定摘要;本阶段只记录声明,不执行真实授权或阻断。" + "permissionProfileKeys 已进入运行时判定摘要;需确认权限会在模型执行前阻断,直到真实确认 resolved。" .to_string(), ); } @@ -2929,18 +2944,24 @@ async fn build_runtime_request_provider_config_from_preference( let locked_model_capability_gap = resolved_model_capability_gap .clone() .filter(|_| honor_explicit_model_lock); - let capability_gap = if vision_gap { - Some("vision_candidate_missing".to_string()) + let (capability_gap, capability_gap_source) = if vision_gap { + ( + Some("vision_candidate_missing".to_string()), + Some("vision_input".to_string()), + ) } else if reasoning_gap { - Some("reasoning_candidate_missing".to_string()) - } else if locked_model_capability_gap.is_some() { - locked_model_capability_gap - } else if resolved_model_capability_gap.is_some() { - resolved_model_capability_gap - } else if runtime_capability_gap.is_some() { - runtime_capability_gap + ( + Some("reasoning_candidate_missing".to_string()), + Some("reasoning_input".to_string()), + ) + } else if let Some(gap) = locked_model_capability_gap { + (Some(gap), Some("explicit_model_lock".to_string())) + } else if let Some(gap) = resolved_model_capability_gap { + (Some(gap), Some("resolved_model".to_string())) + } else if let Some(gap) = runtime_capability_gap { + (Some(gap), Some("candidate_catalog".to_string())) } else { - None + (None, None) }; Ok(ResolvedRuntimeProviderSelection { @@ -2951,6 +2972,7 @@ async fn build_runtime_request_provider_config_from_preference( estimated_cost_class, pricing: model_meta.and_then(|model| model.pricing.clone()), capability_gap, + capability_gap_source, fallback_chain, provider_config: ConfigureProviderRequest { provider_id: Some(context.provider_selector.clone()), @@ -3189,17 +3211,22 @@ pub(super) async fn resolve_runtime_request_provider_resolution( ) .await?; let limit_state = build_limit_state( - if selection.candidate_count <= 1 { - "single_candidate_only" - } else { - "normal" - }, + runtime_limit_status_for_selection(&selection), selection.candidate_count, true, false, oem_locked, selection.capability_gap.clone(), - vec!["当前回合显式指定了 provider/model 偏好。".to_string()], + { + let mut notes = vec!["当前回合显式指定了 provider/model 偏好。".to_string()]; + if selection.capability_gap_source.as_deref() == Some("explicit_model_lock") { + notes.push( + "显式用户模型锁定不满足当前 execution profile 的 routing slot,模型执行前必须阻断。" + .to_string(), + ); + } + notes + }, ); let routing_decision = build_routing_decision( &task_profile, @@ -3262,11 +3289,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution( scene_preference.allow_fallback ); let limit_state = build_limit_state( - if selection.candidate_count <= 1 { - "single_candidate_only" - } else { - "normal" - }, + runtime_limit_status_for_selection(&selection), selection.candidate_count, true, true, @@ -3349,11 +3372,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution( notes.push(note); } let limit_state = build_limit_state( - if selection.candidate_count <= 1 { - "single_candidate_only" - } else { - "normal" - }, + runtime_limit_status_for_selection(&selection), selection.candidate_count, true, true, @@ -3472,11 +3491,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution( notes.push(note.clone()); } let limit_state = build_limit_state( - if selection.candidate_count <= 1 { - "single_candidate_only" - } else { - "normal" - }, + runtime_limit_status_for_selection(&selection), selection.candidate_count, true, false, @@ -4048,6 +4063,36 @@ mod tests { ); } + #[test] + fn user_locked_capability_gap_should_use_blocking_limit_status() { + let selection = ResolvedRuntimeProviderSelection { + provider_config: ConfigureProviderRequest { + provider_id: Some("openai".to_string()), + provider_name: "openai".to_string(), + model_name: "gpt-5.4-mini".to_string(), + api_key: None, + base_url: None, + model_capabilities: None, + tool_call_strategy: None, + toolshim_model: None, + }, + provider_selector: "openai".to_string(), + requested_model: "gpt-5.4-mini".to_string(), + resolved_model: "gpt-5.4-mini".to_string(), + candidate_count: 1, + estimated_cost_class: Some("low".to_string()), + pricing: None, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + capability_gap_source: Some("explicit_model_lock".to_string()), + fallback_chain: Vec::new(), + }; + + assert_eq!( + runtime_limit_status_for_selection(&selection), + "user_locked_capability_gap" + ); + } + #[test] fn custom_provider_multi_candidate_reselection_is_disabled() { let context = ProviderResolutionContext { @@ -4696,6 +4741,7 @@ mod tests { currency: "USD".to_string(), }), capability_gap: None, + capability_gap_source: None, fallback_chain: Vec::new(), }; 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 06817717a..7fc4825e1 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1230,6 +1230,78 @@ impl RuntimeTurnPreparedExecution { ) .await?; + if let Some(permission_state) = extract_runtime_resolution_payload::< + lime_agent::SessionExecutionRuntimePermissionState, + >(request_metadata, "permission_state") + { + if permission_state_requires_turn_gating(&permission_state) { + maybe_emit_runtime_permission_confirmation_request( + app, + request, + workspace_root, + self.thread_id(), + self.turn_id(), + &self.runtime_turn_execution_context.timeline_recorder, + &permission_state, + ); + let error = format_permission_turn_gating_error(&permission_state); + complete_runtime_status_projection( + agent, + app, + &request.event_name, + &self.runtime_turn_execution_context.timeline_recorder, + workspace_root, + &self + .runtime_turn_execution_context + .runtime_status_session_config, + ) + .await; + fail_runtime_turn_before_model_execution( + app, + &request.event_name, + &self.runtime_turn_execution_context.timeline_recorder, + &error, + ); + return Err(error); + } + } + if let Some(limit_state) = extract_runtime_resolution_payload::< + lime_agent::SessionExecutionRuntimeLimitState, + >(request_metadata, "limit_state") + { + if limit_state_requires_user_lock_capability_gating(&limit_state) { + let routing_decision = extract_runtime_resolution_payload::< + lime_agent::SessionExecutionRuntimeRoutingDecision, + >(request_metadata, "routing_decision"); + let task_profile = extract_runtime_resolution_payload::< + lime_agent::SessionExecutionRuntimeTaskProfile, + >(request_metadata, "task_profile"); + let error = format_user_lock_capability_gating_error( + &limit_state, + routing_decision.as_ref(), + task_profile.as_ref(), + ); + complete_runtime_status_projection( + agent, + app, + &request.event_name, + &self.runtime_turn_execution_context.timeline_recorder, + workspace_root, + &self + .runtime_turn_execution_context + .runtime_status_session_config, + ) + .await; + fail_runtime_turn_before_model_execution( + app, + &request.event_name, + &self.runtime_turn_execution_context.timeline_recorder, + &error, + ); + return Err(error); + } + } + self.runtime_turn_execution_context .execute_and_finalize( tracker, @@ -2625,6 +2697,13 @@ async fn prepare_runtime_turn_ingress_context( provider_resolution.oem_policy.as_ref(), &provider_resolution.runtime_summary, ); + let permission_confirmation_session_id = request.session_id.clone(); + merge_runtime_permission_confirmation_from_session( + db, + &permission_confirmation_session_id, + request, + ) + .await; if let Some(resolved_provider_config) = provider_resolution.provider_config { request.provider_config = Some(resolved_provider_config); } @@ -3557,6 +3636,160 @@ fn merge_runtime_request_resolution_metadata( Some(serde_json::Value::Object(root)) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct RuntimePermissionConfirmationProjection { + status: &'static str, + request_id: String, + source: &'static str, + note: &'static str, +} + +fn latest_runtime_permission_confirmation_projection( + detail: &SessionDetail, +) -> Option { + detail.items.iter().rev().find_map(|item| { + let lime_core::database::dao::agent_timeline::AgentThreadItemPayload::RequestUserInput { + request_id, + response, + .. + } = &item.payload + else { + return None; + }; + if !is_runtime_permission_confirmation_request_id(request_id) { + return None; + } + + match item.status { + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::InProgress => { + Some(RuntimePermissionConfirmationProjection { + status: "requested", + request_id: request_id.clone(), + source: "runtime_action_required", + note: "真实权限确认请求正在等待用户处理", + }) + } + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Completed => { + let confirmed = + runtime_permission_confirmation_response_confirmed(response.as_ref()); + let (status, note) = match confirmed { + Some(false) => ("denied", "真实权限确认请求已拒绝"), + Some(true) => ("resolved", "真实权限确认请求已完成"), + None => ("requested", "真实权限确认请求缺少响应,继续等待用户处理"), + }; + Some(RuntimePermissionConfirmationProjection { + status, + request_id: request_id.clone(), + source: "runtime_action_required", + note, + }) + } + lime_core::database::dao::agent_timeline::AgentThreadItemStatus::Failed => { + Some(RuntimePermissionConfirmationProjection { + status: "denied", + request_id: request_id.clone(), + source: "runtime_action_required", + note: "真实权限确认请求已失败或拒绝", + }) + } + } + }) +} + +fn append_permission_confirmation_note( + permission_object: &mut serde_json::Map, + note: &str, +) { + let notes_entry = permission_object + .entry("notes".to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + if !notes_entry.is_array() { + *notes_entry = serde_json::Value::Array(Vec::new()); + } + let Some(notes) = notes_entry.as_array_mut() else { + return; + }; + if notes.iter().any(|value| value.as_str() == Some(note)) { + return; + } + notes.push(serde_json::Value::String(note.to_string())); +} + +fn apply_runtime_permission_confirmation_projection_to_metadata( + metadata: &mut Option, + projection: &RuntimePermissionConfirmationProjection, +) -> bool { + let Some(root) = metadata.as_mut().and_then(serde_json::Value::as_object_mut) else { + return false; + }; + let Some(runtime_object) = root + .get_mut(LIME_RUNTIME_METADATA_KEY) + .and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + let Some(permission_object) = runtime_object + .get_mut("permission_state") + .and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + if permission_object + .get("status") + .and_then(serde_json::Value::as_str) + != Some("requires_confirmation") + { + return false; + } + + permission_object.insert( + "confirmationStatus".to_string(), + serde_json::Value::String(projection.status.to_string()), + ); + permission_object.insert( + "confirmationRequestId".to_string(), + serde_json::Value::String(projection.request_id.clone()), + ); + permission_object.insert( + "confirmationSource".to_string(), + serde_json::Value::String(projection.source.to_string()), + ); + append_permission_confirmation_note(permission_object, projection.note); + true +} + +async fn merge_runtime_permission_confirmation_from_session( + db: &DbConnection, + session_id: &str, + request: &mut AsterChatRequest, +) { + let detail = match AsterAgentWrapper::get_runtime_session_detail(db, session_id).await { + Ok(detail) => detail, + Err(error) => { + tracing::warn!( + "[AsterAgent] 读取权限确认状态失败,已保持本轮声明态权限摘要: session_id={}, error={}", + session_id, + error + ); + return; + } + }; + let Some(projection) = latest_runtime_permission_confirmation_projection(&detail) else { + return; + }; + if apply_runtime_permission_confirmation_projection_to_metadata( + &mut request.metadata, + &projection, + ) { + tracing::info!( + "[AsterAgent] 已合并真实权限确认状态: session_id={}, request_id={}, status={}", + session_id, + projection.request_id, + projection.status + ); + } +} + fn extract_runtime_resolution_payload( request_metadata: Option<&serde_json::Value>, key: &str, @@ -3614,9 +3847,14 @@ fn collect_runtime_request_resolution_side_events( }); if limit_state.capability_gap.is_some() { - events.push(RuntimeAgentEvent::SingleCandidateCapabilityGap { limit_state }); + events.push(RuntimeAgentEvent::SingleCandidateCapabilityGap { + limit_state: limit_state.clone(), + }); } } + if let Some(status) = build_runtime_user_lock_capability_status_from_state(&limit_state) { + events.push(RuntimeAgentEvent::RuntimeStatus { status }); + } } if let Some(cost_state) = extract_runtime_resolution_payload::< @@ -3645,6 +3883,255 @@ fn collect_runtime_request_resolution_side_events( events } +fn permission_state_requires_turn_gating( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> bool { + permission_state.status == "requires_confirmation" + && permission_state.confirmation_status.as_deref() != Some("resolved") +} + +fn limit_state_requires_user_lock_capability_gating( + limit_state: &lime_agent::SessionExecutionRuntimeLimitState, +) -> bool { + limit_state.status == "user_locked_capability_gap" && limit_state.capability_gap.is_some() +} + +fn format_user_lock_capability_gating_error( + limit_state: &lime_agent::SessionExecutionRuntimeLimitState, + routing_decision: Option<&lime_agent::SessionExecutionRuntimeRoutingDecision>, + task_profile: Option<&lime_agent::SessionExecutionRuntimeTaskProfile>, +) -> String { + let gap = limit_state + .capability_gap + .as_deref() + .unwrap_or("unknown_capability_gap"); + let model = routing_decision + .and_then(|decision| decision.selected_model.as_deref()) + .unwrap_or("未记录 selectedModel"); + let requested_model = routing_decision + .and_then(|decision| decision.requested_model.as_deref()) + .unwrap_or(model); + let routing_slot = task_profile + .and_then(|profile| profile.routing_slot.as_deref()) + .unwrap_or("未记录 routingSlot"); + format!( + "显式用户模型锁定不满足当前执行画像,已在模型执行前阻断:requestedModel={requested_model},selectedModel={model},routingSlot={routing_slot},capabilityGap={gap}。请切换到满足该 routing slot 的模型,或移除本轮显式模型锁定后重试。" + ) +} + +fn build_runtime_user_lock_capability_status_from_state( + limit_state: &lime_agent::SessionExecutionRuntimeLimitState, +) -> Option { + if !limit_state_requires_user_lock_capability_gating(limit_state) { + return None; + } + + let gap = limit_state + .capability_gap + .as_deref() + .unwrap_or("unknown_capability_gap"); + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "limit_status".to_string(), + serde_json::Value::String(limit_state.status.clone()), + ); + metadata.insert( + "capability_gap".to_string(), + serde_json::Value::String(gap.to_string()), + ); + metadata.insert("turn_gating".to_string(), serde_json::Value::Bool(true)); + Some(AgentRuntimeStatus { + phase: "routing".to_string(), + title: "显式模型锁定能力不匹配".to_string(), + detail: format!("当前用户锁定模型缺少执行画像要求的能力:{gap};本轮会在模型执行前阻断。"), + checkpoints: vec![ + "能力缺口来自 routing slot 与模型目录匹配结果".to_string(), + "显式用户锁定不会被自动重选覆盖".to_string(), + "请切换模型或取消本轮显式模型锁定后重试".to_string(), + ], + metadata: Some(metadata), + }) +} + +fn should_create_runtime_permission_confirmation_request( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> bool { + permission_state.status == "requires_confirmation" + && matches!( + permission_state.confirmation_status.as_deref(), + None | Some("not_requested") + ) + && permission_state.confirmation_request_id.is_none() +} + +fn runtime_permission_confirmation_request_id(turn_id: &str) -> String { + format!("{RUNTIME_PERMISSION_CONFIRMATION_REQUEST_PREFIX}{turn_id}") +} + +fn runtime_permission_ask_profile_label( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> String { + if permission_state.ask_profile_keys.is_empty() { + return "未记录 askProfileKeys".to_string(); + } + + permission_state.ask_profile_keys.join(", ") +} + +fn format_permission_turn_gating_error( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> String { + let confirmation_status = permission_state + .confirmation_status + .as_deref() + .unwrap_or("未记录 confirmationStatus"); + let ask_profile_keys = runtime_permission_ask_profile_label(permission_state); + + format!( + "运行时权限声明需要真实确认,当前 turn 已在模型执行前阻断:confirmationStatus={confirmation_status},askProfileKeys={ask_profile_keys}。本阻断不创建 ApprovalRequest;请先接入真实权限确认或移除对应执行需求。" + ) +} + +fn build_runtime_permission_confirmation_prompt( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> String { + format!( + "当前执行需要确认运行时权限:{}。确认后才允许继续模型执行;拒绝会保持阻断。", + runtime_permission_ask_profile_label(permission_state) + ) +} + +fn build_runtime_permission_confirmation_questions( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> Vec { + vec![ + lime_core::database::dao::agent_timeline::AgentRequestQuestion { + header: Some("运行时权限确认".to_string()), + question: build_runtime_permission_confirmation_prompt(permission_state), + options: Some(vec![ + lime_core::database::dao::agent_timeline::AgentRequestOption { + label: "允许本次执行".to_string(), + description: Some("写入 resolved,下一次恢复执行可通过权限门禁。".to_string()), + }, + lime_core::database::dao::agent_timeline::AgentRequestOption { + label: "拒绝".to_string(), + description: Some("写入 denied,本次执行需求继续阻断。".to_string()), + }, + ]), + multi_select: Some(false), + }, + ] +} + +fn build_runtime_permission_confirmation_schema( + questions: &[lime_core::database::dao::agent_timeline::AgentRequestQuestion], +) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "answer": { + "type": "string", + "enum": ["允许本次执行", "拒绝"] + } + }, + "required": ["answer"], + "x-lime-ask-user-questions": questions, + }) +} + +fn maybe_emit_runtime_permission_confirmation_request( + app: &AppHandle, + request: &AsterChatRequest, + workspace_root: &str, + thread_id: &str, + turn_id: &str, + timeline_recorder: &Arc>, + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) { + if !should_create_runtime_permission_confirmation_request(permission_state) { + return; + } + + let request_id = runtime_permission_confirmation_request_id(turn_id); + let prompt = build_runtime_permission_confirmation_prompt(permission_state); + let questions = build_runtime_permission_confirmation_questions(permission_state); + { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.record_request_user_input( + app, + &request.event_name, + request_id.clone(), + "elicitation".to_string(), + Some(prompt.clone()), + Some(questions.clone()), + ) { + tracing::warn!( + "[AsterAgent] 记录权限确认请求失败(已降级只发送 action_required): {}", + error + ); + } + } + + emit_runtime_side_event( + app, + &request.event_name, + timeline_recorder, + workspace_root, + RuntimeAgentEvent::ActionRequired { + request_id, + action_type: "elicitation".to_string(), + data: serde_json::json!({ + "request_id": runtime_permission_confirmation_request_id(turn_id), + "action_type": "elicitation", + "prompt": prompt, + "questions": questions, + "requested_schema": build_runtime_permission_confirmation_schema(&questions), + "permission_state": permission_state, + "source": "runtime_permission_confirmation", + }), + scope: Some(lime_agent::AgentActionRequiredScope { + session_id: Some(request.session_id.clone()), + thread_id: Some(thread_id.to_string()), + turn_id: Some(turn_id.to_string()), + }), + }, + ); +} + +fn fail_runtime_turn_before_model_execution( + app: &AppHandle, + event_name: &str, + timeline_recorder: &Arc>, + message: &str, +) { + let terminal_events = { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + recorder.fail_turn(message) + }; + if let Err(error) = &terminal_events { + tracing::warn!( + "[AsterAgent] 记录运行时执行前阻断 turn 时间线失败(已降级继续): {}", + error + ); + } + if let Ok(events) = terminal_events { + emit_runtime_events(app, event_name, events); + } + + let error_event = RuntimeAgentEvent::Error { + message: message.to_string(), + }; + if let Err(error) = app.emit(event_name, &error_event) { + tracing::error!("[AsterAgent] 发送运行时执行前阻断错误事件失败: {}", error); + } +} + fn build_runtime_permission_review_status_from_state( permission_state: &lime_agent::SessionExecutionRuntimePermissionState, ) -> Option { @@ -3698,21 +4185,52 @@ fn build_runtime_permission_review_status_from_state( serde_json::Value::String(confirmation_source.clone()), ); } - metadata.insert("declared_only".to_string(), serde_json::Value::Bool(true)); + let declared_only = permission_state.confirmation_request_id.is_none() + && permission_state.confirmation_source.as_deref() != Some("runtime_action_required"); + metadata.insert( + "declared_only".to_string(), + serde_json::Value::Bool(declared_only), + ); + let turn_gating = permission_state_requires_turn_gating(permission_state); + metadata.insert( + "turn_gating".to_string(), + serde_json::Value::Bool(turn_gating), + ); let ask_count = permission_state.ask_profile_keys.len(); let required_count = permission_state.required_profile_keys.len(); + let confirmation_status = permission_state + .confirmation_status + .as_deref() + .unwrap_or("未记录 confirmationStatus"); + let detail = if turn_gating { + format!( + "当前执行画像声明了 {required_count} 项权限,其中 {ask_count} 项需要确认;confirmationStatus={confirmation_status} 尚未 resolved,本轮会在模型执行前阻断。" + ) + } else { + format!( + "当前执行画像声明了 {required_count} 项权限,其中 {ask_count} 项需要确认;confirmationStatus=resolved,允许继续模型执行。" + ) + }; + let checkpoints = if turn_gating { + vec![ + "权限需求来自 modality execution profile".to_string(), + "未解决权限确认会在 prelude 后、模型执行前阻断本轮 turn".to_string(), + "本事件不代表 ApprovalRequest 已创建;只有 confirmationStatus=resolved 才允许继续" + .to_string(), + ] + } else { + vec![ + "权限需求来自 modality execution profile".to_string(), + "已记录 resolved 权限确认,本轮允许继续执行".to_string(), + "本事件仍只投影确认状态,不伪造新的 ApprovalRequest".to_string(), + ] + }; Some(AgentRuntimeStatus { phase: "permission_review".to_string(), title: "运行时权限需要确认".to_string(), - detail: format!( - "当前执行画像声明了 {required_count} 项权限,其中 {ask_count} 项需要确认;本事件只暴露声明态,不会替代真实授权。" - ), - checkpoints: vec![ - "权限需求来自 modality execution profile".to_string(), - "当前不会因为声明态权限摘要阻断本轮执行".to_string(), - "真实确认与阻断仍由后续权限系统接管".to_string(), - ], + detail, + checkpoints, metadata: Some(metadata), }) } @@ -8091,6 +8609,12 @@ mod tests { .and_then(Value::as_bool), Some(true) ); + assert_eq!( + status + .pointer("/metadata/turn_gating") + .and_then(Value::as_bool), + Some(true) + ); assert_eq!( status .pointer("/metadata/ask_profile_keys") @@ -8098,6 +8622,251 @@ mod tests { .map(Vec::len), Some(2) ); + assert!(status + .get("detail") + .and_then(Value::as_str) + .is_some_and(|detail| detail.contains("模型执行前阻断"))); + } + + #[test] + fn permission_turn_gating_should_block_not_requested_confirmation() { + let permission_state: lime_agent::SessionExecutionRuntimePermissionState = + serde_json::from_value(json!({ + "status": "requires_confirmation", + "requiredProfileKeys": ["read_files"], + "askProfileKeys": ["read_files"], + "blockingProfileKeys": [], + "decisionSource": "modality_execution_profile", + "decisionScope": "declared_profile", + "confirmationStatus": "not_requested", + "confirmationSource": "declared_profile_only" + })) + .expect("应能解析 permission state"); + + assert!(permission_state_requires_turn_gating(&permission_state)); + let error = format_permission_turn_gating_error(&permission_state); + assert!(error.contains("confirmationStatus=not_requested")); + assert!(error.contains("askProfileKeys=read_files")); + assert!(error.contains("不创建 ApprovalRequest")); + } + + #[test] + fn permission_turn_gating_should_block_missing_confirmation_status() { + let permission_state: lime_agent::SessionExecutionRuntimePermissionState = + serde_json::from_value(json!({ + "status": "requires_confirmation", + "requiredProfileKeys": ["write_artifacts"], + "askProfileKeys": ["write_artifacts"], + "blockingProfileKeys": [], + "decisionSource": "modality_execution_profile", + "decisionScope": "declared_profile" + })) + .expect("应能解析 permission state"); + + assert!(permission_state_requires_turn_gating(&permission_state)); + assert!(format_permission_turn_gating_error(&permission_state) + .contains("未记录 confirmationStatus")); + } + + #[test] + fn permission_turn_gating_should_allow_resolved_confirmation() { + let permission_state: lime_agent::SessionExecutionRuntimePermissionState = + serde_json::from_value(json!({ + "status": "requires_confirmation", + "requiredProfileKeys": ["read_files"], + "askProfileKeys": ["read_files"], + "blockingProfileKeys": [], + "decisionSource": "modality_execution_profile", + "decisionScope": "runtime_action_required", + "confirmationStatus": "resolved", + "confirmationRequestId": "approval-1", + "confirmationSource": "runtime_action_required" + })) + .expect("应能解析 permission state"); + + assert!(!permission_state_requires_turn_gating(&permission_state)); + let status = build_runtime_permission_review_status_from_state(&permission_state) + .expect("应生成状态"); + assert_eq!( + status + .metadata + .as_ref() + .and_then(|metadata| metadata.get("declared_only")) + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + status + .metadata + .as_ref() + .and_then(|metadata| metadata.get("turn_gating")) + .and_then(Value::as_bool), + Some(false) + ); + assert!(status.detail.contains("允许继续模型执行")); + } + + #[test] + fn user_lock_capability_gap_should_block_before_model_execution() { + let limit_state = lime_agent::SessionExecutionRuntimeLimitState { + status: "user_locked_capability_gap".to_string(), + single_candidate_only: true, + provider_locked: true, + settings_locked: false, + oem_locked: false, + candidate_count: 1, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + notes: Vec::new(), + }; + let routing_decision = lime_agent::SessionExecutionRuntimeRoutingDecision { + routing_mode: "single_candidate".to_string(), + decision_source: "request_override".to_string(), + decision_reason: "用户显式选择模型".to_string(), + selected_provider: Some("openai".to_string()), + selected_model: Some("gpt-5.4-mini".to_string()), + requested_provider: Some("openai".to_string()), + requested_model: Some("gpt-5.4-mini".to_string()), + candidate_count: 1, + estimated_cost_class: Some("low".to_string()), + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + fallback_chain: Vec::new(), + settings_source: None, + service_model_slot: None, + }; + let task_profile = lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "browser_control".to_string(), + source: "browser_assist".to_string(), + traits: Vec::new(), + modality_contract_key: Some("browser_control".to_string()), + routing_slot: Some("browser_reasoning_model".to_string()), + execution_profile_key: Some("browser_control_profile".to_string()), + executor_adapter_key: Some("browser:browser_assist".to_string()), + executor_kind: Some("browser".to_string()), + executor_binding_key: Some("browser_assist".to_string()), + permission_profile_keys: Vec::new(), + user_lock_policy: Some("honor_explicit_model_lock_with_capability_check".to_string()), + service_model_slot: None, + scene_kind: None, + scene_skill_id: None, + entry_source: None, + }; + + assert!(limit_state_requires_user_lock_capability_gating( + &limit_state + )); + let error = format_user_lock_capability_gating_error( + &limit_state, + Some(&routing_decision), + Some(&task_profile), + ); + assert!(error.contains("模型执行前阻断")); + assert!(error.contains("routingSlot=browser_reasoning_model")); + assert!(error.contains("capabilityGap=browser_reasoning_candidate_missing")); + let status = build_runtime_user_lock_capability_status_from_state(&limit_state) + .expect("应生成 user lock capability status"); + assert_eq!(status.phase, "routing"); + assert_eq!( + status + .metadata + .as_ref() + .and_then(|metadata| metadata.get("turn_gating")) + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn permission_confirmation_projection_should_mark_runtime_metadata_resolved() { + let mut metadata = Some(json!({ + "lime_runtime": { + "permission_state": { + "status": "requires_confirmation", + "requiredProfileKeys": ["read_files"], + "askProfileKeys": ["read_files"], + "blockingProfileKeys": [], + "decisionSource": "modality_execution_profile", + "decisionScope": "declared_profile", + "confirmationStatus": "not_requested", + "confirmationSource": "declared_profile_only" + } + } + })); + + let applied = apply_runtime_permission_confirmation_projection_to_metadata( + &mut metadata, + &RuntimePermissionConfirmationProjection { + status: "resolved", + request_id: "runtime_permission_confirmation:turn-1".to_string(), + source: "runtime_action_required", + note: "真实权限确认请求已完成", + }, + ); + + assert!(applied); + let permission_state = extract_runtime_resolution_payload::< + lime_agent::SessionExecutionRuntimePermissionState, + >(metadata.as_ref(), "permission_state") + .expect("应能读取更新后的 permission_state"); + assert_eq!( + permission_state.confirmation_status.as_deref(), + Some("resolved") + ); + assert_eq!( + permission_state.confirmation_request_id.as_deref(), + Some("runtime_permission_confirmation:turn-1") + ); + assert!(!permission_state_requires_turn_gating(&permission_state)); + } + + #[test] + fn permission_confirmation_response_should_treat_reject_answer_as_denied() { + let response = json!({ + "confirmed": true, + "response": "{\"answer\":\"拒绝\"}", + "userData": { "answer": "拒绝" }, + "source": "runtime_permission_confirmation" + }); + + assert_eq!( + runtime_permission_confirmation_response_confirmed(Some(&response)), + Some(false) + ); + } + + #[test] + fn permission_confirmation_request_should_only_create_for_not_requested() { + let not_requested: lime_agent::SessionExecutionRuntimePermissionState = + serde_json::from_value(json!({ + "status": "requires_confirmation", + "requiredProfileKeys": ["read_files"], + "askProfileKeys": ["read_files"], + "blockingProfileKeys": [], + "decisionSource": "modality_execution_profile", + "decisionScope": "declared_profile", + "confirmationStatus": "not_requested", + "confirmationSource": "declared_profile_only" + })) + .expect("应能解析 permission state"); + let requested: lime_agent::SessionExecutionRuntimePermissionState = + serde_json::from_value(json!({ + "status": "requires_confirmation", + "requiredProfileKeys": ["read_files"], + "askProfileKeys": ["read_files"], + "blockingProfileKeys": [], + "decisionSource": "modality_execution_profile", + "decisionScope": "declared_profile", + "confirmationStatus": "requested", + "confirmationRequestId": "runtime_permission_confirmation:turn-1", + "confirmationSource": "runtime_action_required" + })) + .expect("应能解析 permission state"); + + assert!(should_create_runtime_permission_confirmation_request( + ¬_requested + )); + assert!(!should_create_runtime_permission_confirmation_request( + &requested + )); } #[test] diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index d40bd48a0..1a97e3188 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -20,6 +20,8 @@ use lime_media_runtime::{ use serde::{de, Deserialize, Deserializer}; const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"]; +const THREAD_ID_ENV_KEYS: &[&str] = &["LIME_THREAD_ID", "PROXYCAST_THREAD_ID"]; +const TURN_ID_ENV_KEYS: &[&str] = &["LIME_TURN_ID", "PROXYCAST_TURN_ID"]; const CONTENT_ID_ENV_KEYS: &[&str] = &["LIME_CONTENT_ID", "PROXYCAST_CONTENT_ID"]; const IMAGE_TASK_DEFAULT_ENTRY_SOURCE: &str = "at_image_command"; const AUDIO_TASK_DEFAULT_ENTRY_SOURCE: &str = "at_voice_command"; @@ -566,6 +568,10 @@ struct ImageTaskInput { model: Option, #[serde(default, alias = "session_id")] session_id: Option, + #[serde(default, alias = "thread_id")] + thread_id: Option, + #[serde(default, alias = "turn_id")] + turn_id: Option, #[serde(default, alias = "project_id")] project_id: Option, #[serde(default, alias = "content_id")] @@ -631,6 +637,10 @@ struct AudioTaskInput { model: Option, #[serde(default, alias = "session_id")] session_id: Option, + #[serde(default, alias = "thread_id")] + thread_id: Option, + #[serde(default, alias = "turn_id")] + turn_id: Option, #[serde(default, alias = "project_id")] project_id: Option, #[serde(default, alias = "content_id")] @@ -681,6 +691,10 @@ struct TranscriptionTaskInput { #[serde(default)] session_id: Option, #[serde(default)] + thread_id: Option, + #[serde(default)] + turn_id: Option, + #[serde(default)] project_id: Option, #[serde(default)] content_id: Option, @@ -856,6 +870,22 @@ fn image_task_input_schema() -> serde_json::Value { "session_id", serde_json::json!({ "type": "string", "description": "会话 ID(snake_case 兼容,可选)。" }), ); + insert_property( + "threadId", + serde_json::json!({ "type": "string", "description": "线程 ID(可选)。" }), + ); + insert_property( + "thread_id", + serde_json::json!({ "type": "string", "description": "线程 ID(snake_case 兼容,可选)。" }), + ); + insert_property( + "turnId", + serde_json::json!({ "type": "string", "description": "回合 ID(可选)。" }), + ); + insert_property( + "turn_id", + serde_json::json!({ "type": "string", "description": "回合 ID(snake_case 兼容,可选)。" }), + ); insert_property( "projectId", serde_json::json!({ "type": "string", "description": "项目 ID(可选)。" }), @@ -1054,6 +1084,8 @@ fn build_image_generation_task_request( provider_id, model, session_id, + thread_id, + turn_id, project_id, content_id, entry_source, @@ -1082,6 +1114,8 @@ fn build_image_generation_task_request( Some(value.to_string()) } }); + let thread_id = resolve_context_environment_id(context, thread_id, THREAD_ID_ENV_KEYS); + let turn_id = resolve_context_environment_id(context, turn_id, TURN_ID_ENV_KEYS); let project_id = project_id.or_else(|| { PROJECT_ID_ENV_KEYS.iter().find_map(|key| { context @@ -1128,6 +1162,8 @@ fn build_image_generation_task_request( provider_id, model, session_id, + thread_id, + turn_id, project_id, content_id, entry_source, @@ -1237,6 +1273,10 @@ fn audio_task_input_schema() -> serde_json::Value { "model": { "type": "string", "description": "模型名(可选)。" }, "sessionId": { "type": "string", "description": "会话 ID(可选)。" }, "session_id": { "type": "string", "description": "会话 ID(snake_case 兼容,可选)。" }, + "threadId": { "type": "string", "description": "线程 ID(可选)。" }, + "thread_id": { "type": "string", "description": "线程 ID(snake_case 兼容,可选)。" }, + "turnId": { "type": "string", "description": "回合 ID(可选)。" }, + "turn_id": { "type": "string", "description": "回合 ID(snake_case 兼容,可选)。" }, "projectId": { "type": "string", "description": "项目 ID(可选)。" }, "project_id": { "type": "string", "description": "项目 ID(snake_case 兼容,可选)。" }, "contentId": { "type": "string", "description": "内容 ID(可选)。" }, @@ -1313,6 +1353,8 @@ fn build_audio_generation_task_request( provider_id: input.provider_id, model: input.model, session_id: resolve_context_session_id(context, input.session_id), + thread_id: resolve_context_environment_id(context, input.thread_id, THREAD_ID_ENV_KEYS), + turn_id: resolve_context_environment_id(context, input.turn_id, TURN_ID_ENV_KEYS), project_id: resolve_context_environment_id(context, input.project_id, PROJECT_ID_ENV_KEYS), content_id: resolve_context_environment_id(context, input.content_id, CONTENT_ID_ENV_KEYS), entry_source: input @@ -1428,6 +1470,8 @@ impl Tool for LimeCreateTranscriptionTaskTool { "providerId": { "type": "string", "description": "Provider 标识(可选)。" }, "model": { "type": "string", "description": "模型名(可选)。" }, "sessionId": { "type": "string", "description": "会话 ID(可选)。" }, + "threadId": { "type": "string", "description": "线程 ID(可选)。" }, + "turnId": { "type": "string", "description": "回合 ID(可选)。" }, "projectId": { "type": "string", "description": "项目 ID(可选)。" }, "contentId": { "type": "string", "description": "内容 ID(可选)。" }, "entrySource": { "type": "string", "description": "入口来源(可选)。" }, @@ -1480,6 +1524,8 @@ impl Tool for LimeCreateTranscriptionTaskTool { provider_id: input.provider_id, model: input.model, session_id: input.session_id, + thread_id: input.thread_id, + turn_id: input.turn_id, project_id: input.project_id, content_id: input.content_id, entry_source: input.entry_source, @@ -1961,6 +2007,14 @@ mod tests { "LIME_CONTENT_ID".to_string(), "content-image-compat-1".to_string(), ), + ( + "LIME_THREAD_ID".to_string(), + "thread-image-compat-1".to_string(), + ), + ( + "LIME_TURN_ID".to_string(), + "turn-image-compat-1".to_string(), + ), ])); let request = build_image_generation_task_request( &context, @@ -1986,6 +2040,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana-pro".to_string()), session_id: None, + thread_id: None, + turn_id: None, project_id: None, content_id: None, entry_source: None, @@ -2016,6 +2072,8 @@ mod tests { request.session_id.as_deref(), Some("session-image-compat-1") ); + assert_eq!(request.thread_id.as_deref(), Some("thread-image-compat-1")); + assert_eq!(request.turn_id.as_deref(), Some("turn-image-compat-1")); assert_eq!( request.project_id.as_deref(), Some("project-image-compat-1") @@ -2133,6 +2191,8 @@ mod tests { .with_environment(std::collections::HashMap::from([ ("LIME_PROJECT_ID".to_string(), "project-audio-1".to_string()), ("LIME_CONTENT_ID".to_string(), "content-audio-1".to_string()), + ("LIME_THREAD_ID".to_string(), "thread-audio-1".to_string()), + ("LIME_TURN_ID".to_string(), "turn-audio-1".to_string()), ])); let request = build_audio_generation_task_request( &context, @@ -2149,6 +2209,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("voice-pro".to_string()), session_id: None, + thread_id: None, + turn_id: None, project_id: None, content_id: None, entry_source: None, @@ -2167,6 +2229,8 @@ mod tests { temp_dir.path().to_string_lossy().to_string() ); assert_eq!(request.session_id.as_deref(), Some("session-audio-1")); + assert_eq!(request.thread_id.as_deref(), Some("thread-audio-1")); + assert_eq!(request.turn_id.as_deref(), Some("turn-audio-1")); assert_eq!(request.project_id.as_deref(), Some("project-audio-1")); assert_eq!(request.content_id.as_deref(), Some("content-audio-1")); assert_eq!(request.entry_source.as_deref(), Some("at_voice_command")); diff --git a/src-tauri/src/commands/capability_draft_cmd.rs b/src-tauri/src/commands/capability_draft_cmd.rs new file mode 100644 index 000000000..934c1d492 --- /dev/null +++ b/src-tauri/src/commands/capability_draft_cmd.rs @@ -0,0 +1,54 @@ +//! Capability Draft 命令薄适配层。 +//! +//! 业务规则集中在 `capability_draft_service`,这里不注册、不执行未验证草案。 + +use crate::services::capability_draft_service::{ + create_capability_draft, get_capability_draft, list_capability_drafts, + list_workspace_registered_skills, register_capability_draft, verify_capability_draft, + CapabilityDraftRecord, CreateCapabilityDraftRequest, GetCapabilityDraftRequest, + ListCapabilityDraftsRequest, ListWorkspaceRegisteredSkillsRequest, + RegisterCapabilityDraftRequest, RegisterCapabilityDraftResult, VerifyCapabilityDraftRequest, + VerifyCapabilityDraftResult, WorkspaceRegisteredSkillRecord, +}; + +#[tauri::command] +pub fn capability_draft_create( + request: CreateCapabilityDraftRequest, +) -> Result { + create_capability_draft(request) +} + +#[tauri::command] +pub fn capability_draft_list( + request: ListCapabilityDraftsRequest, +) -> Result, String> { + list_capability_drafts(request) +} + +#[tauri::command] +pub fn capability_draft_get( + request: GetCapabilityDraftRequest, +) -> Result, String> { + get_capability_draft(request) +} + +#[tauri::command] +pub fn capability_draft_verify( + request: VerifyCapabilityDraftRequest, +) -> Result { + verify_capability_draft(request) +} + +#[tauri::command] +pub fn capability_draft_register( + request: RegisterCapabilityDraftRequest, +) -> Result { + register_capability_draft(request) +} + +#[tauri::command] +pub fn capability_draft_list_registered_skills( + request: ListWorkspaceRegisteredSkillsRequest, +) -> Result, String> { + list_workspace_registered_skills(request) +} diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index b65d0a571..3a7b0953f 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -123,6 +123,10 @@ pub struct CreateImageGenerationTaskArtifactRequest { pub model: Option, #[serde(default)] pub session_id: Option, + #[serde(default, alias = "thread_id")] + pub thread_id: Option, + #[serde(default, alias = "turn_id")] + pub turn_id: Option, #[serde(default)] pub project_id: Option, #[serde(default)] @@ -187,6 +191,10 @@ pub struct CreateAudioGenerationTaskArtifactRequest { pub model: Option, #[serde(default, alias = "session_id")] pub session_id: Option, + #[serde(default, alias = "thread_id")] + pub thread_id: Option, + #[serde(default, alias = "turn_id")] + pub turn_id: Option, #[serde(default, alias = "project_id")] pub project_id: Option, #[serde(default, alias = "content_id")] @@ -237,6 +245,10 @@ pub struct CreateTranscriptionTaskArtifactRequest { pub model: Option, #[serde(default, alias = "session_id")] pub session_id: Option, + #[serde(default, alias = "thread_id")] + pub thread_id: Option, + #[serde(default, alias = "turn_id")] + pub turn_id: Option, #[serde(default, alias = "project_id")] pub project_id: Option, #[serde(default, alias = "content_id")] @@ -319,6 +331,18 @@ pub struct MediaTaskModalityRuntimeContractIndexEntry { pub task_type: String, pub normalized_status: String, pub contract_key: Option, + pub entry_key: Option, + pub thread_id: Option, + pub turn_id: Option, + pub content_id: Option, + pub modality: Option, + pub skill_id: Option, + pub model_id: Option, + pub cost_state: Option, + pub limit_state: Option, + pub estimated_cost_class: Option, + pub limit_event_kind: Option, + pub quota_low: Option, pub routing_slot: Option, pub provider_id: Option, pub model: Option, @@ -400,8 +424,22 @@ pub struct MediaTaskLimeCorePolicyEvaluationStatusCount { pub struct MediaTaskModalityRuntimeContractIndex { pub snapshot_count: usize, pub contract_keys: Vec, + pub entry_keys: Vec, + pub thread_ids: Vec, + pub turn_ids: Vec, + pub content_ids: Vec, + pub modalities: Vec, + pub skill_ids: Vec, + pub model_ids: Vec, + pub cost_states: Vec, + pub limit_states: Vec, + pub estimated_cost_classes: Vec, + pub limit_event_kinds: Vec, + pub quota_low_count: usize, pub execution_profile_keys: Vec, pub executor_adapter_keys: Vec, + pub executor_kinds: Vec, + pub executor_binding_keys: Vec, pub limecore_policy_refs: Vec, pub limecore_policy_snapshot_count: usize, pub limecore_policy_snapshot_statuses: Vec, @@ -698,6 +736,8 @@ fn build_image_task_idempotency_key( let storyboard_slots_payload = build_storyboard_slots_payload(storyboard_slots); let fingerprint = json!({ "session_id": normalize_optional_string(request.session_id.clone()), + "thread_id": normalize_optional_string(request.thread_id.clone()), + "turn_id": normalize_optional_string(request.turn_id.clone()), "project_id": normalize_optional_string(request.project_id.clone()), "content_id": normalize_optional_string(request.content_id.clone()), "entry_source": normalize_optional_string(request.entry_source.clone()), @@ -734,6 +774,8 @@ fn build_audio_task_idempotency_key( ) -> Result { let fingerprint = json!({ "session_id": normalize_optional_string(request.session_id.clone()), + "thread_id": normalize_optional_string(request.thread_id.clone()), + "turn_id": normalize_optional_string(request.turn_id.clone()), "project_id": normalize_optional_string(request.project_id.clone()), "content_id": normalize_optional_string(request.content_id.clone()), "entry_source": normalize_optional_string(request.entry_source.clone()), @@ -763,6 +805,8 @@ fn build_transcription_task_idempotency_key( ) -> Result { let fingerprint = json!({ "session_id": normalize_optional_string(request.session_id.clone()), + "thread_id": normalize_optional_string(request.thread_id.clone()), + "turn_id": normalize_optional_string(request.turn_id.clone()), "project_id": normalize_optional_string(request.project_id.clone()), "content_id": normalize_optional_string(request.content_id.clone()), "entry_source": normalize_optional_string(request.entry_source.clone()), @@ -1484,6 +1528,46 @@ fn media_task_contract_key(output: &MediaTaskOutput) -> Option { .map(ToString::to_string) } +fn media_task_entry_key(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string( + &output.record.payload, + &["entry_key", "entryKey", "entry_source", "entrySource"], + ) + .map(ToString::to_string) +} + +fn media_task_thread_id(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["thread_id", "threadId"]) + .map(ToString::to_string) +} + +fn media_task_turn_id(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["turn_id", "turnId"]) + .map(ToString::to_string) +} + +fn media_task_content_id(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["content_id", "contentId"]) + .map(ToString::to_string) +} + +fn media_task_modality(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["modality"]) + .or_else(|| { + media_task_runtime_contract(output) + .and_then(|value| value.get("modality")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .map(ToString::to_string) +} + +fn media_task_model_id(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["model_id", "modelId", "model"]) + .map(ToString::to_string) +} + fn media_task_runtime_contract(output: &MediaTaskOutput) -> Option<&serde_json::Value> { output .record @@ -1592,6 +1676,166 @@ fn media_task_executor_binding_key(output: &MediaTaskOutput) -> Option { .map(ToString::to_string) } +fn media_task_skill_id( + output: &MediaTaskOutput, + executor_kind: Option<&str>, + executor_binding_key: Option<&str>, +) -> Option { + read_image_task_payload_string( + &output.record.payload, + &["skill_id", "skillId", "service_skill_id", "serviceSkillId"], + ) + .map(ToString::to_string) + .or_else(|| match executor_kind { + Some("skill") | Some("service_skill") => executor_binding_key.map(ToString::to_string), + _ => None, + }) +} + +fn read_json_string_from_keys<'a>(value: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> { + keys.iter() + .filter_map(|key| value.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn read_json_object_from_keys<'a>( + value: &'a serde_json::Value, + keys: &[&str], +) -> Option<&'a serde_json::Value> { + keys.iter() + .filter_map(|key| value.get(*key)) + .find(|value| value.is_object()) +} + +fn read_json_bool_from_keys(value: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter() + .filter_map(|key| value.get(*key)) + .find_map(serde_json::Value::as_bool) +} + +fn media_task_runtime_summary(output: &MediaTaskOutput) -> Option<&serde_json::Value> { + read_json_object_from_keys( + &output.record.payload, + &["runtime_summary", "runtimeSummary"], + ) +} + +fn media_task_task_profile(output: &MediaTaskOutput) -> Option<&serde_json::Value> { + read_json_object_from_keys(&output.record.payload, &["task_profile", "taskProfile"]) +} + +fn media_task_cost_state_object(output: &MediaTaskOutput) -> Option<&serde_json::Value> { + read_json_object_from_keys(&output.record.payload, &["cost_state", "costState"]).or_else(|| { + media_task_task_profile(output) + .and_then(|profile| read_json_object_from_keys(profile, &["cost_state", "costState"])) + }) +} + +fn media_task_limit_state_object(output: &MediaTaskOutput) -> Option<&serde_json::Value> { + read_json_object_from_keys(&output.record.payload, &["limit_state", "limitState"]).or_else( + || { + media_task_task_profile(output).and_then(|profile| { + read_json_object_from_keys(profile, &["limit_state", "limitState"]) + }) + }, + ) +} + +fn media_task_limit_event_object(output: &MediaTaskOutput) -> Option<&serde_json::Value> { + read_json_object_from_keys(&output.record.payload, &["limit_event", "limitEvent"]).or_else( + || { + media_task_limit_state_object(output).and_then(|limit_state| { + read_json_object_from_keys(limit_state, &["limit_event", "limitEvent", "event"]) + }) + }, + ) +} + +fn media_task_cost_state(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["cost_state", "costState"]) + .map(ToString::to_string) + .or_else(|| { + media_task_cost_state_object(output) + .and_then(|cost_state| read_json_string_from_keys(cost_state, &["status", "state"])) + .or_else(|| { + media_task_runtime_summary(output).and_then(|summary| { + read_json_string_from_keys(summary, &["cost_status", "costStatus"]) + }) + }) + .map(ToString::to_string) + }) +} + +fn media_task_estimated_cost_class(output: &MediaTaskOutput) -> Option { + media_task_cost_state_object(output) + .and_then(|cost_state| { + read_json_string_from_keys( + cost_state, + &[ + "estimated_cost_class", + "estimatedCostClass", + "cost_class", + "costClass", + ], + ) + }) + .or_else(|| { + media_task_runtime_summary(output).and_then(|summary| { + read_json_string_from_keys(summary, &["estimated_cost_class", "estimatedCostClass"]) + }) + }) + .map(ToString::to_string) +} + +fn media_task_limit_state(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["limit_state", "limitState"]) + .map(ToString::to_string) + .or_else(|| { + media_task_limit_state_object(output) + .and_then(|limit_state| { + read_json_string_from_keys( + limit_state, + &["status", "state", "limit_status", "limitStatus"], + ) + }) + .or_else(|| { + media_task_runtime_summary(output).and_then(|summary| { + read_json_string_from_keys(summary, &["limit_status", "limitStatus"]) + }) + }) + .map(ToString::to_string) + }) +} + +fn media_task_limit_event_kind(output: &MediaTaskOutput) -> Option { + media_task_limit_event_object(output) + .and_then(|limit_event| { + read_json_string_from_keys(limit_event, &["event_kind", "eventKind", "kind"]) + }) + .or_else(|| { + media_task_runtime_summary(output).and_then(|summary| { + read_json_string_from_keys(summary, &["limit_event_kind", "limitEventKind"]) + }) + }) + .map(ToString::to_string) +} + +fn media_task_quota_low(output: &MediaTaskOutput, limit_event_kind: Option<&str>) -> Option { + media_task_limit_event_object(output) + .and_then(|limit_event| read_json_bool_from_keys(limit_event, &["quota_low", "quotaLow"])) + .or_else(|| { + media_task_runtime_summary(output) + .and_then(|summary| read_json_bool_from_keys(summary, &["quota_low", "quotaLow"])) + }) + .or_else(|| { + limit_event_kind + .map(|kind| kind.trim() == "quota_low") + .filter(|value| *value) + }) +} + fn read_payload_string_array_from_keys(payload: &serde_json::Value, keys: &[&str]) -> Vec { for key in keys { if let Some(values) = read_image_task_payload_string_array(payload, key) { @@ -2242,8 +2486,22 @@ fn build_modality_runtime_contract_index( tasks: &[MediaTaskOutput], ) -> MediaTaskModalityRuntimeContractIndex { let mut contract_keys = Vec::new(); + let mut entry_keys = Vec::new(); + let mut thread_ids = Vec::new(); + let mut turn_ids = Vec::new(); + let mut content_ids = Vec::new(); + let mut modalities = Vec::new(); + let mut skill_ids = Vec::new(); + let mut model_ids = Vec::new(); + let mut cost_states = Vec::new(); + let mut limit_states = Vec::new(); + let mut estimated_cost_classes = Vec::new(); + let mut limit_event_kinds = Vec::new(); + let mut quota_low_count = 0usize; let mut execution_profile_keys = Vec::new(); let mut executor_adapter_keys = Vec::new(); + let mut executor_kinds = Vec::new(); + let mut executor_binding_keys = Vec::new(); let mut limecore_policy_refs = Vec::new(); let mut limecore_policy_snapshot_count = 0; let mut limecore_policy_snapshot_statuses = Vec::new(); @@ -2277,10 +2535,40 @@ fn build_modality_runtime_contract_index( } push_unique_string(&mut contract_keys, contract_key.clone()); + let entry_key = media_task_entry_key(output); + push_unique_string(&mut entry_keys, entry_key.clone()); + let thread_id = media_task_thread_id(output); + let turn_id = media_task_turn_id(output); + let content_id = media_task_content_id(output); + push_unique_string(&mut thread_ids, thread_id.clone()); + push_unique_string(&mut turn_ids, turn_id.clone()); + push_unique_string(&mut content_ids, content_id.clone()); + let modality = media_task_modality(output); + let model_id = media_task_model_id(output); + push_unique_string(&mut modalities, modality.clone()); + push_unique_string(&mut model_ids, model_id.clone()); let execution_profile_key = media_task_execution_profile_key(output); let executor_adapter_key = media_task_executor_adapter_key(output); let executor_kind = media_task_executor_kind(output); let executor_binding_key = media_task_executor_binding_key(output); + let skill_id = media_task_skill_id( + output, + executor_kind.as_deref(), + executor_binding_key.as_deref(), + ); + push_unique_string(&mut skill_ids, skill_id.clone()); + let cost_state = media_task_cost_state(output); + let estimated_cost_class = media_task_estimated_cost_class(output); + let limit_state = media_task_limit_state(output); + let limit_event_kind = media_task_limit_event_kind(output); + let quota_low = media_task_quota_low(output, limit_event_kind.as_deref()); + push_unique_string(&mut cost_states, cost_state.clone()); + push_unique_string(&mut estimated_cost_classes, estimated_cost_class.clone()); + push_unique_string(&mut limit_states, limit_state.clone()); + push_unique_string(&mut limit_event_kinds, limit_event_kind.clone()); + if quota_low == Some(true) { + quota_low_count += 1; + } let task_limecore_policy_refs = media_task_limecore_policy_refs(output); let limecore_policy_snapshot_status = media_task_limecore_policy_snapshot_status(output, &task_limecore_policy_refs); @@ -2345,6 +2633,8 @@ fn build_modality_runtime_contract_index( media_task_limecore_policy_value_hit_count(output); push_unique_string(&mut execution_profile_keys, execution_profile_key.clone()); push_unique_string(&mut executor_adapter_keys, executor_adapter_key.clone()); + push_unique_string(&mut executor_kinds, executor_kind.clone()); + push_unique_string(&mut executor_binding_keys, executor_binding_key.clone()); push_unique_string_values(&mut limecore_policy_refs, task_limecore_policy_refs.clone()); push_unique_string_values( &mut limecore_policy_unresolved_refs, @@ -2450,6 +2740,18 @@ fn build_modality_runtime_contract_index( task_type: output.task_type.clone(), normalized_status: output.normalized_status.clone(), contract_key, + entry_key, + thread_id, + turn_id, + content_id, + modality, + skill_id, + model_id, + cost_state, + limit_state, + estimated_cost_class, + limit_event_kind, + quota_low, routing_slot: read_image_task_payload_string(payload, &["routing_slot"]) .map(ToString::to_string), provider_id: read_image_task_payload_string(payload, &["provider_id", "providerId"]) @@ -2526,8 +2828,22 @@ fn build_modality_runtime_contract_index( MediaTaskModalityRuntimeContractIndex { snapshot_count: snapshots.len(), contract_keys, + entry_keys, + thread_ids, + turn_ids, + content_ids, + modalities, + skill_ids, + model_ids, + cost_states, + limit_states, + estimated_cost_classes, + limit_event_kinds, + quota_low_count, execution_profile_keys, executor_adapter_keys, + executor_kinds, + executor_binding_keys, limecore_policy_refs, limecore_policy_snapshot_count, limecore_policy_snapshot_statuses, @@ -4283,6 +4599,8 @@ pub(crate) fn create_image_generation_task_artifact_inner( let raw_text = normalize_optional_string(request.raw_text.clone()); let layout_hint = normalize_optional_string(request.layout_hint.clone()); let session_id = normalize_optional_string(request.session_id.clone()); + let thread_id = normalize_optional_string(request.thread_id.clone()); + let turn_id = normalize_optional_string(request.turn_id.clone()); let project_id = normalize_optional_string(request.project_id.clone()); let content_id = normalize_optional_string(request.content_id.clone()); let entry_source = normalize_optional_string(request.entry_source.clone()); @@ -4342,6 +4660,8 @@ pub(crate) fn create_image_generation_task_artifact_inner( "count": count, "usage": usage, "session_id": session_id, + "thread_id": thread_id, + "turn_id": turn_id, "project_id": project_id, "content_id": content_id, "entry_source": entry_source, @@ -4397,6 +4717,8 @@ pub(crate) fn create_audio_generation_task_artifact_inner( &audio_preference_defaults, ); let session_id = normalize_optional_string(request.session_id.clone()); + let thread_id = normalize_optional_string(request.thread_id.clone()); + let turn_id = normalize_optional_string(request.turn_id.clone()); let project_id = normalize_optional_string(request.project_id.clone()); let content_id = normalize_optional_string(request.content_id.clone()); let entry_source = normalize_optional_string(request.entry_source.clone()) @@ -4446,6 +4768,8 @@ pub(crate) fn create_audio_generation_task_artifact_inner( "provider_id": provider_id, "model": model, "session_id": session_id, + "thread_id": thread_id, + "turn_id": turn_id, "project_id": project_id, "content_id": content_id, "entry_source": entry_source, @@ -4558,6 +4882,8 @@ pub(crate) fn create_transcription_task_artifact_inner( let provider_id = normalize_optional_string(request.provider_id.clone()); let model = normalize_optional_string(request.model.clone()); let session_id = normalize_optional_string(request.session_id.clone()); + let thread_id = normalize_optional_string(request.thread_id.clone()); + let turn_id = normalize_optional_string(request.turn_id.clone()); let project_id = normalize_optional_string(request.project_id.clone()); let content_id = normalize_optional_string(request.content_id.clone()); let entry_source = normalize_optional_string(request.entry_source.clone()) @@ -4616,6 +4942,8 @@ pub(crate) fn create_transcription_task_artifact_inner( "provider_id": provider_id, "model": model, "session_id": session_id, + "thread_id": thread_id, + "turn_id": turn_id, "project_id": project_id, "content_id": content_id, "entry_source": entry_source, @@ -4910,6 +5238,8 @@ mod tests { provider_id: Some("fal".to_string()), model: model.map(ToString::to_string), session_id: Some("session-image-contract-1".to_string()), + thread_id: Some("thread-image-contract-1".to_string()), + turn_id: Some("turn-image-contract-1".to_string()), project_id: Some("project-image-contract-1".to_string()), content_id: Some("content-image-contract-1".to_string()), entry_source: Some("at_image_command".to_string()), @@ -4950,9 +5280,73 @@ mod tests { "capability": "image_generation" } })]); + created.record.payload["cost_state"] = json!({ + "status": "estimated", + "estimatedCostClass": "low" + }); + created.record.payload["limit_state"] = json!({ + "status": "within_limit" + }); + created.record.payload["limit_event"] = json!({ + "eventKind": "quota_low" + }); let index = build_modality_runtime_contract_index(&[created]); + assert_eq!(index.entry_keys, vec!["at_image_command".to_string()]); + assert_eq!( + index.thread_ids, + vec!["thread-image-contract-1".to_string()] + ); + assert_eq!(index.turn_ids, vec!["turn-image-contract-1".to_string()]); + assert_eq!( + index.content_ids, + vec!["content-image-contract-1".to_string()] + ); + assert_eq!(index.modalities, vec!["image".to_string()]); + assert_eq!(index.skill_ids, vec!["image_generate".to_string()]); + assert_eq!(index.model_ids, vec!["gpt-image-1".to_string()]); + assert_eq!(index.cost_states, vec!["estimated".to_string()]); + assert_eq!(index.limit_states, vec!["within_limit".to_string()]); + assert_eq!(index.estimated_cost_classes, vec!["low".to_string()]); + assert_eq!(index.limit_event_kinds, vec!["quota_low".to_string()]); + assert_eq!(index.quota_low_count, 1); + assert_eq!( + index.snapshots[0].entry_key.as_deref(), + Some("at_image_command") + ); + assert_eq!( + index.snapshots[0].thread_id.as_deref(), + Some("thread-image-contract-1") + ); + assert_eq!( + index.snapshots[0].turn_id.as_deref(), + Some("turn-image-contract-1") + ); + assert_eq!( + index.snapshots[0].content_id.as_deref(), + Some("content-image-contract-1") + ); + assert_eq!(index.snapshots[0].modality.as_deref(), Some("image")); + assert_eq!( + index.snapshots[0].skill_id.as_deref(), + Some("image_generate") + ); + assert_eq!(index.snapshots[0].model_id.as_deref(), Some("gpt-image-1")); + assert_eq!(index.snapshots[0].cost_state.as_deref(), Some("estimated")); + assert_eq!( + index.snapshots[0].limit_state.as_deref(), + Some("within_limit") + ); + assert_eq!( + index.snapshots[0].estimated_cost_class.as_deref(), + Some("low") + ); + assert_eq!( + index.snapshots[0].limit_event_kind.as_deref(), + Some("quota_low") + ); + assert_eq!(index.snapshots[0].quota_low, Some(true)); assert_eq!(index.limecore_policy_value_hit_count, 1); assert_eq!( index.limecore_policy_evaluation_statuses[0].status, @@ -5125,6 +5519,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana".to_string()), session_id: Some("session-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-1".to_string()), content_id: Some("content-1".to_string()), entry_source: Some("at_image_command".to_string()), @@ -5187,6 +5583,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana".to_string()), session_id: Some("session-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-1".to_string()), content_id: Some("content-1".to_string()), entry_source: Some("at_image_command".to_string()), @@ -5333,6 +5731,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("voice-pro".to_string()), session_id: Some("session-voice-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-voice-1".to_string()), content_id: Some("content-voice-1".to_string()), entry_source: None, @@ -5362,6 +5762,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("voice-pro".to_string()), session_id: Some("session-voice-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-voice-1".to_string()), content_id: Some("content-voice-1".to_string()), entry_source: None, @@ -5634,6 +6036,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("voice-pro".to_string()), session_id: None, + thread_id: None, + turn_id: None, project_id: None, content_id: None, entry_source: None, @@ -5679,6 +6083,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("asr-pro".to_string()), session_id: Some("session-transcription-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-transcription-1".to_string()), content_id: Some("content-transcription-1".to_string()), entry_source: None, @@ -5706,6 +6112,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("asr-pro".to_string()), session_id: Some("session-transcription-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-transcription-1".to_string()), content_id: Some("content-transcription-1".to_string()), entry_source: None, @@ -5920,6 +6328,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("asr-pro".to_string()), session_id: None, + thread_id: None, + turn_id: None, project_id: None, content_id: None, entry_source: None, @@ -5967,6 +6377,8 @@ mod tests { provider_id: Some("openai-asr".to_string()), model: Some("whisper-1".to_string()), session_id: Some("session-transcription-worker-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-transcription-worker-1".to_string()), content_id: Some("content-transcription-worker-1".to_string()), entry_source: None, @@ -6116,6 +6528,8 @@ mod tests { provider_id: Some("openai-asr".to_string()), model: Some("whisper-1".to_string()), session_id: Some("session-transcription-worker-success".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-transcription-worker-success".to_string()), content_id: Some("content-transcription-worker-success".to_string()), entry_source: None, @@ -6236,6 +6650,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("voice-pro".to_string()), session_id: Some("session-voice-complete".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-voice-complete".to_string()), content_id: Some("content-voice-complete".to_string()), entry_source: None, @@ -6373,6 +6789,8 @@ mod tests { provider_id: Some("limecore".to_string()), model: Some("voice-pro".to_string()), session_id: Some("session-audio-worker-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-audio-worker-1".to_string()), content_id: Some("content-audio-worker-1".to_string()), entry_source: None, @@ -6554,6 +6972,8 @@ mod tests { provider_id: Some("openai-tts".to_string()), model: Some("gpt-4o-mini-tts".to_string()), session_id: Some("session-audio-worker-success".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-audio-worker-success".to_string()), content_id: Some("content-audio-worker-success".to_string()), entry_source: None, @@ -6986,6 +7406,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/flux-pro".to_string()), session_id: Some("session-2".to_string()), + thread_id: Some("thread-2".to_string()), + turn_id: Some("turn-2".to_string()), project_id: Some("project-2".to_string()), content_id: Some("content-2".to_string()), entry_source: Some("at_image_command".to_string()), @@ -7030,6 +7452,34 @@ mod tests { listed.modality_runtime_contracts.contract_keys, vec![IMAGE_GENERATION_CONTRACT_KEY.to_string()] ); + assert_eq!( + listed.modality_runtime_contracts.entry_keys, + vec!["at_image_command".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.thread_ids, + vec!["thread-2".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.turn_ids, + vec!["turn-2".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.content_ids, + vec!["content-2".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.modalities, + vec!["image".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.skill_ids, + vec!["image_generate".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.model_ids, + vec!["fal-ai/flux-pro".to_string()] + ); assert_eq!( listed.modality_runtime_contracts.execution_profile_keys, vec![IMAGE_GENERATION_EXECUTION_PROFILE_KEY.to_string()] @@ -7038,6 +7488,14 @@ mod tests { listed.modality_runtime_contracts.executor_adapter_keys, vec![IMAGE_GENERATION_EXECUTOR_ADAPTER_KEY.to_string()] ); + assert_eq!( + listed.modality_runtime_contracts.executor_kinds, + vec!["skill".to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.executor_binding_keys, + vec![IMAGE_GENERATION_EXECUTOR_BINDING_KEY.to_string()] + ); assert_eq!( listed.modality_runtime_contracts.limecore_policy_refs, IMAGE_GENERATION_LIMECORE_POLICY_REFS @@ -7091,6 +7549,48 @@ mod tests { .as_str(), "accepted" ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .entry_key + .as_deref(), + Some("at_image_command") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .thread_id + .as_deref(), + Some("thread-2") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .turn_id + .as_deref(), + Some("turn-2") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .content_id + .as_deref(), + Some("content-2") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .modality + .as_deref(), + Some("image") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .skill_id + .as_deref(), + Some("image_generate") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .model_id + .as_deref(), + Some("fal-ai/flux-pro") + ); assert_eq!( listed.modality_runtime_contracts.snapshots[0] .execution_profile_key @@ -7281,6 +7781,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana".to_string()), session_id: Some("session-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-1".to_string()), content_id: Some("content-1".to_string()), entry_source: Some("at_image_command".to_string()), @@ -7325,6 +7827,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana".to_string()), session_id: Some("session-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-1".to_string()), content_id: Some("content-1".to_string()), entry_source: Some("at_image_command".to_string()), @@ -7372,6 +7876,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana-pro".to_string()), session_id: Some("session-image-worker-1".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-image-worker-1".to_string()), content_id: Some("content-image-worker-1".to_string()), entry_source: Some("at_image_command".to_string()), @@ -7572,6 +8078,8 @@ mod tests { provider_id: Some("fal".to_string()), model: Some("fal-ai/nano-banana-pro".to_string()), session_id: Some("session-image-worker-2".to_string()), + thread_id: None, + turn_id: None, project_id: Some("project-image-worker-2".to_string()), content_id: Some("content-image-worker-2".to_string()), entry_source: Some("at_image_command".to_string()), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d1e852b5c..bb132078c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod browser_connector_cmd; pub mod browser_environment_cmd; pub mod browser_profile_cmd; pub mod browser_runtime_cmd; +pub mod capability_draft_cmd; pub mod companion_cmd; pub mod config_cmd; pub mod connect_cmd; diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index bda84439d..d08a74fb1 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -6,6 +6,7 @@ mod agent_sessions; mod app_runtime; mod automation; mod browser; +mod capability_drafts; mod channels; mod companion; mod content; @@ -189,6 +190,10 @@ pub async fn handle_command( return Ok(result); } + if let Some(result) = capability_drafts::try_handle(cmd, args.as_ref())? { + return Ok(result); + } + Err(format!( "[DevBridge] 未知命令: '{cmd}'. 如需此命令,请将其添加到 dispatcher.rs 的 handle_command 函数中。" ) diff --git a/src-tauri/src/dev_bridge/dispatcher/capability_drafts.rs b/src-tauri/src/dev_bridge/dispatcher/capability_drafts.rs new file mode 100644 index 000000000..105e0567d --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/capability_drafts.rs @@ -0,0 +1,70 @@ +use super::{args_or_default, parse_nested_arg}; +use crate::commands::capability_draft_cmd::{ + capability_draft_create, capability_draft_get, capability_draft_list, + capability_draft_list_registered_skills, capability_draft_register, capability_draft_verify, +}; +use crate::services::capability_draft_service::{ + CreateCapabilityDraftRequest, GetCapabilityDraftRequest, ListCapabilityDraftsRequest, + ListWorkspaceRegisteredSkillsRequest, RegisterCapabilityDraftRequest, + VerifyCapabilityDraftRequest, +}; +use serde_json::Value as JsonValue; + +pub(super) fn try_handle( + cmd: &str, + args: Option<&JsonValue>, +) -> Result, Box> { + let result = match cmd { + "capability_draft_create" => { + let args = args_or_default(args); + let request: CreateCapabilityDraftRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value( + capability_draft_create(request) + .map_err(|error| format!("创建 Capability Draft 失败: {error}"))?, + )? + } + "capability_draft_list" => { + let args = args_or_default(args); + let request: ListCapabilityDraftsRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value( + capability_draft_list(request) + .map_err(|error| format!("读取 Capability Draft 列表失败: {error}"))?, + )? + } + "capability_draft_get" => { + let args = args_or_default(args); + let request: GetCapabilityDraftRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value( + capability_draft_get(request) + .map_err(|error| format!("读取 Capability Draft 失败: {error}"))?, + )? + } + "capability_draft_verify" => { + let args = args_or_default(args); + let request: VerifyCapabilityDraftRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value( + capability_draft_verify(request) + .map_err(|error| format!("验证 Capability Draft 失败: {error}"))?, + )? + } + "capability_draft_register" => { + let args = args_or_default(args); + let request: RegisterCapabilityDraftRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value( + capability_draft_register(request) + .map_err(|error| format!("注册 Capability Draft 失败: {error}"))?, + )? + } + "capability_draft_list_registered_skills" => { + let args = args_or_default(args); + let request: ListWorkspaceRegisteredSkillsRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value( + capability_draft_list_registered_skills(request) + .map_err(|error| format!("读取 Workspace 已注册能力失败: {error}"))?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/services/agent_timeline_service.rs b/src-tauri/src/services/agent_timeline_service.rs index d05a19ce3..2f732df77 100644 --- a/src-tauri/src/services/agent_timeline_service.rs +++ b/src-tauri/src/services/agent_timeline_service.rs @@ -1,8 +1,8 @@ use chrono::Utc; use lime_agent::AgentEvent as RuntimeAgentEvent; use lime_core::database::dao::agent_timeline::{ - AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, AgentThreadTurn, - AgentThreadTurnStatus, AgentTimelineDao, + AgentRequestQuestion, AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, + AgentThreadTurn, AgentThreadTurnStatus, AgentTimelineDao, }; use lime_core::database::{lock_db, DbConnection}; use serde_json::Value; @@ -235,6 +235,30 @@ impl AgentTimelineRecorder { Ok(()) } + pub fn record_request_user_input( + &mut self, + app: &AppHandle, + event_name: &str, + request_id: String, + action_type: String, + prompt: Option, + questions: Option>, + ) -> Result<(), String> { + let item = self.build_item( + request_id.clone(), + AgentThreadItemStatus::InProgress, + None, + AgentThreadItemPayload::RequestUserInput { + request_id, + action_type, + prompt, + questions, + response: None, + }, + ); + self.persist_and_emit_item(app, event_name, item) + } + pub fn complete_turn_success(&mut self) -> Result, String> { let now = Utc::now().to_rfc3339(); self.turn.status = AgentThreadTurnStatus::Completed; diff --git a/src-tauri/src/services/capability_draft_service.rs b/src-tauri/src/services/capability_draft_service.rs new file mode 100644 index 000000000..7c87eccbc --- /dev/null +++ b/src-tauri/src/services/capability_draft_service.rs @@ -0,0 +1,1900 @@ +//! Capability Draft 文件事实源服务。 +//! +//! P1A / P1B 只负责创建、读取、列出和静态验证草案;不注册 Skill,也不进入执行面。 + +use chrono::Utc; +use lime_core::models::{ + parse_skill_manifest_from_content, SkillResourceSummary, SkillStandardCompliance, +}; +use lime_services::skill_service::SkillService; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use uuid::Uuid; + +const DRAFTS_RELATIVE_DIR: &str = ".lime/capability-drafts"; +const MANIFEST_FILE_NAME: &str = "manifest.json"; +const VERIFICATION_DIR_NAME: &str = "verification"; +const LATEST_VERIFICATION_FILE_NAME: &str = "latest.json"; +const REGISTRATION_DIR_NAME: &str = "registration"; +const LATEST_REGISTRATION_FILE_NAME: &str = "latest.json"; +const REGISTERED_SKILLS_ROOT_DIR_NAME: &str = ".agents"; +const REGISTERED_SKILLS_DIR_NAME: &str = "skills"; +const SKILL_REGISTRATION_METADATA_DIR_NAME: &str = ".lime"; +const SKILL_REGISTRATION_METADATA_FILE_NAME: &str = "registration.json"; +const MAX_GENERATED_FILES: usize = 32; +const MAX_FILE_BYTES: usize = 256 * 1024; +const MAX_TOTAL_BYTES: usize = 1024 * 1024; +const MAX_TEXT_FIELD_CHARS: usize = 4096; +const MIN_SKILL_MD_CHARS: usize = 40; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityDraftStatus { + Unverified, + FailedSelfCheck, + VerificationFailed, + VerifiedPendingRegistration, + Registered, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityDraftVerificationRunStatus { + Passed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityDraftVerificationCheckStatus { + Passed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftVerificationCheck { + pub id: String, + pub label: String, + pub status: CapabilityDraftVerificationCheckStatus, + pub message: String, + pub suggestions: Vec, + pub can_agent_repair: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftVerificationSummary { + pub report_id: String, + pub status: CapabilityDraftVerificationRunStatus, + pub summary: String, + pub checked_at: String, + pub failed_check_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftVerificationReport { + #[serde(flatten)] + pub summary: CapabilityDraftVerificationSummary, + pub draft_id: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftRegistrationSummary { + pub registration_id: String, + pub registered_at: String, + pub skill_directory: String, + pub registered_skill_directory: String, + pub source_draft_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_verification_report_id: Option, + pub generated_file_count: usize, + pub permission_summary: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftFileInput { + #[serde(alias = "relative_path")] + pub relative_path: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftFileSummary { + #[serde(alias = "relative_path")] + pub relative_path: String, + pub byte_length: usize, + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftManifest { + pub draft_id: String, + pub name: String, + pub description: String, + pub user_goal: String, + pub source_kind: String, + pub source_refs: Vec, + pub permission_summary: Vec, + pub generated_files: Vec, + pub verification_status: CapabilityDraftStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_verification: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_registration: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CapabilityDraftRecord { + #[serde(flatten)] + pub manifest: CapabilityDraftManifest, + pub draft_root: String, + pub manifest_path: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreateCapabilityDraftRequest { + #[serde(alias = "workspace_root")] + pub workspace_root: String, + pub name: String, + pub description: String, + #[serde(alias = "user_goal")] + pub user_goal: String, + #[serde(default = "default_source_kind", alias = "source_kind")] + pub source_kind: String, + #[serde(default, alias = "source_refs")] + pub source_refs: Vec, + #[serde(default, alias = "permission_summary")] + pub permission_summary: Vec, + #[serde(default, alias = "generated_files")] + pub generated_files: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ListCapabilityDraftsRequest { + #[serde(alias = "workspace_root")] + pub workspace_root: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GetCapabilityDraftRequest { + #[serde(alias = "workspace_root")] + pub workspace_root: String, + #[serde(alias = "draft_id")] + pub draft_id: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VerifyCapabilityDraftRequest { + #[serde(alias = "workspace_root")] + pub workspace_root: String, + #[serde(alias = "draft_id")] + pub draft_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VerifyCapabilityDraftResult { + pub draft: CapabilityDraftRecord, + pub report: CapabilityDraftVerificationReport, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegisterCapabilityDraftRequest { + #[serde(alias = "workspace_root")] + pub workspace_root: String, + #[serde(alias = "draft_id")] + pub draft_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RegisterCapabilityDraftResult { + pub draft: CapabilityDraftRecord, + pub registration: CapabilityDraftRegistrationSummary, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ListWorkspaceRegisteredSkillsRequest { + #[serde(alias = "workspace_root")] + pub workspace_root: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceRegisteredSkillRecord { + pub key: String, + pub name: String, + pub description: String, + pub directory: String, + pub registered_skill_directory: String, + pub registration: CapabilityDraftRegistrationSummary, + pub permission_summary: Vec, + pub metadata: HashMap, + pub allowed_tools: Vec, + pub resource_summary: SkillResourceSummary, + pub standard_compliance: SkillStandardCompliance, + pub launch_enabled: bool, + pub runtime_gate: String, +} + +struct PreparedDraftFile { + relative_path: String, + output_path: PathBuf, + content: String, + summary: CapabilityDraftFileSummary, +} + +fn default_source_kind() -> String { + "manual".to_string() +} + +fn now_iso8601() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +fn normalize_required_text(value: &str, field: &str) -> Result { + let normalized = value.replace('\r', "").trim().to_string(); + if normalized.is_empty() { + return Err(format!("{field} 不能为空")); + } + if normalized.chars().count() > MAX_TEXT_FIELD_CHARS { + return Err(format!("{field} 过长,最多 {MAX_TEXT_FIELD_CHARS} 个字符")); + } + Ok(normalized) +} + +fn normalize_string_list(values: &[String], field: &str) -> Result, String> { + let mut normalized = Vec::new(); + let mut seen = HashSet::new(); + for value in values { + let item = value.replace('\r', "").trim().to_string(); + if item.is_empty() { + continue; + } + if item.chars().count() > MAX_TEXT_FIELD_CHARS { + return Err(format!("{field} 中存在过长条目")); + } + if seen.insert(item.clone()) { + normalized.push(item); + } + } + Ok(normalized) +} + +fn resolve_workspace_root(workspace_root: &str) -> Result { + let raw = workspace_root.trim(); + if raw.is_empty() { + return Err("workspaceRoot 不能为空".to_string()); + } + + let path = PathBuf::from(raw); + if !path.is_absolute() { + return Err("workspaceRoot 必须是绝对路径".to_string()); + } + if !path.exists() { + return Err(format!("工作区根目录不存在: {raw}")); + } + if !path.is_dir() { + return Err(format!("workspaceRoot 不是目录: {raw}")); + } + + fs::canonicalize(&path).map_err(|error| format!("解析工作区根目录失败: {error}")) +} + +fn drafts_root_for_workspace(workspace_root: &Path) -> PathBuf { + workspace_root.join(DRAFTS_RELATIVE_DIR) +} + +fn validate_draft_id(draft_id: &str) -> Result { + let normalized = draft_id.trim(); + if normalized.is_empty() { + return Err("draftId 不能为空".to_string()); + } + if normalized.len() > 96 { + return Err("draftId 过长".to_string()); + } + if normalized == "." || normalized == ".." { + return Err("draftId 不合法".to_string()); + } + if !normalized + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err("draftId 只能包含字母、数字、短横线和下划线".to_string()); + } + Ok(normalized.to_string()) +} + +fn validate_relative_path(relative_path: &str) -> Result { + let raw = relative_path.trim(); + if raw.is_empty() { + return Err("生成文件 relativePath 不能为空".to_string()); + } + if raw.contains('\\') || raw.contains(':') || raw.chars().any(char::is_control) { + return Err(format!("生成文件路径不允许包含平台相关或控制字符: {raw}")); + } + + let path = Path::new(raw); + if path.is_absolute() { + return Err(format!("生成文件路径必须是相对路径: {raw}")); + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(segment) => normalized.push(segment), + Component::CurDir + | Component::ParentDir + | Component::RootDir + | Component::Prefix(_) => { + return Err(format!("生成文件路径不能包含 .、.. 或根路径: {raw}")); + } + } + } + + let normalized_text = normalized.to_string_lossy().replace('\\', "/"); + if normalized_text.is_empty() || normalized_text == MANIFEST_FILE_NAME { + return Err("manifest.json 由 Capability Draft 服务维护,不能作为生成文件写入".to_string()); + } + + Ok(normalized) +} + +fn sha256_hex(content: &str) -> String { + let digest = Sha256::digest(content.as_bytes()); + format!("{digest:x}") +} + +fn prepare_generated_files( + draft_root: &Path, + files: &[CapabilityDraftFileInput], +) -> Result, String> { + if files.is_empty() { + return Err("至少需要 1 个生成文件,P1A 不创建空草案".to_string()); + } + if files.len() > MAX_GENERATED_FILES { + return Err(format!("生成文件过多,最多 {MAX_GENERATED_FILES} 个")); + } + + let mut total_bytes = 0usize; + let mut seen = HashSet::new(); + let mut prepared = Vec::with_capacity(files.len()); + + for file in files { + let relative_path = validate_relative_path(&file.relative_path)?; + let relative_text = relative_path.to_string_lossy().replace('\\', "/"); + if !seen.insert(relative_text.clone()) { + return Err(format!("生成文件路径重复: {relative_text}")); + } + + let byte_length = file.content.as_bytes().len(); + if byte_length > MAX_FILE_BYTES { + return Err(format!( + "生成文件 {relative_text} 过大,最多 {MAX_FILE_BYTES} 字节" + )); + } + total_bytes = total_bytes.saturating_add(byte_length); + if total_bytes > MAX_TOTAL_BYTES { + return Err(format!("生成文件总大小过大,最多 {MAX_TOTAL_BYTES} 字节")); + } + + let output_path = draft_root.join(&relative_path); + if !output_path.starts_with(draft_root) { + return Err(format!("生成文件路径逃逸 draft root: {relative_text}")); + } + + prepared.push(PreparedDraftFile { + relative_path: relative_text.clone(), + output_path, + content: file.content.clone(), + summary: CapabilityDraftFileSummary { + relative_path: relative_text, + byte_length, + sha256: sha256_hex(&file.content), + }, + }); + } + + Ok(prepared) +} + +fn write_manifest(path: &Path, manifest: &CapabilityDraftManifest) -> Result<(), String> { + let content = serde_json::to_string_pretty(manifest) + .map_err(|error| format!("序列化 capability draft manifest 失败: {error}"))?; + let temp_path = path.with_extension("json.tmp"); + fs::write(&temp_path, content) + .map_err(|error| format!("写入 capability draft manifest 临时文件失败: {error}"))?; + fs::rename(&temp_path, path) + .map_err(|error| format!("替换 capability draft manifest 失败: {error}")) +} + +fn read_manifest(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 capability draft manifest 失败: {error}"))?; + serde_json::from_str(&content) + .map_err(|error| format!("解析 capability draft manifest 失败: {error}")) +} + +fn verification_report_path(draft_root: &Path) -> PathBuf { + draft_root + .join(VERIFICATION_DIR_NAME) + .join(LATEST_VERIFICATION_FILE_NAME) +} + +fn registration_report_path(draft_root: &Path) -> PathBuf { + draft_root + .join(REGISTRATION_DIR_NAME) + .join(LATEST_REGISTRATION_FILE_NAME) +} + +fn write_verification_report( + path: &Path, + report: &CapabilityDraftVerificationReport, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 capability draft verification 目录失败: {error}"))?; + } + let content = serde_json::to_string_pretty(report) + .map_err(|error| format!("序列化 capability draft verification report 失败: {error}"))?; + let temp_path = path.with_extension("json.tmp"); + fs::write(&temp_path, content) + .map_err(|error| format!("写入 capability draft verification 临时文件失败: {error}"))?; + fs::rename(&temp_path, path) + .map_err(|error| format!("替换 capability draft verification report 失败: {error}")) +} + +fn write_registration_summary( + path: &Path, + summary: &CapabilityDraftRegistrationSummary, +) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建 capability draft registration 目录失败: {error}"))?; + } + let content = serde_json::to_string_pretty(summary) + .map_err(|error| format!("序列化 capability draft registration summary 失败: {error}"))?; + let temp_path = path.with_extension("json.tmp"); + fs::write(&temp_path, content) + .map_err(|error| format!("写入 capability draft registration 临时文件失败: {error}"))?; + fs::rename(&temp_path, path) + .map_err(|error| format!("替换 capability draft registration summary 失败: {error}")) +} + +fn read_registration_summary(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| format!("读取 capability draft registration summary 失败: {error}"))?; + serde_json::from_str(&content) + .map_err(|error| format!("解析 capability draft registration summary 失败: {error}")) +} + +fn to_record(draft_root: &Path, manifest: CapabilityDraftManifest) -> CapabilityDraftRecord { + CapabilityDraftRecord { + manifest, + draft_root: draft_root.to_string_lossy().to_string(), + manifest_path: draft_root + .join(MANIFEST_FILE_NAME) + .to_string_lossy() + .to_string(), + } +} + +fn workspace_registered_skills_root(workspace_root: &Path) -> PathBuf { + workspace_root + .join(REGISTERED_SKILLS_ROOT_DIR_NAME) + .join(REGISTERED_SKILLS_DIR_NAME) +} + +fn skill_directory_for_draft(draft_id: &str) -> Result { + let normalized = validate_draft_id(draft_id)?; + let suffix = normalized.strip_prefix("capdraft-").unwrap_or(&normalized); + let short = suffix + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .take(12) + .collect::(); + if short.is_empty() { + return Err("无法从 draftId 派生 Skill 目录名".to_string()); + } + Ok(format!("capability-{short}")) +} + +fn validate_agent_skill_standard(skill_dir: &Path) -> Result<(), String> { + let inspection = SkillService::inspect_skill_dir(skill_dir) + .map_err(|error| format!("Agent Skills 标准检查失败: {error}"))?; + if inspection.standard_compliance.validation_errors.is_empty() { + return Ok(()); + } + Err(format!( + "Agent Skills 标准检查未通过: {}", + inspection.standard_compliance.validation_errors.join(";") + )) +} + +fn copy_registered_skill_files( + draft_root: &Path, + target_dir: &Path, + manifest: &CapabilityDraftManifest, +) -> Result<(), String> { + if let Some(parent) = target_dir.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建注册 Skill 根目录失败 {}: {error}", parent.display()))?; + } + fs::create_dir(target_dir).map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + format!("Workspace Skill 目录已存在: {}", target_dir.display()) + } else { + format!("创建注册 Skill 目录失败 {}: {error}", target_dir.display()) + } + })?; + + for file in &manifest.generated_files { + let relative_path = validate_relative_path(&file.relative_path)?; + let source_path = draft_root.join(&relative_path); + let target_path = target_dir.join(&relative_path); + if !source_path.starts_with(draft_root) || !target_path.starts_with(target_dir) { + return Err(format!("注册文件路径逃逸: {}", file.relative_path)); + } + + let metadata = fs::symlink_metadata(&source_path) + .map_err(|error| format!("读取注册源文件失败 {}: {error}", file.relative_path))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "注册源文件不允许是 symlink: {}", + file.relative_path + )); + } + if !metadata.is_file() { + return Err(format!("注册源路径不是文件: {}", file.relative_path)); + } + + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建注册目标父目录失败 {}: {error}", parent.display()))?; + } + fs::copy(&source_path, &target_path).map_err(|error| { + format!( + "复制注册文件失败 {} -> {}: {error}", + source_path.display(), + target_path.display() + ) + })?; + } + + Ok(()) +} + +fn verification_check( + id: &str, + label: &str, + passed: bool, + message: impl Into, + suggestions: Vec, +) -> CapabilityDraftVerificationCheck { + CapabilityDraftVerificationCheck { + id: id.to_string(), + label: label.to_string(), + status: if passed { + CapabilityDraftVerificationCheckStatus::Passed + } else { + CapabilityDraftVerificationCheckStatus::Failed + }, + message: message.into(), + suggestions, + can_agent_repair: !passed, + } +} + +fn relative_path_matches(relative_path: &str, candidates: &[&str]) -> bool { + candidates.iter().any(|candidate| { + relative_path == *candidate || relative_path.ends_with(&format!("/{candidate}")) + }) +} + +fn find_generated_file<'a>( + manifest: &'a CapabilityDraftManifest, + candidates: &[&str], +) -> Option<&'a CapabilityDraftFileSummary> { + manifest + .generated_files + .iter() + .find(|file| relative_path_matches(&file.relative_path, candidates)) +} + +fn read_generated_file_text( + draft_root: &Path, + file: &CapabilityDraftFileSummary, +) -> Result { + let relative_path = validate_relative_path(&file.relative_path)?; + let path = draft_root.join(relative_path); + if !path.starts_with(draft_root) { + return Err(format!( + "生成文件路径逃逸 draft root: {}", + file.relative_path + )); + } + fs::read_to_string(&path).map_err(|error| format!("读取 {} 失败: {error}", file.relative_path)) +} + +fn validate_manifest_file_integrity( + draft_root: &Path, + manifest: &CapabilityDraftManifest, +) -> Result<(), Vec> { + let mut issues = Vec::new(); + let mut seen = HashSet::new(); + + for file in &manifest.generated_files { + let relative_path = match validate_relative_path(&file.relative_path) { + Ok(path) => path, + Err(error) => { + issues.push(error); + continue; + } + }; + if !seen.insert(file.relative_path.clone()) { + issues.push(format!("文件清单重复: {}", file.relative_path)); + } + let path = draft_root.join(relative_path); + if !path.starts_with(draft_root) { + issues.push(format!("文件清单路径逃逸: {}", file.relative_path)); + continue; + } + let content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) => { + issues.push(format!("读取 {} 失败: {error}", file.relative_path)); + continue; + } + }; + let byte_length = content.as_bytes().len(); + if byte_length != file.byte_length { + issues.push(format!( + "{} 字节数不一致,manifest={} actual={}", + file.relative_path, file.byte_length, byte_length + )); + } + let sha256 = sha256_hex(&content); + if sha256 != file.sha256 { + issues.push(format!("{} sha256 不一致", file.relative_path)); + } + } + + if issues.is_empty() { + Ok(()) + } else { + Err(issues) + } +} + +fn permission_text(manifest: &CapabilityDraftManifest) -> String { + manifest + .permission_summary + .iter() + .map(|item| item.to_lowercase()) + .collect::>() + .join("\n") +} + +fn permission_declares_local_cli(permission_text: &str) -> bool { + [ + "cli", + "local command", + "local cli", + "本地命令", + "本地 cli", + "命令", + ] + .iter() + .any(|needle| permission_text.contains(needle)) +} + +fn scan_static_risks( + draft_root: &Path, + manifest: &CapabilityDraftManifest, +) -> Result<(), Vec> { + let permissions = permission_text(manifest); + let local_cli_declared = permission_declares_local_cli(&permissions); + let mut issues = Vec::new(); + + for file in &manifest.generated_files { + let content = match read_generated_file_text(draft_root, file) { + Ok(content) => content, + Err(error) => { + issues.push(error); + continue; + } + }; + let lower = content.to_lowercase(); + let path = &file.relative_path; + + for token in [ + "rm -rf", + "fs.rm(", + "fs.rmsync(", + "unlink(", + "remove_file", + "deleteobject", + ] { + if lower.contains(token) { + issues.push(format!("{path} 命中删除类危险 token: {token}")); + } + } + + for token in [ + "npm install", + "pnpm add", + "yarn add", + "pip install", + "cargo add", + ] { + if lower.contains(token) { + issues.push(format!("{path} 命中依赖安装 token: {token}")); + } + } + + for token in [ + "child_process.exec", + "execsync(", + "shell: true", + "curl -x post", + "curl -x put", + "curl -x patch", + "curl -x delete", + "method: \"post\"", + "method: 'post'", + "method: \"put\"", + "method: 'put'", + "method: \"patch\"", + "method: 'patch'", + "method: \"delete\"", + "method: 'delete'", + "axios.post", + "axios.put", + "axios.patch", + "axios.delete", + ] { + if lower.contains(token) { + issues.push(format!("{path} 命中外部写 / shell 字符串 token: {token}")); + } + } + + for token in [ + "payment", + "charge", + "place_order", + "create_order", + "create_listing", + "publish_listing", + "update_price", + ] { + if lower.contains(token) { + issues.push(format!("{path} 命中高风险业务动作 token: {token}")); + } + } + + let declares_cli_token = [ + "child_process.spawn", + "spawn(", + "execfile(", + "std::process::command", + "command::new", + ] + .iter() + .any(|token| lower.contains(token)); + + if declares_cli_token && !local_cli_declared { + issues.push(format!( + "{path} 出现本地 CLI 执行,但 permissionSummary 未声明本地命令权限" + )); + } + } + + if issues.is_empty() { + Ok(()) + } else { + Err(issues) + } +} + +fn run_capability_draft_static_checks( + draft_root: &Path, + manifest: &CapabilityDraftManifest, +) -> Vec { + let mut checks = Vec::new(); + let skill_file = find_generated_file(manifest, &["SKILL.md"]); + + match validate_manifest_file_integrity(draft_root, manifest) { + Ok(()) if skill_file.is_some() => checks.push(verification_check( + "package_structure", + "包结构", + true, + "manifest 文件清单与磁盘一致,且包含 SKILL.md。", + Vec::new(), + )), + Ok(()) => checks.push(verification_check( + "package_structure", + "包结构", + false, + "文件清单缺少 SKILL.md。", + vec!["补齐 SKILL.md,并确保它进入 generatedFiles 清单。".to_string()], + )), + Err(issues) => checks.push(verification_check( + "package_structure", + "包结构", + false, + issues.join(";"), + vec![ + "重新生成或修复 manifest 文件清单,确保路径、字节数和 sha256 与磁盘一致。" + .to_string(), + ], + )), + } + + let skill_quality = skill_file + .and_then(|file| read_generated_file_text(draft_root, file).ok()) + .map(|content| { + let trimmed = content.trim(); + trimmed.chars().count() >= MIN_SKILL_MD_CHARS + && (trimmed.contains("##") + || trimmed.contains("步骤") + || trimmed.contains("输入") + || trimmed.contains("输出") + || trimmed.to_lowercase().contains("when")) + }) + .unwrap_or(false); + checks.push(verification_check( + "skill_readme_quality", + "Skill 说明质量", + skill_quality, + if skill_quality { + "SKILL.md 包含基本说明,可供后续人工复核。" + } else { + "SKILL.md 过短或缺少输入、输出、步骤、触发条件等可读说明。" + }, + vec!["补齐触发条件、输入、执行步骤、输出、失败回退和权限边界。".to_string()], + )); + + let has_input_contract = find_generated_file( + manifest, + &[ + "contract/input.schema.json", + "contracts/input.schema.json", + "input.schema.json", + "input.schema.yaml", + "input.schema.yml", + ], + ) + .is_some(); + checks.push(verification_check( + "input_contract", + "输入 contract", + has_input_contract, + if has_input_contract { + "已找到输入 contract。" + } else { + "缺少输入 contract。" + }, + vec!["新增 contract/input.schema.json,描述必填输入、类型和约束。".to_string()], + )); + + let has_output_contract = find_generated_file( + manifest, + &[ + "contract/output.schema.json", + "contracts/output.schema.json", + "output.schema.json", + "output.schema.yaml", + "output.schema.yml", + ], + ) + .is_some(); + checks.push(verification_check( + "output_contract", + "输出 contract", + has_output_contract, + if has_output_contract { + "已找到输出 contract。" + } else { + "缺少输出 contract。" + }, + vec!["新增 contract/output.schema.json,描述产物、错误和输出字段。".to_string()], + )); + + let has_permission_summary = !manifest.permission_summary.is_empty(); + checks.push(verification_check( + "permission_declaration", + "权限声明", + has_permission_summary, + if has_permission_summary { + "已声明权限摘要。" + } else { + "缺少权限摘要,无法判断草案是否只读、是否写文件或是否调用本地命令。" + }, + vec![ + "补充 permissionSummary,明确只读发现、草案内写入、本地 CLI、网络和外部写边界。" + .to_string(), + ], + )); + + match scan_static_risks(draft_root, manifest) { + Ok(()) => checks.push(verification_check( + "static_risk_scan", + "静态风险扫描", + true, + "未发现删除、依赖安装、HTTP 写操作、任意 shell 字符串或高风险业务动作 token。", + Vec::new(), + )), + Err(issues) => checks.push(verification_check( + "static_risk_scan", + "静态风险扫描", + false, + issues.join(";"), + vec![ + "移除高风险动作,或拆成后续需要人工确认 / policy gate 的能力。".to_string(), + "如果只是只读 CLI,请使用结构化参数并在 permissionSummary 中声明本地命令边界。" + .to_string(), + ], + )), + } + + let has_fixture = manifest.generated_files.iter().any(|file| { + file.relative_path.starts_with("tests/") || file.relative_path.starts_with("examples/") + }); + checks.push(verification_check( + "fixture_presence", + "fixture / example", + has_fixture, + if has_fixture { + "已找到 tests/ 或 examples/,可作为后续 dry-run 输入。" + } else { + "缺少 tests/ 或 examples/,后续无法做可重复 dry-run。" + }, + vec!["新增 examples/input.sample.json 或 tests/fixture.test.*。".to_string()], + )); + + checks +} + +pub fn create_capability_draft( + request: CreateCapabilityDraftRequest, +) -> Result { + let workspace_root = resolve_workspace_root(&request.workspace_root)?; + let drafts_root = drafts_root_for_workspace(&workspace_root); + let draft_id = format!("capdraft-{}", Uuid::new_v4().simple()); + let draft_root = drafts_root.join(&draft_id); + if draft_root.exists() { + return Err(format!("Capability Draft 已存在: {draft_id}")); + } + + let name = normalize_required_text(&request.name, "name")?; + let description = normalize_required_text(&request.description, "description")?; + let user_goal = normalize_required_text(&request.user_goal, "userGoal")?; + let source_kind = normalize_required_text(&request.source_kind, "sourceKind")?; + let source_refs = normalize_string_list(&request.source_refs, "sourceRefs")?; + let permission_summary = + normalize_string_list(&request.permission_summary, "permissionSummary")?; + let prepared_files = prepare_generated_files(&draft_root, &request.generated_files)?; + + fs::create_dir_all(&draft_root) + .map_err(|error| format!("创建 capability draft 目录失败: {error}"))?; + + for file in &prepared_files { + if let Some(parent) = file.output_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建生成文件父目录失败: {error}"))?; + } + fs::write(&file.output_path, &file.content) + .map_err(|error| format!("写入生成文件 {} 失败: {error}", file.relative_path))?; + } + + let now = now_iso8601(); + let manifest = CapabilityDraftManifest { + draft_id, + name, + description, + user_goal, + source_kind, + source_refs, + permission_summary, + generated_files: prepared_files + .into_iter() + .map(|file| file.summary) + .collect(), + verification_status: CapabilityDraftStatus::Unverified, + last_verification: None, + last_registration: None, + created_at: now.clone(), + updated_at: now, + }; + + let manifest_path = draft_root.join(MANIFEST_FILE_NAME); + write_manifest(&manifest_path, &manifest)?; + + Ok(to_record(&draft_root, manifest)) +} + +pub fn list_capability_drafts( + request: ListCapabilityDraftsRequest, +) -> Result, String> { + let workspace_root = resolve_workspace_root(&request.workspace_root)?; + let drafts_root = drafts_root_for_workspace(&workspace_root); + if !drafts_root.exists() { + return Ok(Vec::new()); + } + + let mut records = Vec::new(); + let entries = fs::read_dir(&drafts_root) + .map_err(|error| format!("读取 capability drafts 目录失败: {error}"))?; + for entry in entries { + let entry = entry.map_err(|error| format!("读取 capability draft 目录项失败: {error}"))?; + let draft_root = entry.path(); + if !draft_root.is_dir() { + continue; + } + let manifest_path = draft_root.join(MANIFEST_FILE_NAME); + if !manifest_path.is_file() { + continue; + } + let manifest = read_manifest(&manifest_path)?; + records.push(to_record(&draft_root, manifest)); + } + + records.sort_by(|left, right| { + right + .manifest + .updated_at + .cmp(&left.manifest.updated_at) + .then_with(|| left.manifest.draft_id.cmp(&right.manifest.draft_id)) + }); + + Ok(records) +} + +pub fn get_capability_draft( + request: GetCapabilityDraftRequest, +) -> Result, String> { + let workspace_root = resolve_workspace_root(&request.workspace_root)?; + let draft_id = validate_draft_id(&request.draft_id)?; + let draft_root = drafts_root_for_workspace(&workspace_root).join(&draft_id); + let manifest_path = draft_root.join(MANIFEST_FILE_NAME); + if !manifest_path.is_file() { + return Ok(None); + } + + let manifest = read_manifest(&manifest_path)?; + Ok(Some(to_record(&draft_root, manifest))) +} + +pub fn verify_capability_draft( + request: VerifyCapabilityDraftRequest, +) -> Result { + let workspace_root = resolve_workspace_root(&request.workspace_root)?; + let draft_id = validate_draft_id(&request.draft_id)?; + let draft_root = drafts_root_for_workspace(&workspace_root).join(&draft_id); + let manifest_path = draft_root.join(MANIFEST_FILE_NAME); + if !manifest_path.is_file() { + return Err(format!("Capability Draft 不存在: {draft_id}")); + } + + let mut manifest = read_manifest(&manifest_path)?; + if manifest.draft_id != draft_id { + return Err(format!( + "Capability Draft ID 不一致: path={draft_id} manifest={}", + manifest.draft_id + )); + } + + let checks = run_capability_draft_static_checks(&draft_root, &manifest); + let failed_check_count = checks + .iter() + .filter(|check| check.status == CapabilityDraftVerificationCheckStatus::Failed) + .count(); + let checked_at = now_iso8601(); + let run_status = if failed_check_count == 0 { + CapabilityDraftVerificationRunStatus::Passed + } else { + CapabilityDraftVerificationRunStatus::Failed + }; + let summary_text = if failed_check_count == 0 { + "最小 verification gate 通过,等待后续注册阶段。".to_string() + } else { + format!("最小 verification gate 未通过,{failed_check_count} 项检查失败。") + }; + let summary = CapabilityDraftVerificationSummary { + report_id: format!("capver-{}", Uuid::new_v4().simple()), + status: run_status, + summary: summary_text, + checked_at, + failed_check_count, + }; + let report = CapabilityDraftVerificationReport { + summary: summary.clone(), + draft_id: draft_id.clone(), + checks, + }; + + write_verification_report(&verification_report_path(&draft_root), &report)?; + + manifest.verification_status = if failed_check_count == 0 { + CapabilityDraftStatus::VerifiedPendingRegistration + } else { + CapabilityDraftStatus::VerificationFailed + }; + manifest.last_verification = Some(summary); + manifest.updated_at = now_iso8601(); + write_manifest(&manifest_path, &manifest)?; + + Ok(VerifyCapabilityDraftResult { + draft: to_record(&draft_root, manifest), + report, + }) +} + +pub fn register_capability_draft( + request: RegisterCapabilityDraftRequest, +) -> Result { + let workspace_root = resolve_workspace_root(&request.workspace_root)?; + let draft_id = validate_draft_id(&request.draft_id)?; + let draft_root = drafts_root_for_workspace(&workspace_root).join(&draft_id); + let manifest_path = draft_root.join(MANIFEST_FILE_NAME); + if !manifest_path.is_file() { + return Err(format!("Capability Draft 不存在: {draft_id}")); + } + + let mut manifest = read_manifest(&manifest_path)?; + if manifest.draft_id != draft_id { + return Err(format!( + "Capability Draft ID 不一致: path={draft_id} manifest={}", + manifest.draft_id + )); + } + if manifest.verification_status != CapabilityDraftStatus::VerifiedPendingRegistration { + return Err(format!( + "Capability Draft 当前状态为 {:?},只有 verified_pending_registration 可以注册", + manifest.verification_status + )); + } + + validate_manifest_file_integrity(&draft_root, &manifest) + .map_err(|issues| format!("注册前文件完整性检查失败: {}", issues.join(";")))?; + validate_agent_skill_standard(&draft_root)?; + + let skill_directory = skill_directory_for_draft(&draft_id)?; + let skills_root = workspace_registered_skills_root(&workspace_root); + let target_dir = skills_root.join(&skill_directory); + if target_dir.exists() { + return Err(format!("Workspace Skill 目录已存在: {skill_directory}")); + } + + let summary = CapabilityDraftRegistrationSummary { + registration_id: format!("capreg-{}", Uuid::new_v4().simple()), + registered_at: now_iso8601(), + skill_directory: skill_directory.clone(), + registered_skill_directory: target_dir.to_string_lossy().to_string(), + source_draft_id: draft_id.clone(), + source_verification_report_id: manifest + .last_verification + .as_ref() + .map(|verification| verification.report_id.clone()), + generated_file_count: manifest.generated_files.len(), + permission_summary: manifest.permission_summary.clone(), + }; + + if let Err(error) = copy_registered_skill_files(&draft_root, &target_dir, &manifest) { + let _ = fs::remove_dir_all(&target_dir); + return Err(error); + } + + let target_registration_path = target_dir + .join(SKILL_REGISTRATION_METADATA_DIR_NAME) + .join(SKILL_REGISTRATION_METADATA_FILE_NAME); + if let Err(error) = write_registration_summary(&target_registration_path, &summary) { + let _ = fs::remove_dir_all(&target_dir); + return Err(error); + } + if let Err(error) = validate_agent_skill_standard(&target_dir) { + let _ = fs::remove_dir_all(&target_dir); + return Err(error); + } + if let Err(error) = write_registration_summary(®istration_report_path(&draft_root), &summary) + { + let _ = fs::remove_dir_all(&target_dir); + return Err(error); + } + + manifest.verification_status = CapabilityDraftStatus::Registered; + manifest.last_registration = Some(summary.clone()); + manifest.updated_at = now_iso8601(); + if let Err(error) = write_manifest(&manifest_path, &manifest) { + let _ = fs::remove_dir_all(&target_dir); + let _ = fs::remove_file(registration_report_path(&draft_root)); + return Err(error); + } + + Ok(RegisterCapabilityDraftResult { + draft: to_record(&draft_root, manifest), + registration: summary, + }) +} + +fn build_workspace_registered_skill_record( + skill_dir: &Path, + directory: String, + registration: CapabilityDraftRegistrationSummary, +) -> Result { + let inspection = SkillService::inspect_skill_dir(skill_dir) + .map_err(|error| format!("检查 Workspace 注册 Skill 失败: {error}"))?; + let parsed_manifest = parse_skill_manifest_from_content(&inspection.content).ok(); + let name = parsed_manifest + .as_ref() + .and_then(|manifest| manifest.metadata.name.clone()) + .unwrap_or_else(|| directory.clone()); + let description = parsed_manifest + .as_ref() + .and_then(|manifest| manifest.metadata.description.clone()) + .unwrap_or_default(); + + Ok(WorkspaceRegisteredSkillRecord { + key: format!("workspace:{directory}"), + name, + description, + directory, + registered_skill_directory: skill_dir.to_string_lossy().to_string(), + permission_summary: registration.permission_summary.clone(), + metadata: inspection.metadata, + allowed_tools: inspection.allowed_tools, + resource_summary: inspection.resource_summary, + standard_compliance: inspection.standard_compliance, + registration, + launch_enabled: false, + runtime_gate: "已注册为 Workspace 本地 Skill 包;进入运行前还需要 P3B runtime binding 与 tool_runtime 授权。" + .to_string(), + }) +} + +pub fn list_workspace_registered_skills( + request: ListWorkspaceRegisteredSkillsRequest, +) -> Result, String> { + let workspace_root = resolve_workspace_root(&request.workspace_root)?; + let skills_root = workspace_registered_skills_root(&workspace_root); + if !skills_root.exists() { + return Ok(Vec::new()); + } + + let root_metadata = fs::symlink_metadata(&skills_root) + .map_err(|error| format!("读取 Workspace Skill 根目录失败: {error}"))?; + if root_metadata.file_type().is_symlink() { + return Err(format!( + "Workspace Skill 根目录不允许是 symlink: {}", + skills_root.display() + )); + } + if !root_metadata.is_dir() { + return Err(format!( + "Workspace Skill 根目录不是目录: {}", + skills_root.display() + )); + } + + let canonical_skills_root = fs::canonicalize(&skills_root) + .map_err(|error| format!("解析 Workspace Skill 根目录失败: {error}"))?; + let mut entries = fs::read_dir(&skills_root) + .map_err(|error| format!("读取 Workspace Skill 目录失败: {error}"))? + .collect::, _>>() + .map_err(|error| format!("读取 Workspace Skill 目录项失败: {error}"))?; + entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_string()); + + let mut records = Vec::new(); + for entry in entries { + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path) + .map_err(|error| format!("读取 Workspace Skill 目录项失败: {error}"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Workspace 注册 Skill 不允许是 symlink: {}", + entry_path.display() + )); + } + if !metadata.is_dir() { + continue; + } + + let skill_md = entry_path.join("SKILL.md"); + let registration_path = entry_path + .join(SKILL_REGISTRATION_METADATA_DIR_NAME) + .join(SKILL_REGISTRATION_METADATA_FILE_NAME); + let skill_md_metadata = match fs::symlink_metadata(&skill_md) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let registration_metadata = match fs::symlink_metadata(®istration_path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if skill_md_metadata.file_type().is_symlink() + || registration_metadata.file_type().is_symlink() + { + return Err(format!( + "Workspace 注册 Skill 元数据不允许是 symlink: {}", + entry_path.display() + )); + } + if !skill_md_metadata.is_file() || !registration_metadata.is_file() { + continue; + } + + let canonical_skill_dir = fs::canonicalize(&entry_path) + .map_err(|error| format!("解析 Workspace 注册 Skill 目录失败: {error}"))?; + if !canonical_skill_dir.starts_with(&canonical_skills_root) { + return Err(format!( + "Workspace 注册 Skill 路径逃逸: {}", + entry_path.display() + )); + } + let canonical_skill_md = fs::canonicalize(&skill_md) + .map_err(|error| format!("解析 Workspace 注册 Skill 说明失败: {error}"))?; + let canonical_registration = fs::canonicalize(®istration_path) + .map_err(|error| format!("解析 Workspace 注册 Skill provenance 失败: {error}"))?; + if !canonical_skill_md.starts_with(&canonical_skill_dir) + || !canonical_registration.starts_with(&canonical_skill_dir) + { + return Err(format!( + "Workspace 注册 Skill 文件路径逃逸: {}", + entry_path.display() + )); + } + + let directory = entry + .file_name() + .to_str() + .ok_or_else(|| "Workspace 注册 Skill 目录名不是 UTF-8".to_string())? + .to_string(); + let registration = read_registration_summary(®istration_path)?; + records.push(build_workspace_registered_skill_record( + &canonical_skill_dir, + directory, + registration, + )?); + } + + records.sort_by(|left, right| { + right + .registration + .registered_at + .cmp(&left.registration.registered_at) + .then_with(|| left.directory.cmp(&right.directory)) + }); + + Ok(records) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_request(root: &Path) -> CreateCapabilityDraftRequest { + CreateCapabilityDraftRequest { + workspace_root: root.to_string_lossy().to_string(), + name: "竞品监控草案".to_string(), + description: "每天汇总竞品价格和上新变化。".to_string(), + user_goal: "持续监控竞品爆款并产出待复核清单。".to_string(), + source_kind: "manual".to_string(), + source_refs: vec!["docs/research/creaoai".to_string()], + permission_summary: vec!["Level 0 只读发现".to_string()], + generated_files: vec![CapabilityDraftFileInput { + relative_path: "SKILL.md".to_string(), + content: "# 竞品监控草案\n\n未验证,只能复核。".to_string(), + }], + } + } + + fn verifiable_request(root: &Path) -> CreateCapabilityDraftRequest { + CreateCapabilityDraftRequest { + workspace_root: root.to_string_lossy().to_string(), + name: "只读 CLI 报告草案".to_string(), + description: "把只读 CLI 输出整理成 Markdown 报告。".to_string(), + user_goal: "每天读取本地 CLI 输出并保存趋势摘要。".to_string(), + source_kind: "cli".to_string(), + source_refs: vec!["trendctl --help".to_string()], + permission_summary: vec![ + "Level 0 只读发现".to_string(), + "允许执行本地 CLI,但只读取输出,不做外部写操作".to_string(), + ], + generated_files: vec![ + CapabilityDraftFileInput { + relative_path: "SKILL.md".to_string(), + content: [ + "# 只读 CLI 报告草案", + "", + "## 何时使用", + "当用户需要把本地只读 CLI 输出整理为 Markdown 报告时使用。", + "", + "## 输入", + "- topic: 报告主题", + "", + "## 输出", + "- markdown_report: 生成的 Markdown 摘要", + ] + .join("\n"), + }, + CapabilityDraftFileInput { + relative_path: "contract/input.schema.json".to_string(), + content: r#"{"type":"object","required":["topic"],"properties":{"topic":{"type":"string"}}}"# + .to_string(), + }, + CapabilityDraftFileInput { + relative_path: "contract/output.schema.json".to_string(), + content: r#"{"type":"object","required":["markdown_report"],"properties":{"markdown_report":{"type":"string"}}}"# + .to_string(), + }, + CapabilityDraftFileInput { + relative_path: "examples/input.sample.json".to_string(), + content: r#"{"topic":"AI Agent"}"#.to_string(), + }, + ], + } + } + + fn standard_verifiable_request(root: &Path) -> CreateCapabilityDraftRequest { + let mut request = verifiable_request(root); + request.generated_files[0].content = [ + "---", + "name: 只读 CLI 报告", + "description: 把本地只读 CLI 输出整理成 Markdown 报告。", + "---", + "", + "# 只读 CLI 报告", + "", + "## 何时使用", + "当用户需要把本地只读 CLI 输出整理为 Markdown 报告时使用。", + "", + "## 输入", + "- topic: 报告主题", + "", + "## 执行步骤", + "1. 读取用户提供的只读 CLI 输出或 fixture。", + "2. 提炼趋势、异常和后续建议。", + "", + "## 输出", + "- markdown_report: 生成的 Markdown 摘要", + ] + .join("\n"); + request + } + + #[test] + fn create_get_and_list_capability_draft() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(sample_request(temp.path())).unwrap(); + + assert_eq!(created.manifest.name, "竞品监控草案"); + assert_eq!( + created.manifest.verification_status, + CapabilityDraftStatus::Unverified + ); + assert_eq!(created.manifest.generated_files.len(), 1); + assert!(created + .draft_root + .contains(".lime/capability-drafts/capdraft-")); + + let skill_path = Path::new(&created.draft_root).join("SKILL.md"); + assert_eq!( + fs::read_to_string(skill_path).unwrap(), + "# 竞品监控草案\n\n未验证,只能复核。" + ); + + let loaded = get_capability_draft(GetCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap() + .unwrap(); + assert_eq!(loaded.manifest.draft_id, created.manifest.draft_id); + + let drafts = list_capability_drafts(ListCapabilityDraftsRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + }) + .unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].manifest.draft_id, created.manifest.draft_id); + } + + #[test] + fn rejects_path_escape_and_platform_specific_paths() { + let temp = TempDir::new().unwrap(); + for relative_path in [ + "../SKILL.md", + "/tmp/SKILL.md", + "scripts\\tool.ts", + "C:foo.ts", + "./SKILL.md", + ] { + let mut request = sample_request(temp.path()); + request.generated_files[0].relative_path = relative_path.to_string(); + let error = create_capability_draft(request).unwrap_err(); + assert!( + error.contains("生成文件路径") || error.contains("manifest.json"), + "unexpected error for {relative_path}: {error}" + ); + } + } + + #[test] + fn rejects_empty_generated_file_set() { + let temp = TempDir::new().unwrap(); + let mut request = sample_request(temp.path()); + request.generated_files = Vec::new(); + + let error = create_capability_draft(request).unwrap_err(); + assert!(error.contains("至少需要 1 个生成文件")); + } + + #[test] + fn verify_capability_draft_marks_complete_draft_pending_registration() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(verifiable_request(temp.path())).unwrap(); + + let result = verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + + assert_eq!( + result.draft.manifest.verification_status, + CapabilityDraftStatus::VerifiedPendingRegistration + ); + assert_eq!( + result.report.summary.status, + CapabilityDraftVerificationRunStatus::Passed + ); + assert_eq!(result.report.summary.failed_check_count, 0); + assert!(result + .report + .checks + .iter() + .all(|check| check.status == CapabilityDraftVerificationCheckStatus::Passed)); + assert!(Path::new(&result.draft.draft_root) + .join("verification/latest.json") + .is_file()); + } + + #[test] + fn verify_capability_draft_fails_without_contracts() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(sample_request(temp.path())).unwrap(); + + let result = verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + + assert_eq!( + result.draft.manifest.verification_status, + CapabilityDraftStatus::VerificationFailed + ); + assert_eq!( + result.report.summary.status, + CapabilityDraftVerificationRunStatus::Failed + ); + assert!(result.report.summary.failed_check_count >= 1); + assert!(result + .report + .checks + .iter() + .any(|check| check.id == "input_contract" + && check.status == CapabilityDraftVerificationCheckStatus::Failed)); + assert!(result + .draft + .manifest + .last_verification + .as_ref() + .is_some_and(|summary| summary.failed_check_count >= 1)); + } + + #[test] + fn verify_capability_draft_rejects_dangerous_tokens() { + let temp = TempDir::new().unwrap(); + let mut request = verifiable_request(temp.path()); + request.generated_files.push(CapabilityDraftFileInput { + relative_path: "scripts/publish.ts".to_string(), + content: "await fetch(url, { method: \"POST\", body });".to_string(), + }); + let created = create_capability_draft(request).unwrap(); + + let result = verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + + assert_eq!( + result.draft.manifest.verification_status, + CapabilityDraftStatus::VerificationFailed + ); + let risk_check = result + .report + .checks + .iter() + .find(|check| check.id == "static_risk_scan") + .unwrap(); + assert_eq!( + risk_check.status, + CapabilityDraftVerificationCheckStatus::Failed + ); + assert!(risk_check.message.contains("method: \"post\"")); + } + + #[test] + fn register_capability_draft_rejects_unverified_draft() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(standard_verifiable_request(temp.path())).unwrap(); + + let error = register_capability_draft(RegisterCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap_err(); + + assert!(error.contains("verified_pending_registration")); + } + + #[test] + fn register_capability_draft_rejects_verification_failed_draft() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(sample_request(temp.path())).unwrap(); + let verified = verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + assert_eq!( + verified.draft.manifest.verification_status, + CapabilityDraftStatus::VerificationFailed + ); + + let error = register_capability_draft(RegisterCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap_err(); + + assert!(error.contains("verified_pending_registration")); + } + + #[test] + fn register_capability_draft_rejects_non_standard_skill() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(verifiable_request(temp.path())).unwrap(); + let verified = verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + assert_eq!( + verified.draft.manifest.verification_status, + CapabilityDraftStatus::VerifiedPendingRegistration + ); + + let error = register_capability_draft(RegisterCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap_err(); + + assert!(error.contains("Agent Skills 标准检查未通过")); + } + + #[test] + fn register_capability_draft_copies_verified_standard_skill() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(standard_verifiable_request(temp.path())).unwrap(); + verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + + let result = register_capability_draft(RegisterCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + + assert_eq!( + result.draft.manifest.verification_status, + CapabilityDraftStatus::Registered + ); + assert_eq!( + result + .draft + .manifest + .last_registration + .as_ref() + .map(|summary| summary.source_draft_id.as_str()), + Some(created.manifest.draft_id.as_str()) + ); + assert!(Path::new(&result.registration.registered_skill_directory) + .join("SKILL.md") + .is_file()); + assert!(Path::new(&result.registration.registered_skill_directory) + .join(SKILL_REGISTRATION_METADATA_DIR_NAME) + .join(SKILL_REGISTRATION_METADATA_FILE_NAME) + .is_file()); + assert!(Path::new(&result.draft.draft_root) + .join("registration/latest.json") + .is_file()); + assert_eq!(result.registration.generated_file_count, 4); + assert!(result.registration.source_verification_report_id.is_some()); + } + + #[test] + fn register_capability_draft_rejects_existing_skill_directory() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(standard_verifiable_request(temp.path())).unwrap(); + verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + let skill_directory = skill_directory_for_draft(&created.manifest.draft_id).unwrap(); + fs::create_dir_all( + temp.path() + .join(REGISTERED_SKILLS_ROOT_DIR_NAME) + .join(REGISTERED_SKILLS_DIR_NAME) + .join(&skill_directory), + ) + .unwrap(); + + let error = register_capability_draft(RegisterCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap_err(); + + assert!(error.contains("Workspace Skill 目录已存在")); + } + + #[test] + fn list_workspace_registered_skills_returns_empty_without_skills_root() { + let temp = TempDir::new().unwrap(); + + let records = list_workspace_registered_skills(ListWorkspaceRegisteredSkillsRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + }) + .unwrap(); + + assert!(records.is_empty()); + } + + #[test] + fn list_workspace_registered_skills_rejects_relative_workspace_root() { + let error = list_workspace_registered_skills(ListWorkspaceRegisteredSkillsRequest { + workspace_root: "relative/workspace".to_string(), + }) + .unwrap_err(); + + assert!(error.contains("workspaceRoot 必须是绝对路径")); + } + + #[test] + fn list_workspace_registered_skills_ignores_standard_skill_without_registration() { + let temp = TempDir::new().unwrap(); + let skill_dir = temp + .path() + .join(REGISTERED_SKILLS_ROOT_DIR_NAME) + .join(REGISTERED_SKILLS_DIR_NAME) + .join("manual-standard-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + [ + "---", + "name: 手工标准 Skill", + "description: 没有 P3A provenance。", + "---", + "", + "# 手工标准 Skill", + ] + .join("\n"), + ) + .unwrap(); + + let records = list_workspace_registered_skills(ListWorkspaceRegisteredSkillsRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + }) + .unwrap(); + + assert!(records.is_empty()); + } + + #[test] + fn list_workspace_registered_skills_discovers_p3a_registered_skill() { + let temp = TempDir::new().unwrap(); + let created = create_capability_draft(standard_verifiable_request(temp.path())).unwrap(); + verify_capability_draft(VerifyCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + let registered = register_capability_draft(RegisterCapabilityDraftRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + draft_id: created.manifest.draft_id.clone(), + }) + .unwrap(); + + let records = list_workspace_registered_skills(ListWorkspaceRegisteredSkillsRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + }) + .unwrap(); + + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record.key, format!("workspace:{}", record.directory)); + assert_eq!(record.name, "只读 CLI 报告"); + assert_eq!( + record.registration.source_draft_id, + created.manifest.draft_id + ); + assert_eq!( + record.registration.skill_directory, + registered.registration.skill_directory + ); + assert!(!record.launch_enabled); + assert!(record.runtime_gate.contains("tool_runtime 授权")); + assert!(record.standard_compliance.is_standard); + assert_eq!( + record.permission_summary, + registered.registration.permission_summary + ); + } + + #[cfg(unix)] + #[test] + fn list_workspace_registered_skills_rejects_symlink_skill_directory() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let skills_root = temp + .path() + .join(REGISTERED_SKILLS_ROOT_DIR_NAME) + .join(REGISTERED_SKILLS_DIR_NAME); + let outside = temp.path().join("outside-skill"); + fs::create_dir_all(&outside).unwrap(); + fs::write( + outside.join("SKILL.md"), + "---\nname: Outside\ndescription: escape\n---\n", + ) + .unwrap(); + fs::create_dir_all(&skills_root).unwrap(); + symlink(&outside, skills_root.join("escape-skill")).unwrap(); + + let error = list_workspace_registered_skills(ListWorkspaceRegisteredSkillsRequest { + workspace_root: temp.path().to_string_lossy().to_string(), + }) + .unwrap_err(); + + assert!(error.contains("不允许是 symlink")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 138b2950a..396c5013a 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -19,6 +19,7 @@ pub mod browser_connector_service; pub mod browser_environment_service; pub mod browser_profile_service; pub mod browser_runtime_window; +pub mod capability_draft_service; pub mod chat_history_service; pub mod companion_service; pub mod conversation_statistics_service; diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index 51391c131..dfe79bf69 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -111,6 +111,9 @@ struct AnalysisContextSummary { decision_reason: String, estimated_cost_class: String, fallback_chain: Vec, + capability_gap: String, + limit_status: String, + user_locked_capability_summary: String, permission_status: String, permission_confirmation_status: String, permission_confirmation_request_id: String, @@ -341,6 +344,53 @@ pub fn export_runtime_analysis_handoff( &permission_confirmation_request_id, &permission_confirmation_source, ); + let capability_gap = value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/capabilityGap") + .unwrap_or(&Value::Null), + ) + .or_else(|| { + value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/limitState/capabilityGap") + .unwrap_or(&Value::Null), + ) + }) + .or_else(|| normalize_optional_text(thread_read.capability_gap.clone())) + .or_else(|| { + thread_read + .limit_state + .as_ref() + .and_then(|value| normalize_optional_text(value.capability_gap.clone())) + }) + .unwrap_or_default(); + let limit_status = value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/limitState/status") + .unwrap_or(&Value::Null), + ) + .or_else(|| { + value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/runtimeSummary/limitStatus") + .unwrap_or(&Value::Null), + ) + }) + .or_else(|| { + thread_read + .limit_state + .as_ref() + .and_then(|value| normalize_optional_text(Some(value.status.clone()))) + }) + .or_else(|| { + thread_read + .runtime_summary + .as_ref() + .and_then(|value| value_string(value.get("limitStatus").unwrap_or(&Value::Null))) + }) + .unwrap_or_default(); + let user_locked_capability_summary = + format_user_locked_capability_summary(&limit_status, &capability_gap); let replay_refs = replay_case .artifacts @@ -461,6 +511,9 @@ pub fn export_runtime_analysis_handoff( .pointer("/runtimeContext/runtimeFacts/fallbackChain") .unwrap_or(&Value::Null), ), + capability_gap, + limit_status, + user_locked_capability_summary, permission_status, permission_confirmation_status, permission_confirmation_request_id, @@ -675,6 +728,10 @@ fn build_analysis_brief( "- 权限确认:{}", empty_fallback(&summary.permission_confirmation_summary, "未导出") ), + format!( + "- 模型锁定能力缺口:{}", + empty_fallback(&summary.user_locked_capability_summary, "未触发") + ), String::new(), "## 证据关联与可观测覆盖".to_string(), String::new(), @@ -820,6 +877,10 @@ fn build_copy_prompt( "- 权限确认:{}", empty_fallback(&summary.permission_confirmation_summary, "未导出") ), + format!( + "- 模型锁定能力缺口:{}", + empty_fallback(&summary.user_locked_capability_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)), @@ -887,16 +948,24 @@ fn build_human_review_checklist( "确认回归建议是否能沉淀为 replay / eval / smoke,而不是停留在口头建议。".to_string(), ]; - if summary.permission_confirmation_status == "denied" { + if permission_confirmation_blocks_delivery(summary) { checklist.insert( 0, - "当前真实权限确认已被拒绝,不应把 handoff / evidence / replay 当成成功交付通过证据。" + "当前权限确认尚未解决,不应把 handoff / evidence / replay 当成成功交付通过证据。" .to_string(), ); } else if summary.permission_confirmation_status == "resolved" { checklist.push("确认外部 AI 没有把已通过的真实权限确认误判成仍需人工确认。".to_string()); } + if user_locked_capability_blocks_delivery(summary) { + checklist.insert( + 0, + "显式用户模型锁定不满足当前 execution profile,不应把 handoff / evidence / replay 当成成功交付通过证据。" + .to_string(), + ); + } + let requires_human_review = expected_payload .pointer("/graderSuggestion/requiresHumanReview") .and_then(Value::as_bool) @@ -922,6 +991,29 @@ fn build_human_review_checklist( checklist } +fn permission_confirmation_blocks_delivery(summary: &AnalysisContextSummary) -> bool { + match summary.permission_confirmation_status.as_str() { + "resolved" => false, + "denied" | "requested" | "not_requested" => true, + status => summary.permission_status == "requires_confirmation" && status != "resolved", + } +} + +fn user_locked_capability_blocks_delivery(summary: &AnalysisContextSummary) -> bool { + summary.limit_status == "user_locked_capability_gap" +} + +fn format_user_locked_capability_summary(limit_status: &str, capability_gap: &str) -> String { + if limit_status != "user_locked_capability_gap" { + return String::new(); + } + + format!( + "显式用户模型锁定不满足当前 execution profile(capabilityGap={}),不能作为成功交付证据。", + empty_fallback(capability_gap, "未记录 capabilityGap") + ) +} + fn format_permission_confirmation_summary(status: &str, request_id: &str, source: &str) -> String { let request_id = empty_fallback(request_id, "未记录 confirmationRequestId"); let source = empty_fallback(source, "未记录 confirmationSource"); @@ -931,8 +1023,10 @@ fn format_permission_confirmation_summary(status: &str, request_id: &str, source format!("已拒绝(request_id={request_id}, source={source}),不能作为成功交付证据。") } "resolved" => format!("已通过(request_id={request_id}, source={source})。"), - "requested" => format!("等待处理(request_id={request_id}, source={source})。"), - "not_requested" => "声明态权限尚未发起真实审批请求。".to_string(), + "requested" => { + format!("等待处理(request_id={request_id}, source={source}),不能作为成功交付证据。") + } + "not_requested" => "声明态权限尚未发起真实审批请求,不能作为成功交付证据。".to_string(), "" => String::new(), other => format!("{other}(source={source})。"), } @@ -1549,6 +1643,13 @@ mod tests { confirmation_status: &str, request_id: &str, ) { + let confirmation_request_id = + (!request_id.trim().is_empty()).then(|| request_id.to_string()); + let confirmation_source = if confirmation_status == "not_requested" { + "declared_profile_only" + } else { + "runtime_action_required" + }; thread_read.permission_state = Some(lime_agent::SessionExecutionRuntimePermissionState { status: "requires_confirmation".to_string(), required_profile_keys: vec!["browser_control".to_string()], @@ -1557,8 +1658,8 @@ mod tests { decision_source: "runtime_task_profile".to_string(), decision_scope: "declared_profile_only".to_string(), confirmation_status: Some(confirmation_status.to_string()), - confirmation_request_id: Some(request_id.to_string()), - confirmation_source: Some("runtime_action_required".to_string()), + confirmation_request_id, + confirmation_source: Some(confirmation_source.to_string()), notes: Vec::new(), }); } @@ -1800,6 +1901,86 @@ mod tests { assert!(context.contains("\"permissionState\"")); } + #[test] + fn should_surface_not_requested_permission_confirmation_as_analysis_blocking() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + set_permission_confirmation(&mut thread_read, "not_requested", ""); + write_request_telemetry_fixture(temp_dir.path()); + + let result = export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + assert!(result.copy_prompt.contains("尚未发起真实审批请求")); + assert!(result.copy_prompt.contains("不能作为成功交付证据")); + + let brief = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/analysis/analysis-brief.md"), + ) + .expect("brief"); + let context = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/analysis/analysis-context.json"), + ) + .expect("context"); + + assert!(brief.contains("尚未发起真实审批请求")); + assert!(brief.contains("不能作为成功交付证据")); + assert!(context.contains("\"permissionConfirmationStatus\": \"not_requested\"")); + assert!(context.contains("当前权限确认尚未解决")); + } + + #[test] + fn should_surface_user_locked_capability_gap_in_analysis_handoff() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + thread_read.permission_state = None; + thread_read.capability_gap = Some("browser_reasoning_candidate_missing".to_string()); + thread_read.limit_state = Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "user_locked_capability_gap".to_string(), + single_candidate_only: true, + provider_locked: false, + settings_locked: true, + oem_locked: false, + candidate_count: 1, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + notes: vec!["显式模型锁定不满足 browser_reasoning routingSlot".to_string()], + }); + write_request_telemetry_fixture(temp_dir.path()); + + let result = export_runtime_analysis_handoff(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + assert!(result.copy_prompt.contains("模型锁定能力缺口")); + assert!(result + .copy_prompt + .contains("browser_reasoning_candidate_missing")); + + let brief = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/analysis/analysis-brief.md"), + ) + .expect("brief"); + let context = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/analysis/analysis-context.json"), + ) + .expect("context"); + + assert!(brief.contains("显式用户模型锁定")); + assert!(brief.contains("不能作为成功交付证据")); + assert!(context.contains("\"limitStatus\": \"user_locked_capability_gap\"")); + assert!(context.contains("\"capabilityGap\": \"browser_reasoning_candidate_missing\"")); + assert!(context.contains("显式用户模型锁定不满足当前 execution profile")); + } + #[test] fn should_surface_resolved_permission_confirmation_in_analysis_handoff() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index ac841ab92..14d3285aa 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -29,7 +29,7 @@ use crate::services::runtime_file_checkpoint_service::list_file_checkpoints; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; use crate::workspace::WorkspaceManager; use chrono::Utc; -use lime_core::database::dao::agent_timeline::AgentThreadItemPayload; +use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadItemPayload}; use lime_infra::telemetry::RequestLog; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; @@ -777,6 +777,26 @@ fn build_modality_runtime_contracts_observability_summary_json( summary: &RuntimeModalityContractSnapshotSummary, ) -> Value { let snapshot_index = build_modality_runtime_contract_snapshot_index(&summary.snapshots); + let task_index = snapshot_index.get("taskIndex").cloned().unwrap_or_else(|| { + json!({ + "snapshotCount": 0, + "threadIds": [], + "turnIds": [], + "contentIds": [], + "entryKeys": [], + "modalities": [], + "skillIds": [], + "modelIds": [], + "executorKinds": [], + "executorBindingKeys": [], + "costStates": [], + "limitStates": [], + "estimatedCostClasses": [], + "limitEventKinds": [], + "quotaLowCount": 0, + "items": [] + }) + }); let browser_action_index = snapshot_index .get("browserActionIndex") .cloned() @@ -814,6 +834,7 @@ fn build_modality_runtime_contracts_observability_summary_json( json!({ "snapshotCount": summary.snapshots.len(), "snapshotIndex": { + "taskIndex": task_index, "browserActionIndex": browser_action_index, "limecorePolicyIndex": limecore_policy_index } @@ -838,6 +859,21 @@ fn build_modality_runtime_contract_snapshot_index(snapshots: &[Value]) -> Value let mut expected_routing_slots = BTreeSet::new(); let mut execution_profile_keys = BTreeSet::new(); let mut executor_adapter_keys = BTreeSet::new(); + let mut task_thread_ids = BTreeSet::new(); + let mut task_turn_ids = BTreeSet::new(); + let mut task_content_ids = BTreeSet::new(); + let mut task_entry_keys = BTreeSet::new(); + let mut task_modalities = BTreeSet::new(); + let mut task_skill_ids = BTreeSet::new(); + let mut task_model_ids = BTreeSet::new(); + let mut task_executor_kinds = BTreeSet::new(); + let mut task_executor_binding_keys = BTreeSet::new(); + let mut task_cost_states = BTreeSet::new(); + let mut task_limit_states = BTreeSet::new(); + let mut task_estimated_cost_classes = BTreeSet::new(); + let mut task_limit_event_kinds = BTreeSet::new(); + let mut task_quota_low_count = 0usize; + let mut task_index_items = Vec::new(); let mut limecore_policy_refs = BTreeSet::new(); let mut limecore_policy_missing_inputs = BTreeSet::new(); let mut limecore_policy_pending_hit_refs = BTreeSet::new(); @@ -871,6 +907,22 @@ fn build_modality_runtime_contract_snapshot_index(snapshots: &[Value]) -> Value let expected_routing_slot = snapshot_string(snapshot, "expectedRoutingSlot"); let execution_profile_key = snapshot_string(snapshot, "executionProfileKey"); let executor_adapter_key = snapshot_string(snapshot, "executorAdapterKey"); + let thread_id = snapshot_string(snapshot, "threadId"); + let turn_id = snapshot_string(snapshot, "turnId"); + let content_id = snapshot_string(snapshot, "contentId"); + let entry_key = snapshot_string(snapshot, "entryKey") + .or_else(|| snapshot_string(snapshot, "entrySource")); + let modality = snapshot_string(snapshot, "modality"); + let skill_id = snapshot_string(snapshot, "skillId"); + let model_id = + snapshot_string(snapshot, "modelId").or_else(|| snapshot_string(snapshot, "model")); + let executor_kind = snapshot_string(snapshot, "executorKind"); + let executor_binding_key = snapshot_string(snapshot, "executorBindingKey"); + let cost_state = snapshot_string(snapshot, "costState"); + let limit_state = snapshot_string(snapshot, "limitState"); + let estimated_cost_class = snapshot_string(snapshot, "estimatedCostClass"); + let limit_event_kind = snapshot_string(snapshot, "limitEventKind"); + let quota_low = snapshot.get("quotaLow").and_then(Value::as_bool); let snapshot_limecore_policy_refs = read_json_string_array(snapshot, &[&["limecorePolicyRefs"][..]]); let limecore_policy_snapshot = snapshot @@ -897,6 +949,71 @@ fn build_modality_runtime_contract_snapshot_index(snapshots: &[Value]) -> Value if let Some(executor_adapter_key) = executor_adapter_key.as_deref() { executor_adapter_keys.insert(executor_adapter_key.to_string()); } + if let Some(value) = thread_id.as_deref() { + task_thread_ids.insert(value.to_string()); + } + if let Some(value) = turn_id.as_deref() { + task_turn_ids.insert(value.to_string()); + } + if let Some(value) = content_id.as_deref() { + task_content_ids.insert(value.to_string()); + } + if let Some(value) = entry_key.as_deref() { + task_entry_keys.insert(value.to_string()); + } + if let Some(value) = modality.as_deref() { + task_modalities.insert(value.to_string()); + } + if let Some(value) = skill_id.as_deref() { + task_skill_ids.insert(value.to_string()); + } + if let Some(value) = model_id.as_deref() { + task_model_ids.insert(value.to_string()); + } + if let Some(value) = executor_kind.as_deref() { + task_executor_kinds.insert(value.to_string()); + } + if let Some(value) = executor_binding_key.as_deref() { + task_executor_binding_keys.insert(value.to_string()); + } + if let Some(value) = cost_state.as_deref() { + task_cost_states.insert(value.to_string()); + } + if let Some(value) = limit_state.as_deref() { + task_limit_states.insert(value.to_string()); + } + if let Some(value) = estimated_cost_class.as_deref() { + task_estimated_cost_classes.insert(value.to_string()); + } + if let Some(value) = limit_event_kind.as_deref() { + task_limit_event_kinds.insert(value.to_string()); + } + if quota_low == Some(true) { + task_quota_low_count += 1; + } + task_index_items.push(json!({ + "artifactPath": snapshot.get("artifactPath").cloned().unwrap_or(Value::Null), + "taskId": snapshot.get("taskId").cloned().unwrap_or(Value::Null), + "taskType": snapshot.get("taskType").cloned().unwrap_or(Value::Null), + "contractKey": contract_key.clone(), + "source": source.clone(), + "threadId": thread_id, + "turnId": turn_id, + "contentId": content_id, + "entryKey": entry_key, + "entrySource": snapshot.get("entrySource").cloned().unwrap_or(Value::Null), + "modality": modality, + "skillId": skill_id, + "modelId": model_id, + "executorKind": executor_kind, + "executorBindingKey": executor_binding_key, + "costState": cost_state, + "limitState": limit_state, + "estimatedCostClass": estimated_cost_class, + "limitEventKind": limit_event_kind, + "quotaLow": quota_low, + "routingOutcome": snapshot.get("routingOutcome").cloned().unwrap_or(Value::Null), + })); for policy_ref in &snapshot_limecore_policy_refs { limecore_policy_refs.insert(policy_ref.to_string()); } @@ -1196,6 +1313,24 @@ fn build_modality_runtime_contract_snapshot_index(snapshots: &[Value]) -> Value "expectedRoutingSlots": expected_routing_slots.into_iter().collect::>(), "executionProfileKeys": execution_profile_keys.into_iter().collect::>(), "executorAdapterKeys": executor_adapter_keys.into_iter().collect::>(), + "taskIndex": { + "snapshotCount": task_index_items.len(), + "threadIds": task_thread_ids.into_iter().collect::>(), + "turnIds": task_turn_ids.into_iter().collect::>(), + "contentIds": task_content_ids.into_iter().collect::>(), + "entryKeys": task_entry_keys.into_iter().collect::>(), + "modalities": task_modalities.into_iter().collect::>(), + "skillIds": task_skill_ids.into_iter().collect::>(), + "modelIds": task_model_ids.into_iter().collect::>(), + "executorKinds": task_executor_kinds.into_iter().collect::>(), + "executorBindingKeys": task_executor_binding_keys.into_iter().collect::>(), + "costStates": task_cost_states.into_iter().collect::>(), + "limitStates": task_limit_states.into_iter().collect::>(), + "estimatedCostClasses": task_estimated_cost_classes.into_iter().collect::>(), + "limitEventKinds": task_limit_event_kinds.into_iter().collect::>(), + "quotaLowCount": task_quota_low_count, + "items": task_index_items, + }, "limecorePolicyRefs": limecore_policy_ref_keys.clone(), "limecorePolicyIndex": { "snapshotCount": limecore_policy_items.len(), @@ -1391,6 +1526,9 @@ fn build_known_gaps( if let Some(gap) = permission_confirmation_known_gap(thread_read) { gaps.push(gap); } + if let Some(gap) = user_locked_capability_known_gap(thread_read) { + gaps.push(gap); + } if recent_artifacts.is_empty() { gaps.push("当前未检测到最近产物路径,Artifact 证据为空。".to_string()); @@ -1400,10 +1538,26 @@ fn build_known_gaps( gaps } +fn user_locked_capability_known_gap(thread_read: &AgentRuntimeThreadReadModel) -> Option { + let limit_state = thread_read.limit_state.as_ref()?; + if limit_state.status != "user_locked_capability_gap" { + return None; + } + let capability_gap = limit_state + .capability_gap + .as_deref() + .or(thread_read.capability_gap.as_deref()) + .unwrap_or("未记录 capabilityGap"); + Some(format!( + "显式用户模型锁定不满足当前 execution profile,当前证据包不能作为成功交付证据:capabilityGap={}。", + capability_gap + )) +} + fn permission_confirmation_known_gap(thread_read: &AgentRuntimeThreadReadModel) -> Option { let permission_state = thread_read.permission_state.as_ref()?; if permission_state.confirmation_status.as_deref() != Some("denied") { - return None; + return unresolved_permission_confirmation_blocking_detail(permission_state); } Some(format!( @@ -1419,6 +1573,55 @@ fn permission_confirmation_known_gap(thread_read: &AgentRuntimeThreadReadModel) )) } +fn unresolved_permission_confirmation_blocking_detail( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> Option { + let confirmation_status = permission_state.confirmation_status.as_deref(); + if permission_state.status != "requires_confirmation" + || matches!(confirmation_status, Some("resolved" | "denied")) + { + return None; + } + + let ask_profile_keys = + format_permission_profile_keys(&permission_state.ask_profile_keys, "未记录 askProfileKeys"); + let confirmation_source = permission_state + .confirmation_source + .as_deref() + .unwrap_or("未记录 confirmationSource"); + let confirmation_request_id = permission_state + .confirmation_request_id + .as_deref() + .unwrap_or("未记录 confirmationRequestId"); + + Some(match confirmation_status { + Some("not_requested") => format!( + "声明态权限需要真实确认但尚未发起 ApprovalRequest,当前证据包不能作为成功交付证据:askProfileKeys={},source={}。", + ask_profile_keys, confirmation_source + ), + Some("requested") => format!( + "真实权限确认正在等待处理,当前证据包不能作为成功交付证据:askProfileKeys={},request_id={},source={}。", + ask_profile_keys, confirmation_request_id, confirmation_source + ), + Some(other) => format!( + "运行时权限确认状态尚未解决,当前证据包不能作为成功交付证据:confirmationStatus={},askProfileKeys={},source={}。", + other, ask_profile_keys, confirmation_source + ), + None => format!( + "运行时权限声明仍需确认但缺少 confirmationStatus,当前证据包不能作为成功交付证据:askProfileKeys={},source={}。", + ask_profile_keys, confirmation_source + ), + }) +} + +fn format_permission_profile_keys(values: &[String], fallback: &str) -> String { + if values.is_empty() { + fallback.to_string() + } else { + values.join(", ") + } +} + fn collect_latest_turn_summary(detail: &SessionDetail) -> Option { detail .items @@ -1640,6 +1843,15 @@ fn permission_state_signal_coverage( }; } + if let Some(detail) = unresolved_permission_confirmation_blocking_detail(permission_state) { + return RuntimeEvidenceSignalCoverageEntry { + signal: "permissionState", + status: "blocked", + source: "thread_read.permission_state", + detail: format!("thread_read 已导出 permission_state,但{detail}"), + }; + } + let detail = match confirmation_status { Some("resolved") => format!( "thread_read 已导出 permission_state,真实权限确认已通过:request_id={confirmation_request_id}, source={confirmation_source}。" @@ -2043,7 +2255,10 @@ fn collect_modality_runtime_contract_snapshots( ) }) }; - let Some(snapshot) = snapshot else { continue }; + let Some(mut snapshot) = snapshot else { + continue; + }; + enrich_modality_runtime_contract_snapshot_with_thread_item(&mut snapshot, item); summary.applicable_count += 1; summary.snapshots.push(snapshot); if summary.snapshots.len() >= MAX_RECENT_ARTIFACTS { @@ -2054,6 +2269,25 @@ fn collect_modality_runtime_contract_snapshots( summary } +fn enrich_modality_runtime_contract_snapshot_with_thread_item( + snapshot: &mut Value, + item: &AgentThreadItem, +) { + let Some(object) = snapshot.as_object_mut() else { + return; + }; + + if object.get("threadId").map_or(true, Value::is_null) { + object.insert( + "threadId".to_string(), + Value::String(item.thread_id.clone()), + ); + } + if object.get("turnId").map_or(true, Value::is_null) { + object.insert("turnId".to_string(), Value::String(item.turn_id.clone())); + } +} + fn collect_artifact_validator_summary( workspace_root: Option<&Path>, recent_artifacts: &[RuntimeRecentArtifact], @@ -3528,6 +3762,36 @@ fn extract_modality_runtime_contract_snapshot( extract_runtime_contract_limecore_policy_refs(document, contract_key.as_str()); let limecore_policy_snapshot = extract_runtime_contract_limecore_policy_snapshot(document, &limecore_policy_refs); + let entry_source = read_modality_contract_entry_source(document); + let entry_key = read_modality_contract_entry_key(document).or_else(|| entry_source.clone()); + let modality = read_json_string( + document, + &[ + &["modality"][..], + &["payload", "modality"][..], + &["record", "payload", "modality"][..], + &["runtime_contract", "modality"][..], + &["runtimeContract", "modality"][..], + &["payload", "runtime_contract", "modality"][..], + &["payload", "runtimeContract", "modality"][..], + &["record", "payload", "runtime_contract", "modality"][..], + &["record", "payload", "runtimeContract", "modality"][..], + ], + ); + let model = read_modality_contract_model(document); + let model_id = read_modality_contract_model_id(document).or_else(|| model.clone()); + let executor_kind = extract_runtime_contract_executor_kind(document); + let executor_binding_key = extract_runtime_contract_executor_binding_key(document); + let skill_id = + read_modality_contract_skill_id(document).or_else(|| match executor_kind.as_deref() { + Some("skill") | Some("service_skill") => executor_binding_key.clone(), + _ => None, + }); + let cost_state = read_modality_contract_cost_state(document); + let estimated_cost_class = read_modality_contract_estimated_cost_class(document); + let limit_state = read_modality_contract_limit_state(document); + let limit_event_kind = read_modality_contract_limit_event_kind(document); + let quota_low = read_modality_contract_quota_low(document, limit_event_kind.as_deref()); Some(json!({ "artifactPath": artifact_path, @@ -3558,6 +3822,9 @@ fn extract_modality_runtime_contract_snapshot( ], ), "taskType": task_type, + "threadId": read_modality_contract_thread_id(document), + "turnId": read_modality_contract_turn_id(document), + "contentId": read_modality_contract_content_id(document), "status": read_json_string( document, &[ @@ -3585,25 +3852,18 @@ fn extract_modality_runtime_contract_snapshot( } else { None }, - "entrySource": read_json_string( - document, - &[ - &["entry_source"][..], - &["entrySource"][..], - &["payload", "entry_source"][..], - &["payload", "entrySource"][..], - &["record", "payload", "entry_source"][..], - &["record", "payload", "entrySource"][..], - ], - ), - "modality": read_json_string( - document, - &[ - &["modality"][..], - &["payload", "modality"][..], - &["record", "payload", "modality"][..], - ], - ), + "entryKey": entry_key, + "entrySource": entry_source, + "modality": modality, + "skillId": skill_id, + "modelId": model_id, + "executorKind": executor_kind, + "executorBindingKey": executor_binding_key, + "costState": cost_state, + "limitState": limit_state, + "estimatedCostClass": estimated_cost_class, + "limitEventKind": limit_event_kind, + "quotaLow": quota_low, "requiredCapabilities": read_json_string_array( document, &[ @@ -3647,20 +3907,7 @@ fn extract_modality_runtime_contract_snapshot( &["record", "payload", "preferredProviderId"][..], ], ), - "model": read_json_string( - document, - &[ - &["model"][..], - &["preferred_model_id"][..], - &["preferredModelId"][..], - &["payload", "model"][..], - &["payload", "preferred_model_id"][..], - &["payload", "preferredModelId"][..], - &["record", "payload", "model"][..], - &["record", "payload", "preferred_model_id"][..], - &["record", "payload", "preferredModelId"][..], - ], - ), + "model": model, "modelCapabilityAssessment": find_json_value_at_paths( document, &[ @@ -3703,6 +3950,356 @@ fn extract_modality_runtime_contract_snapshot( })) } +fn read_modality_contract_thread_id(document: &Value) -> Option { + read_json_string( + document, + &[ + &["thread_id"][..], + &["threadId"][..], + &["payload", "thread_id"][..], + &["payload", "threadId"][..], + &["record", "payload", "thread_id"][..], + &["record", "payload", "threadId"][..], + &["runtime_summary", "thread_id"][..], + &["runtimeSummary", "threadId"][..], + &["request_metadata", "harness", "thread_id"][..], + &["requestMetadata", "harness", "threadId"][..], + ], + ) +} + +fn read_modality_contract_turn_id(document: &Value) -> Option { + read_json_string( + document, + &[ + &["turn_id"][..], + &["turnId"][..], + &["payload", "turn_id"][..], + &["payload", "turnId"][..], + &["record", "payload", "turn_id"][..], + &["record", "payload", "turnId"][..], + &["runtime_summary", "turn_id"][..], + &["runtimeSummary", "turnId"][..], + &["request_metadata", "harness", "turn_id"][..], + &["requestMetadata", "harness", "turnId"][..], + ], + ) +} + +fn read_modality_contract_content_id(document: &Value) -> Option { + read_json_string( + document, + &[ + &["content_id"][..], + &["contentId"][..], + &["payload", "content_id"][..], + &["payload", "contentId"][..], + &["record", "payload", "content_id"][..], + &["record", "payload", "contentId"][..], + &["runtime_summary", "content_id"][..], + &["runtimeSummary", "contentId"][..], + &["request_metadata", "harness", "content_id"][..], + &["requestMetadata", "harness", "contentId"][..], + ], + ) +} + +fn read_modality_contract_entry_source(document: &Value) -> Option { + read_json_string( + document, + &[ + &["entry_source"][..], + &["entrySource"][..], + &["payload", "entry_source"][..], + &["payload", "entrySource"][..], + &["record", "payload", "entry_source"][..], + &["record", "payload", "entrySource"][..], + &["request_metadata", "harness", "entry_source"][..], + &["requestMetadata", "harness", "entrySource"][..], + ], + ) +} + +fn read_modality_contract_entry_key(document: &Value) -> Option { + read_json_string( + document, + &[ + &["entry_key"][..], + &["entryKey"][..], + &["payload", "entry_key"][..], + &["payload", "entryKey"][..], + &["record", "payload", "entry_key"][..], + &["record", "payload", "entryKey"][..], + &["request_metadata", "harness", "entry_key"][..], + &["requestMetadata", "harness", "entryKey"][..], + ], + ) +} + +fn read_modality_contract_skill_id(document: &Value) -> Option { + read_json_string( + document, + &[ + &["skill_id"][..], + &["skillId"][..], + &["service_skill_id"][..], + &["serviceSkillId"][..], + &["payload", "skill_id"][..], + &["payload", "skillId"][..], + &["payload", "service_skill_id"][..], + &["payload", "serviceSkillId"][..], + &["record", "payload", "skill_id"][..], + &["record", "payload", "skillId"][..], + &["record", "payload", "service_skill_id"][..], + &["record", "payload", "serviceSkillId"][..], + ], + ) +} + +fn read_modality_contract_model(document: &Value) -> Option { + read_json_string( + document, + &[ + &["model"][..], + &["preferred_model_id"][..], + &["preferredModelId"][..], + &["payload", "model"][..], + &["payload", "preferred_model_id"][..], + &["payload", "preferredModelId"][..], + &["record", "payload", "model"][..], + &["record", "payload", "preferred_model_id"][..], + &["record", "payload", "preferredModelId"][..], + ], + ) +} + +fn read_modality_contract_model_id(document: &Value) -> Option { + read_json_string( + document, + &[ + &["model_id"][..], + &["modelId"][..], + &["payload", "model_id"][..], + &["payload", "modelId"][..], + &["record", "payload", "model_id"][..], + &["record", "payload", "modelId"][..], + ], + ) +} + +fn extract_runtime_contract_executor_kind(document: &Value) -> Option { + read_json_string( + document, + &[ + &["executor_kind"][..], + &["executorKind"][..], + &["payload", "executor_kind"][..], + &["payload", "executorKind"][..], + &["record", "payload", "executor_kind"][..], + &["record", "payload", "executorKind"][..], + &["runtime_contract", "executor_binding", "executor_kind"][..], + &["runtimeContract", "executorBinding", "executorKind"][..], + &[ + "payload", + "runtime_contract", + "executor_binding", + "executor_kind", + ][..], + &[ + "payload", + "runtimeContract", + "executorBinding", + "executorKind", + ][..], + &[ + "record", + "payload", + "runtime_contract", + "executor_binding", + "executor_kind", + ][..], + &[ + "record", + "payload", + "runtimeContract", + "executorBinding", + "executorKind", + ][..], + ], + ) +} + +fn extract_runtime_contract_executor_binding_key(document: &Value) -> Option { + read_json_string( + document, + &[ + &["executor_binding_key"][..], + &["executorBindingKey"][..], + &["payload", "executor_binding_key"][..], + &["payload", "executorBindingKey"][..], + &["record", "payload", "executor_binding_key"][..], + &["record", "payload", "executorBindingKey"][..], + &["runtime_contract", "executor_binding", "binding_key"][..], + &["runtimeContract", "executorBinding", "bindingKey"][..], + &[ + "payload", + "runtime_contract", + "executor_binding", + "binding_key", + ][..], + &[ + "payload", + "runtimeContract", + "executorBinding", + "bindingKey", + ][..], + &[ + "record", + "payload", + "runtime_contract", + "executor_binding", + "binding_key", + ][..], + &[ + "record", + "payload", + "runtimeContract", + "executorBinding", + "bindingKey", + ][..], + ], + ) +} + +fn read_modality_contract_cost_state(document: &Value) -> Option { + read_json_string( + document, + &[ + &["cost_state", "status"][..], + &["costState", "status"][..], + &["payload", "cost_state", "status"][..], + &["payload", "costState", "status"][..], + &["record", "payload", "cost_state", "status"][..], + &["record", "payload", "costState", "status"][..], + &["task_profile", "cost_state", "status"][..], + &["taskProfile", "costState", "status"][..], + &["payload", "task_profile", "cost_state", "status"][..], + &["payload", "taskProfile", "costState", "status"][..], + &["runtime_summary", "costStatus"][..], + &["runtimeSummary", "costStatus"][..], + &["cost_state"][..], + &["costState"][..], + &["payload", "cost_state"][..], + &["payload", "costState"][..], + &["record", "payload", "cost_state"][..], + &["record", "payload", "costState"][..], + ], + ) +} + +fn read_modality_contract_estimated_cost_class(document: &Value) -> Option { + read_json_string( + document, + &[ + &["cost_state", "estimatedCostClass"][..], + &["cost_state", "estimated_cost_class"][..], + &["costState", "estimatedCostClass"][..], + &["payload", "cost_state", "estimatedCostClass"][..], + &["payload", "costState", "estimatedCostClass"][..], + &["record", "payload", "cost_state", "estimatedCostClass"][..], + &["record", "payload", "costState", "estimatedCostClass"][..], + &["task_profile", "cost_state", "estimatedCostClass"][..], + &["taskProfile", "costState", "estimatedCostClass"][..], + &[ + "payload", + "task_profile", + "cost_state", + "estimatedCostClass", + ][..], + &["payload", "taskProfile", "costState", "estimatedCostClass"][..], + &["runtime_summary", "estimatedCostClass"][..], + &["runtime_summary", "estimated_cost_class"][..], + &["runtimeSummary", "estimatedCostClass"][..], + &["estimated_cost_class"][..], + &["estimatedCostClass"][..], + ], + ) +} + +fn read_modality_contract_limit_state(document: &Value) -> Option { + read_json_string( + document, + &[ + &["limit_state", "status"][..], + &["limitState", "status"][..], + &["payload", "limit_state", "status"][..], + &["payload", "limitState", "status"][..], + &["record", "payload", "limit_state", "status"][..], + &["record", "payload", "limitState", "status"][..], + &["task_profile", "limit_state", "status"][..], + &["taskProfile", "limitState", "status"][..], + &["payload", "task_profile", "limit_state", "status"][..], + &["payload", "taskProfile", "limitState", "status"][..], + &["runtime_summary", "limitStatus"][..], + &["runtimeSummary", "limitStatus"][..], + &["limit_state"][..], + &["limitState"][..], + &["payload", "limit_state"][..], + &["payload", "limitState"][..], + &["record", "payload", "limit_state"][..], + &["record", "payload", "limitState"][..], + ], + ) +} + +fn read_modality_contract_limit_event_kind(document: &Value) -> Option { + read_json_string( + document, + &[ + &["limit_event", "eventKind"][..], + &["limit_event", "event_kind"][..], + &["limitEvent", "eventKind"][..], + &["limit_state", "limit_event", "eventKind"][..], + &["limitState", "limitEvent", "eventKind"][..], + &["payload", "limit_event", "eventKind"][..], + &["payload", "limitEvent", "eventKind"][..], + &["payload", "limit_state", "limit_event", "eventKind"][..], + &["payload", "limitState", "limitEvent", "eventKind"][..], + &["record", "payload", "limit_event", "eventKind"][..], + &["record", "payload", "limitEvent", "eventKind"][..], + &["runtime_summary", "limitEventKind"][..], + &["runtime_summary", "limit_event_kind"][..], + &["runtimeSummary", "limitEventKind"][..], + ], + ) +} + +fn read_modality_contract_quota_low( + document: &Value, + limit_event_kind: Option<&str>, +) -> Option { + read_json_bool( + document, + &[ + &["limit_event", "quotaLow"][..], + &["limit_event", "quota_low"][..], + &["limitEvent", "quotaLow"][..], + &["payload", "limit_event", "quotaLow"][..], + &["payload", "limitEvent", "quotaLow"][..], + &["record", "payload", "limit_event", "quotaLow"][..], + &["record", "payload", "limitEvent", "quotaLow"][..], + &["runtime_summary", "quotaLow"][..], + &["runtime_summary", "quota_low"][..], + &["runtimeSummary", "quotaLow"][..], + ], + ) + .or_else(|| { + limit_event_kind + .map(|value| value.trim() == "quota_low") + .filter(|value| *value) + }) +} + fn default_limecore_policy_refs_for_contract(contract_key: &str) -> &'static [&'static str] { match contract_key { IMAGE_GENERATION_CONTRACT_KEY => IMAGE_GENERATION_LIMECORE_POLICY_REFS, @@ -5082,6 +5679,39 @@ mod tests { assert!(coverage.detail.contains("真实权限确认已通过")); } + #[test] + fn permission_state_signal_coverage_should_surface_not_requested_confirmation_as_blocked() { + let thread_read = build_thread_read(); + + let coverage = permission_state_signal_coverage(&thread_read); + + assert_eq!(coverage.signal, "permissionState"); + assert_eq!(coverage.status, "blocked"); + assert!(coverage.detail.contains("尚未发起 ApprovalRequest")); + assert!(coverage.detail.contains("read_files")); + assert!(coverage.detail.contains("write_artifacts")); + } + + #[test] + fn permission_state_signal_coverage_should_surface_requested_confirmation_as_blocked() { + let mut thread_read = build_thread_read(); + let mut permission_state = thread_read + .permission_state + .clone() + .expect("permission state"); + permission_state.confirmation_status = Some("requested".to_string()); + permission_state.confirmation_request_id = Some("approval-pending".to_string()); + permission_state.confirmation_source = Some("runtime_action_required".to_string()); + thread_read.permission_state = Some(permission_state); + + let coverage = permission_state_signal_coverage(&thread_read); + + assert_eq!(coverage.signal, "permissionState"); + assert_eq!(coverage.status, "blocked"); + assert!(coverage.detail.contains("真实权限确认正在等待处理")); + assert!(coverage.detail.contains("approval-pending")); + } + #[test] fn known_gaps_should_surface_denied_permission_confirmation() { let mut thread_read = build_thread_read(); @@ -5100,6 +5730,42 @@ mod tests { assert!(gaps.iter().any(|gap| gap.contains("权限确认已被拒绝"))); } + #[test] + fn known_gaps_should_surface_not_requested_permission_confirmation() { + let thread_read = build_thread_read(); + + let gaps = build_known_gaps(&[], &[], &thread_read); + + assert!(gaps + .iter() + .any(|gap| gap.contains("尚未发起 ApprovalRequest"))); + assert!(gaps.iter().any(|gap| gap.contains("read_files"))); + } + + #[test] + fn known_gaps_should_surface_user_locked_capability_gap() { + let mut thread_read = build_thread_read(); + thread_read.permission_state = None; + thread_read.capability_gap = Some("browser_reasoning_candidate_missing".to_string()); + thread_read.limit_state = Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "user_locked_capability_gap".to_string(), + single_candidate_only: true, + provider_locked: false, + settings_locked: true, + oem_locked: false, + candidate_count: 1, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + notes: vec!["显式模型锁定不满足 browser_reasoning routingSlot".to_string()], + }); + + let gaps = build_known_gaps(&[], &[], &thread_read); + + assert!(gaps.iter().any(|gap| gap.contains("显式用户模型锁定"))); + assert!(gaps + .iter() + .any(|gap| gap.contains("browser_reasoning_candidate_missing"))); + } + #[test] fn known_gaps_should_not_surface_resolved_permission_confirmation() { let mut thread_read = build_thread_read(); @@ -5571,7 +6237,12 @@ mod tests { 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 mut thread_read = build_thread_read(); + if let Some(permission_state) = thread_read.permission_state.as_mut() { + permission_state.confirmation_status = Some("resolved".to_string()); + permission_state.confirmation_request_id = Some("approval-resolved".to_string()); + permission_state.confirmation_source = Some("runtime_action_required".to_string()); + } write_request_telemetry_fixture(temp_dir.path()); let result = @@ -6300,6 +6971,18 @@ mod tests { "tool_family": "browser", "modality_contract_key": BROWSER_CONTROL_CONTRACT_KEY, "modality": "browser", + "content_id": "content-browser-1", + "model_id": "gpt-5.2-browser", + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + }, + "limit_state": { + "status": "within_limit" + }, + "limit_event": { + "eventKind": "quota_low" + }, "required_capabilities": [ "text_generation", "browser_reasoning", @@ -6402,6 +7085,126 @@ mod tests { .and_then(Value::as_str), Some("browser-session-1") ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/threadId") + .and_then(Value::as_str), + Some("thread-1") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/turnId") + .and_then(Value::as_str), + Some("turn-1") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contentId") + .and_then(Value::as_str), + Some("content-browser-1") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/entryKey") + .and_then(Value::as_str), + Some("at_browser_command") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/modelId") + .and_then(Value::as_str), + Some("gpt-5.2-browser") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/executorKind") + .and_then(Value::as_str), + Some("browser_action") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/executorBindingKey") + .and_then(Value::as_str), + Some("lime_browser_mcp") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/costState") + .and_then(Value::as_str), + Some("estimated") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/limitState") + .and_then(Value::as_str), + Some("within_limit") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/estimatedCostClass") + .and_then(Value::as_str), + Some("low") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/limitEventKind") + .and_then(Value::as_str), + Some("quota_low") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/quotaLow") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/threadIds/0") + .and_then(Value::as_str), + Some("thread-1") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/contentIds/0") + .and_then(Value::as_str), + Some("content-browser-1") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/entryKeys/0") + .and_then(Value::as_str), + Some("at_browser_command") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/modelIds/0") + .and_then(Value::as_str), + Some("gpt-5.2-browser") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/executorKinds/0") + .and_then(Value::as_str), + Some("browser_action") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/costStates/0") + .and_then(Value::as_str), + Some("estimated") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/limitStates/0") + .and_then(Value::as_str), + Some("within_limit") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/quotaLowCount") + .and_then(Value::as_u64), + Some(1) + ); assert_eq!( runtime .pointer("/modalityRuntimeContracts/snapshotIndex/browserActionIndex/actionCount") @@ -6428,6 +7231,13 @@ mod tests { .and_then(Value::as_str), Some("navigate") ); + assert_eq!( + result + .observability_summary + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/threadIds/0") + .and_then(Value::as_str), + Some("thread-1") + ); assert_eq!( result .observability_summary diff --git a/src-tauri/src/services/runtime_handoff_artifact_service.rs b/src-tauri/src/services/runtime_handoff_artifact_service.rs index e06011081..d98ccc8bc 100644 --- a/src-tauri/src/services/runtime_handoff_artifact_service.rs +++ b/src-tauri/src/services/runtime_handoff_artifact_service.rs @@ -815,10 +815,26 @@ fn build_resume_order() -> Vec<&'static str> { fn build_review_actions(thread_read: &AgentRuntimeThreadReadModel) -> Vec { let mut actions = Vec::new(); + if let Some(message) = user_locked_capability_blocks_delivery(thread_read) { + actions.push(format!( + "{message},不能把当前交接作为成功交付证据;请切换到满足 routingSlot 的模型或取消显式模型锁定后再恢复。" + )); + } + if let Some(permission_state) = thread_read.permission_state.as_ref() { - if permission_state.confirmation_status.as_deref() == Some("denied") { + if permission_confirmation_blocks_delivery(permission_state) { + let status = permission_state + .confirmation_status + .as_deref() + .unwrap_or("未记录 confirmationStatus"); + let prefix = if status == "denied" { + "权限确认已被拒绝" + } else { + "权限确认尚未解决" + }; actions.push(format!( - "权限确认已被拒绝,不能把当前交接作为成功交付证据:request_id={},source={}。", + "{prefix},不能把当前交接作为成功交付证据:status={},request_id={},source={}。", + status, permission_confirmation_request_id(permission_state), permission_confirmation_source(permission_state) )); @@ -863,10 +879,24 @@ fn build_review_actions(thread_read: &AgentRuntimeThreadReadModel) -> Vec Vec { let mut lines = Vec::new(); + if let Some(message) = user_locked_capability_blocks_delivery(thread_read) { + lines.push(format!("{message}。")); + } + if let Some(permission_state) = thread_read.permission_state.as_ref() { - if permission_state.confirmation_status.as_deref() == Some("denied") { + if permission_confirmation_blocks_delivery(permission_state) { + let status = permission_state + .confirmation_status + .as_deref() + .unwrap_or("未记录 confirmationStatus"); + let prefix = if status == "denied" { + "权限确认已被拒绝" + } else { + "权限确认尚未解决" + }; lines.push(format!( - "权限确认已被拒绝:request_id={},source={}。", + "{prefix}:status={},request_id={},source={}。", + status, permission_confirmation_request_id(permission_state), permission_confirmation_source(permission_state) )); @@ -896,6 +926,23 @@ fn build_blocking_lines(thread_read: &AgentRuntimeThreadReadModel) -> Vec Option { + let limit_state = thread_read.limit_state.as_ref()?; + if limit_state.status != "user_locked_capability_gap" { + return None; + } + let capability_gap = limit_state + .capability_gap + .as_deref() + .or(thread_read.capability_gap.as_deref()) + .unwrap_or("未记录 capabilityGap"); + Some(format!( + "显式用户模型锁定不满足当前 execution profile:capabilityGap={capability_gap}" + )) +} + fn format_permission_confirmation_line( permission_state: &lime_agent::SessionExecutionRuntimePermissionState, ) -> Option { @@ -911,13 +958,25 @@ fn format_permission_confirmation_line( "权限确认:已通过(request_id={request_id}, source={source})。" )), "requested" => Some(format!( - "权限确认:等待处理(request_id={request_id}, source={source})。" + "权限确认:等待处理(request_id={request_id}, source={source}),当前交接不能作为成功交付证据。" )), - "not_requested" => Some("权限确认:声明态权限尚未发起真实审批请求。".to_string()), + "not_requested" => Some( + "权限确认:声明态权限尚未发起真实审批请求,当前交接不能作为成功交付证据。".to_string(), + ), other => Some(format!("权限确认:{other}(source={source})。")), } } +fn permission_confirmation_blocks_delivery( + permission_state: &lime_agent::SessionExecutionRuntimePermissionState, +) -> bool { + match permission_state.confirmation_status.as_deref() { + Some("resolved") => false, + Some("denied" | "requested" | "not_requested") => true, + _ => permission_state.status == "requires_confirmation", + } +} + fn permission_confirmation_request_id( permission_state: &lime_agent::SessionExecutionRuntimePermissionState, ) -> &str { @@ -1307,6 +1366,13 @@ mod tests { confirmation_status: &str, request_id: &str, ) { + let confirmation_request_id = + (!request_id.trim().is_empty()).then(|| request_id.to_string()); + let confirmation_source = if confirmation_status == "not_requested" { + "declared_profile_only" + } else { + "runtime_action_required" + }; thread_read.permission_state = Some(lime_agent::SessionExecutionRuntimePermissionState { status: "requires_confirmation".to_string(), required_profile_keys: vec!["browser_control".to_string()], @@ -1315,8 +1381,8 @@ mod tests { decision_source: "runtime_task_profile".to_string(), decision_scope: "declared_profile_only".to_string(), confirmation_status: Some(confirmation_status.to_string()), - confirmation_request_id: Some(request_id.to_string()), - confirmation_source: Some("runtime_action_required".to_string()), + confirmation_request_id, + confirmation_source: Some(confirmation_source.to_string()), notes: Vec::new(), }); } @@ -1411,6 +1477,79 @@ mod tests { assert!(progress.contains("\"confirmationStatus\": \"denied\"")); } + #[test] + fn should_surface_not_requested_permission_confirmation_as_handoff_blocking() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + set_permission_confirmation(&mut thread_read, "not_requested", ""); + + export_runtime_handoff_bundle(&detail, &thread_read, temp_dir.path()).expect("export"); + + let plan = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/plan.md"), + ) + .expect("plan"); + let handoff = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/handoff.md"), + ) + .expect("handoff"); + + assert!(plan.contains("权限确认尚未解决")); + assert!(plan.contains("not_requested")); + assert!(handoff.contains("尚未发起真实审批请求")); + assert!(handoff.contains("不能作为成功交付证据")); + } + + #[test] + fn should_surface_user_locked_capability_gap_as_handoff_blocking() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + thread_read.permission_state = None; + thread_read.capability_gap = Some("browser_reasoning_candidate_missing".to_string()); + thread_read.limit_state = Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "user_locked_capability_gap".to_string(), + single_candidate_only: true, + provider_locked: false, + settings_locked: true, + oem_locked: false, + candidate_count: 1, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + notes: vec!["显式模型锁定不满足 browser_reasoning routingSlot".to_string()], + }); + + export_runtime_handoff_bundle(&detail, &thread_read, temp_dir.path()).expect("export"); + + let plan = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/plan.md"), + ) + .expect("plan"); + let handoff = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/handoff.md"), + ) + .expect("handoff"); + let review = fs::read_to_string( + temp_dir + .path() + .join(".lime/harness/sessions/session-1/review-summary.md"), + ) + .expect("review"); + + assert!(plan.contains("显式用户模型锁定")); + assert!(plan.contains("browser_reasoning_candidate_missing")); + assert!(handoff.contains("切换到满足 routingSlot 的模型")); + assert!(review.contains("不能把当前交接作为成功交付证据")); + } + #[test] fn runtime_fact_lines_should_surface_resolved_permission_confirmation() { let mut thread_read = build_thread_read(); diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index 73fdb202e..df9f0444b 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -432,7 +432,7 @@ fn build_input_json( "recentTimeline": recent_timeline, "lastOutcome": &thread_read.last_outcome, "incidents": &thread_read.incidents, - "runtimeFacts": build_replay_runtime_facts(thread_read), + "runtimeFacts": build_replay_runtime_facts(thread_read, modality_runtime_contracts), "modalityRuntimeContracts": modality_runtime_contracts, }, "observability": observability_summary, @@ -604,7 +604,10 @@ fn build_evidence_links_json( .map_err(|error| format!("序列化 evidence-links.json 失败: {error}")) } -fn build_replay_runtime_facts(thread_read: &AgentRuntimeThreadReadModel) -> Value { +fn build_replay_runtime_facts( + thread_read: &AgentRuntimeThreadReadModel, + modality_runtime_contracts: &Value, +) -> Value { json!({ "taskKind": thread_read.task_kind, "serviceModelSlot": thread_read.service_model_slot, @@ -621,10 +624,53 @@ fn build_replay_runtime_facts(thread_read: &AgentRuntimeThreadReadModel) -> Valu "runtimeSummary": thread_read.runtime_summary, "permissionState": thread_read.permission_state, "oemPolicy": thread_read.oem_policy, - "auxiliaryTaskRuntime": thread_read.auxiliary_task_runtime + "auxiliaryTaskRuntime": thread_read.auxiliary_task_runtime, + "modalityTaskIndex": build_replay_modality_task_index_facts(modality_runtime_contracts) }) } +fn build_replay_modality_task_index_facts(modality_runtime_contracts: &Value) -> Value { + if let Some(index) = modality_contract_task_index(modality_runtime_contracts) { + json!({ + "snapshotCount": modality_contract_task_index_snapshot_count(modality_runtime_contracts), + "threadIds": modality_contract_task_index_strings(index, "threadIds", "thread_ids", "threadId", "thread_id"), + "turnIds": modality_contract_task_index_strings(index, "turnIds", "turn_ids", "turnId", "turn_id"), + "contentIds": modality_contract_task_index_strings(index, "contentIds", "content_ids", "contentId", "content_id"), + "entryKeys": modality_contract_task_index_strings(index, "entryKeys", "entry_keys", "entryKey", "entry_key"), + "modalities": modality_contract_task_index_strings(index, "modalities", "modalities", "modality", "modality"), + "skillIds": modality_contract_task_index_strings(index, "skillIds", "skill_ids", "skillId", "skill_id"), + "modelIds": modality_contract_task_index_strings(index, "modelIds", "model_ids", "modelId", "model_id"), + "executorKinds": modality_contract_task_index_strings(index, "executorKinds", "executor_kinds", "executorKind", "executor_kind"), + "executorBindingKeys": modality_contract_task_index_strings(index, "executorBindingKeys", "executor_binding_keys", "executorBindingKey", "executor_binding_key"), + "costStates": modality_contract_task_index_strings(index, "costStates", "cost_states", "costState", "cost_state"), + "limitStates": modality_contract_task_index_strings(index, "limitStates", "limit_states", "limitState", "limit_state"), + "estimatedCostClasses": modality_contract_task_index_strings(index, "estimatedCostClasses", "estimated_cost_classes", "estimatedCostClass", "estimated_cost_class"), + "limitEventKinds": modality_contract_task_index_strings(index, "limitEventKinds", "limit_event_kinds", "limitEventKind", "limit_event_kind"), + "quotaLowCount": modality_contract_task_index_quota_low_count(index), + "itemCount": modality_contract_task_index_item_count(index) + }) + } else { + json!({ + "snapshotCount": 0, + "threadIds": [], + "turnIds": [], + "contentIds": [], + "entryKeys": [], + "modalities": [], + "skillIds": [], + "modelIds": [], + "executorKinds": [], + "executorBindingKeys": [], + "costStates": [], + "limitStates": [], + "estimatedCostClasses": [], + "limitEventKinds": [], + "quotaLowCount": 0, + "itemCount": 0 + }) + } +} + fn build_success_criteria( detail: &SessionDetail, thread_read: &AgentRuntimeThreadReadModel, @@ -705,6 +751,31 @@ fn build_success_criteria( format_text_list(&executor_adapter_keys, "未记录 executor adapter") )); } + if modality_contract_has_task_index(modality_runtime_contracts) { + criteria.push(format!( + "回放必须保留 Evidence `snapshotIndex.taskIndex`,继续暴露身份锚点 thread/turn/content/entry:{}。", + format_text_list( + &modality_contract_task_index_identity_anchors(modality_runtime_contracts), + "未记录 task index identity" + ) + )); + let executor_dimensions = + modality_contract_task_index_executor_dimensions(modality_runtime_contracts); + if !executor_dimensions.is_empty() { + criteria.push(format!( + "回放必须保留 task index executor 维度:{},不能退回无绑定的自由工具选择。", + format_text_list(&executor_dimensions, "未记录 executor 维度") + )); + } + let cost_limit_dimensions = + modality_contract_task_index_cost_limit_dimensions(modality_runtime_contracts); + if !cost_limit_dimensions.is_empty() { + criteria.push(format!( + "回放必须保留 task index cost/limit 摘要:{};除非有真实 runtime 摘要更新,否则不能伪造成本或限额状态。", + format_text_list(&cost_limit_dimensions, "未记录 cost/limit 摘要") + )); + } + } let limecore_policy_refs = modality_contract_limecore_policy_refs(modality_runtime_contracts); if modality_contract_has_limecore_policy_index(modality_runtime_contracts) { @@ -849,6 +920,19 @@ fn build_blocking_checks( } } + if let Some(limit_state) = thread_read.limit_state.as_ref() { + if limit_state.status == "user_locked_capability_gap" { + let capability_gap = limit_state + .capability_gap + .as_deref() + .or(thread_read.capability_gap.as_deref()) + .unwrap_or("未记录 capabilityGap"); + checks.push(format!( + "显式用户模型锁定不满足当前 execution profile:{capability_gap};除非 replay 切换到满足 routingSlot 的模型或取消显式模型锁定,否则不能判 PASS。" + )); + } + } + if modality_contract_has_routing_block(modality_runtime_contracts) { checks.push(format!( "多模态运行合同存在路由阻塞:{};除非重放已换到满足合同的模型并成功产出,否则不能判 PASS。", @@ -858,6 +942,14 @@ fn build_blocking_checks( ) )); } + if modality_contract_snapshot_count(modality_runtime_contracts) > 0 + && !modality_contract_has_task_index(modality_runtime_contracts) + { + checks.push( + "多模态运行合同缺少 Evidence `snapshotIndex.taskIndex`;除非 replay 重新导出同一 task index,否则不能证明身份锚点、executor 与 cost/limit 摘要仍可查询。" + .to_string(), + ); + } if modality_contract_has_browser_control(modality_runtime_contracts) && !modality_contract_has_browser_action_trace(modality_runtime_contracts) { @@ -999,6 +1091,36 @@ fn build_modality_contract_checks(modality_runtime_contracts: &Value) -> Vec 0 + && !modality_contract_has_task_index(modality_runtime_contracts) + { + push_unique_text_tag(&mut failure_modes, "modality_task_index_missing"); + } if !modality_contract_limecore_policy_missing_inputs(modality_runtime_contracts).is_empty() { push_unique_text_tag(&mut failure_modes, "limecore_policy_missing_inputs"); } @@ -1495,6 +1633,219 @@ fn modality_contract_executor_adapter_keys(modality_runtime_contracts: &Value) - values } +fn modality_contract_task_index(modality_runtime_contracts: &Value) -> Option<&Value> { + modality_runtime_contracts + .pointer("/snapshotIndex/taskIndex") + .or_else(|| modality_runtime_contracts.pointer("/snapshot_index/task_index")) +} + +fn modality_contract_has_task_index(modality_runtime_contracts: &Value) -> bool { + modality_contract_task_index_snapshot_count(modality_runtime_contracts) > 0 + || modality_contract_task_index(modality_runtime_contracts) + .is_some_and(|index| modality_contract_task_index_item_count(index) > 0) +} + +fn modality_contract_task_index_snapshot_count(modality_runtime_contracts: &Value) -> usize { + modality_contract_task_index(modality_runtime_contracts) + .and_then(|index| { + index + .get("snapshotCount") + .or_else(|| index.get("snapshot_count")) + }) + .and_then(Value::as_u64) + .map(|count| count as usize) + .unwrap_or_else(|| { + modality_contract_task_index(modality_runtime_contracts) + .map(modality_contract_task_index_item_count) + .unwrap_or_default() + }) +} + +fn modality_contract_task_index_item_count(index: &Value) -> usize { + index + .get("items") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or_default() +} + +fn modality_contract_task_index_quota_low_count(index: &Value) -> usize { + index + .get("quotaLowCount") + .or_else(|| index.get("quota_low_count")) + .and_then(Value::as_u64) + .map(|count| count as usize) + .unwrap_or_else(|| { + index + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter(|item| { + item.get("quotaLow") + .or_else(|| item.get("quota_low")) + .and_then(Value::as_bool) + == Some(true) + }) + .count() + }) + .unwrap_or_default() + }) +} + +fn modality_contract_task_index_strings( + index: &Value, + array_camel_key: &str, + array_snake_key: &str, + item_camel_key: &str, + item_snake_key: &str, +) -> Vec { + let mut values = Vec::new(); + collect_unique_string_array_fields(index, &[array_camel_key, array_snake_key], &mut values); + for item in index + .get("items") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + collect_unique_string_fields(item, &[item_camel_key, item_snake_key], &mut values); + } + values +} + +fn modality_contract_task_index_identity_anchors( + modality_runtime_contracts: &Value, +) -> Vec { + let Some(index) = modality_contract_task_index(modality_runtime_contracts) else { + return Vec::new(); + }; + + let mut values = modality_contract_task_index_strings( + index, + "threadIds", + "thread_ids", + "threadId", + "thread_id", + ); + for value in + modality_contract_task_index_strings(index, "turnIds", "turn_ids", "turnId", "turn_id") + { + push_unique_owned_tag(&mut values, value); + } + for value in modality_contract_task_index_strings( + index, + "contentIds", + "content_ids", + "contentId", + "content_id", + ) { + push_unique_owned_tag(&mut values, value); + } + for value in modality_contract_task_index_strings( + index, + "entryKeys", + "entry_keys", + "entryKey", + "entry_key", + ) { + push_unique_owned_tag(&mut values, value); + } + values +} + +fn modality_contract_task_index_executor_dimensions( + modality_runtime_contracts: &Value, +) -> Vec { + let Some(index) = modality_contract_task_index(modality_runtime_contracts) else { + return Vec::new(); + }; + + let mut values = modality_contract_task_index_strings( + index, + "modalities", + "modalities", + "modality", + "modality", + ); + for value in + modality_contract_task_index_strings(index, "skillIds", "skill_ids", "skillId", "skill_id") + { + push_unique_owned_tag(&mut values, value); + } + for value in + modality_contract_task_index_strings(index, "modelIds", "model_ids", "modelId", "model_id") + { + push_unique_owned_tag(&mut values, value); + } + for value in modality_contract_task_index_strings( + index, + "executorKinds", + "executor_kinds", + "executorKind", + "executor_kind", + ) { + push_unique_owned_tag(&mut values, value); + } + for value in modality_contract_task_index_strings( + index, + "executorBindingKeys", + "executor_binding_keys", + "executorBindingKey", + "executor_binding_key", + ) { + push_unique_owned_tag(&mut values, value); + } + values +} + +fn modality_contract_task_index_cost_limit_dimensions( + modality_runtime_contracts: &Value, +) -> Vec { + let Some(index) = modality_contract_task_index(modality_runtime_contracts) else { + return Vec::new(); + }; + + let mut values = modality_contract_task_index_strings( + index, + "costStates", + "cost_states", + "costState", + "cost_state", + ); + for value in modality_contract_task_index_strings( + index, + "limitStates", + "limit_states", + "limitState", + "limit_state", + ) { + push_unique_owned_tag(&mut values, value); + } + for value in modality_contract_task_index_strings( + index, + "estimatedCostClasses", + "estimated_cost_classes", + "estimatedCostClass", + "estimated_cost_class", + ) { + push_unique_owned_tag(&mut values, value); + } + for value in modality_contract_task_index_strings( + index, + "limitEventKinds", + "limit_event_kinds", + "limitEventKind", + "limit_event_kind", + ) { + push_unique_owned_tag(&mut values, value); + } + if modality_contract_task_index_quota_low_count(index) > 0 { + push_unique_text_tag(&mut values, "quota_low"); + } + values +} + fn modality_contract_limecore_policy_index(modality_runtime_contracts: &Value) -> Option<&Value> { modality_runtime_contracts .pointer("/snapshotIndex/limecorePolicyIndex") @@ -2748,6 +3099,25 @@ mod tests { .any(|check| check.contains("运行时权限声明仍需确认"))); } + #[test] + fn replay_blocking_checks_should_treat_not_requested_permission_confirmation_as_blocking() { + let mut thread_read = build_thread_read(); + thread_read.pending_requests.clear(); + thread_read.queued_turns.clear(); + thread_read.diagnostics = None; + + let checks = build_blocking_checks(&thread_read, &[], &json!({})); + + assert!(checks + .iter() + .any(|check| check.contains("运行时权限声明仍需确认"))); + assert!(checks.iter().any(|check| check.contains("read_files"))); + assert!(checks.iter().any(|check| check.contains("write_artifacts"))); + assert!(!checks + .iter() + .any(|check| check.contains("运行时权限确认已被拒绝"))); + } + #[test] fn replay_blocking_checks_should_not_block_resolved_permission_confirmation() { let mut thread_read = build_thread_read(); @@ -2773,6 +3143,36 @@ mod tests { .any(|check| check.contains("运行时权限确认已被拒绝"))); } + #[test] + fn replay_blocking_checks_should_treat_user_locked_capability_gap_as_blocking() { + let mut thread_read = build_thread_read(); + thread_read.pending_requests.clear(); + thread_read.queued_turns.clear(); + thread_read.diagnostics = None; + thread_read.permission_state = None; + thread_read.capability_gap = Some("browser_reasoning_candidate_missing".to_string()); + thread_read.limit_state = Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "user_locked_capability_gap".to_string(), + single_candidate_only: true, + provider_locked: false, + settings_locked: true, + oem_locked: false, + candidate_count: 1, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + notes: vec!["显式模型锁定不满足 browser_reasoning routingSlot".to_string()], + }); + + let checks = build_blocking_checks(&thread_read, &[], &json!({})); + + assert!(checks + .iter() + .any(|check| check.contains("显式用户模型锁定"))); + assert!(checks + .iter() + .any(|check| check.contains("browser_reasoning_candidate_missing"))); + assert!(checks.iter().any(|check| check.contains("不能判 PASS"))); + } + fn write_failed_image_contract_task_fixture(root: &Path, relative_path: &str) { let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); fs::create_dir_all( @@ -3174,6 +3574,19 @@ mod tests { "tool_family": "browser", "modality_contract_key": BROWSER_CONTROL_CONTRACT_KEY, "modality": "browser", + "skill_id": "browser_assist", + "content_id": "content-browser-1", + "model_id": "gpt-5.2-browser", + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + }, + "limit_state": { + "status": "within_limit" + }, + "limit_event": { + "eventKind": "quota_low" + }, "required_capabilities": [ "text_generation", "browser_reasoning", @@ -3257,6 +3670,50 @@ mod tests { .and_then(Value::as_str), Some("browser-session-1") ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/taskIndex/threadIds/0" + ) + .and_then(Value::as_str), + Some("thread-1") + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/taskIndex/contentIds/0" + ) + .and_then(Value::as_str), + Some("content-browser-1") + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/taskIndex/entryKeys/0" + ) + .and_then(Value::as_str), + Some("at_browser_command") + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/taskIndex/costStates/0" + ) + .and_then(Value::as_str), + Some("estimated") + ); + assert_eq!( + input + .pointer("/runtimeContext/runtimeFacts/modalityTaskIndex/executorBindingKeys/0") + .and_then(Value::as_str), + Some("lime_browser_mcp") + ); + assert_eq!( + input + .pointer("/runtimeContext/runtimeFacts/modalityTaskIndex/quotaLowCount") + .and_then(Value::as_u64), + Some(1) + ); let suite_tags = input .pointer("/classification/suiteTags") .and_then(Value::as_array) @@ -3267,6 +3724,9 @@ mod tests { "browser-assist", "browser-action-trace", "browser-action-index", + "modality-task-index", + "modality-task-identity", + "modality-task-cost-limit", ] { assert!(suite_tags .iter() @@ -3279,12 +3739,18 @@ mod tests { assert!(expected.contains("WebSearch")); assert!(expected.contains("browser_action_trace")); assert!(expected.contains("browserActionIndex")); + assert!(expected.contains("snapshotIndex.taskIndex")); + assert!(expected.contains("thread-1")); + assert!(expected.contains("estimated")); assert!(expected.contains("\"requiresHumanReview\": false")); let grader = fs::read_to_string(grader_path).expect("grader"); assert!(grader.contains("多模态运行合同检查")); assert!(grader.contains("browser_action_requested")); assert!(grader.contains("browserActionIndex")); + assert!(grader.contains("snapshotIndex.taskIndex")); + assert!(grader.contains("at_browser_command")); + assert!(grader.contains("within_limit")); assert!(grader.contains("WebSearch")); let links = @@ -3302,6 +3768,12 @@ mod tests { .and_then(Value::as_str), Some("https://example.com/") ); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshotIndex/taskIndex/modelIds/0") + .and_then(Value::as_str), + Some("gpt-5.2-browser") + ); } #[test] diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs index f724a2136..7261eace0 100644 --- a/src-tauri/src/services/runtime_review_decision_service.rs +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -61,6 +61,9 @@ pub struct RuntimeReviewDecisionTemplateExportResult { pub queued_turn_count: usize, pub default_decision_status: String, pub verification_summary: Option, + pub limit_status: String, + pub capability_gap: String, + pub user_locked_capability_summary: String, pub permission_status: String, pub permission_confirmation_status: String, pub permission_confirmation_request_id: String, @@ -123,6 +126,12 @@ struct ReviewDecisionContext { verification_failure_outcomes: Vec, verification_recovered_outcomes: Vec, #[serde(default)] + limit_status: String, + #[serde(default)] + capability_gap: String, + #[serde(default)] + user_locked_capability_summary: String, + #[serde(default)] permission_status: String, #[serde(default)] permission_confirmation_status: String, @@ -267,6 +276,12 @@ fn sync_runtime_review_decision( queued_turn_count: analysis.queued_turn_count, default_decision_status: DEFAULT_DECISION_STATUS.to_string(), verification_summary: document.review_context.verification_summary.clone(), + limit_status: document.review_context.limit_status.clone(), + capability_gap: document.review_context.capability_gap.clone(), + user_locked_capability_summary: document + .review_context + .user_locked_capability_summary + .clone(), permission_status: document.review_context.permission_status.clone(), permission_confirmation_status: document .review_context @@ -343,6 +358,11 @@ fn build_review_decision_document( verification_summary: verification_context.summary.clone(), verification_failure_outcomes: verification_context.failure_outcomes.clone(), verification_recovered_outcomes: verification_context.recovered_outcomes.clone(), + limit_status: verification_context.limit_status.clone(), + capability_gap: verification_context.capability_gap.clone(), + user_locked_capability_summary: verification_context + .user_locked_capability_summary + .clone(), permission_status: verification_context.permission_status.clone(), permission_confirmation_status: verification_context .permission_confirmation_status @@ -404,6 +424,12 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { &document.review_context.permission_confirmation_summary, "未导出", ); + let limit_status = empty_fallback(&document.review_context.limit_status, "未导出"); + let capability_gap = empty_fallback(&document.review_context.capability_gap, "无"); + let user_locked_capability = empty_fallback( + &document.review_context.user_locked_capability_summary, + "未触发", + ); let decision_status_options = document .decision_status_options .iter() @@ -431,11 +457,14 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { - thread_id:`{thread_id}`\n\ - 线程状态:`{thread_status}`\n\ - 最新 Turn:`{latest_turn_status}`\n\ -- 待处理请求:`{pending_request_count}`\n\ -- 排队任务:`{queued_turn_count}`\n\ -- 权限状态:`{permission_status}`\n\ -- 权限确认:{permission_confirmation}\n\ -- analysis 目录:`{analysis_relative_root}`\n\ + - 待处理请求:`{pending_request_count}`\n\ + - 排队任务:`{queued_turn_count}`\n\ + - 额度状态:`{limit_status}`\n\ + - 能力缺口:`{capability_gap}`\n\ + - 模型锁定能力缺口:{user_locked_capability}\n\ + - 权限状态:`{permission_status}`\n\ + - 权限确认:{permission_confirmation}\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\ @@ -486,6 +515,9 @@ fn build_review_decision_markdown(document: &ReviewDecisionDocument) -> String { .unwrap_or("unknown"), pending_request_count = document.review_context.pending_request_count, queued_turn_count = document.review_context.queued_turn_count, + limit_status = limit_status, + capability_gap = capability_gap, + user_locked_capability = user_locked_capability, permission_status = permission_status, permission_confirmation = permission_confirmation, analysis_relative_root = document.review_context.analysis_relative_root, @@ -539,26 +571,115 @@ fn build_review_checklist(verification_context: &ReviewDecisionVerificationConte "把最终决定记录为 accepted / deferred / rejected / needs_more_evidence 之一。".to_string(), ]; - match verification_context.permission_confirmation_status.as_str() { - "denied" => checklist.insert( - 0, - "真实权限确认已被拒绝,不应把本次 review decision 标记为 accepted,除非已有新的真实授权证据。" - .to_string(), - ), - "resolved" => checklist.push( - "确认 review 决策不会把已通过的真实权限确认当作待处理阻塞。".to_string(), - ), - _ => {} + if let Some(message) = permission_confirmation_acceptance_block_message(verification_context) { + checklist.insert(0, message); + } else if verification_context.permission_confirmation_status == "resolved" { + checklist.push("确认 review 决策不会把已通过的真实权限确认当作待处理阻塞。".to_string()); + } + + if let Some(message) = user_locked_capability_acceptance_block_message(verification_context) { + checklist.insert(0, message); } checklist } +fn user_locked_capability_acceptance_block_message( + verification_context: &ReviewDecisionVerificationContext, +) -> Option { + if verification_context.limit_status != "user_locked_capability_gap" { + return None; + } + Some(format!( + "显式用户模型锁定不满足当前 execution profile(capabilityGap={}),不应把本次 review decision 标记为 accepted,除非已切换到满足 routingSlot 的模型或取消显式模型锁定并重新导出证据。", + empty_fallback( + &verification_context.capability_gap, + "未记录 capabilityGap" + ) + )) +} + +fn permission_confirmation_acceptance_block_message( + verification_context: &ReviewDecisionVerificationContext, +) -> Option { + match verification_context.permission_confirmation_status.as_str() { + "denied" => Some( + "真实权限确认已被拒绝,不应把本次 review decision 标记为 accepted,除非已有新的真实授权证据。" + .to_string(), + ), + "requested" => Some( + "真实权限确认仍在等待处理,不应把本次 review decision 标记为 accepted,除非该确认已变为 resolved。" + .to_string(), + ), + "not_requested" => Some( + "声明态权限尚未发起真实审批请求,不应把本次 review decision 标记为 accepted,除非已接入真实授权证据。" + .to_string(), + ), + status + if verification_context.permission_status == "requires_confirmation" + && status != "resolved" => + { + Some( + "运行时权限确认尚未解决,不应把本次 review decision 标记为 accepted,除非已有真实授权证据。" + .to_string(), + ) + } + _ => None, + } +} + +fn permission_confirmation_acceptance_error( + verification_context: &ReviewDecisionVerificationContext, +) -> Option { + match verification_context.permission_confirmation_status.as_str() { + "denied" => Some( + "真实权限确认已被拒绝,不能把本次 review decision 保存为 accepted;请先处理真实权限确认,或改为 rejected / deferred / needs_more_evidence。" + .to_string(), + ), + "requested" => Some( + "真实权限确认仍在等待处理,不能把本次 review decision 保存为 accepted;请先等待确认 resolved,或改为 rejected / deferred / needs_more_evidence。" + .to_string(), + ), + "not_requested" => Some( + "声明态权限尚未发起真实审批请求,不能把本次 review decision 保存为 accepted;请先接入真实授权证据,或改为 rejected / deferred / needs_more_evidence。" + .to_string(), + ), + status + if verification_context.permission_status == "requires_confirmation" + && status != "resolved" => + { + Some( + "运行时权限确认尚未解决,不能把本次 review decision 保存为 accepted;请先处理真实权限确认,或改为 rejected / deferred / needs_more_evidence。" + .to_string(), + ) + } + _ => None, + } +} + +fn user_locked_capability_acceptance_error( + verification_context: &ReviewDecisionVerificationContext, +) -> Option { + if verification_context.limit_status != "user_locked_capability_gap" { + return None; + } + Some(format!( + "显式用户模型锁定不满足当前 execution profile(capabilityGap={}),不能把本次 review decision 保存为 accepted;请切换到满足 routingSlot 的模型或取消显式模型锁定并重新导出证据,或改为 rejected / deferred / needs_more_evidence。", + empty_fallback( + &verification_context.capability_gap, + "未记录 capabilityGap" + ) + )) +} + #[derive(Debug, Clone, Default)] struct ReviewDecisionVerificationContext { summary: Option, failure_outcomes: Vec, recovered_outcomes: Vec, + limit_status: String, + capability_gap: String, + user_locked_capability_summary: String, permission_status: String, permission_confirmation_status: String, permission_confirmation_request_id: String, @@ -629,6 +750,11 @@ fn load_analysis_verification_context( .pointer("/observability/verificationRecoveredOutcomes") .map(value_string_list) .unwrap_or_default(), + limit_status: value_string(payload.pointer("/summary/limitStatus")), + capability_gap: value_string(payload.pointer("/summary/capabilityGap")), + user_locked_capability_summary: value_string( + payload.pointer("/summary/userLockedCapabilitySummary"), + ), permission_status: value_string(payload.pointer("/summary/permissionStatus")), permission_confirmation_status: value_string( payload.pointer("/summary/permissionConfirmationStatus"), @@ -748,13 +874,13 @@ fn validate_review_decision_write( verification_context: &ReviewDecisionVerificationContext, decision: &RuntimeReviewDecisionContent, ) -> Result<(), String> { - if verification_context.permission_confirmation_status == "denied" - && decision.decision_status == "accepted" - { - return Err( - "真实权限确认已被拒绝,不能把本次 review decision 保存为 accepted;请先处理真实权限确认,或改为 rejected / deferred / needs_more_evidence。" - .to_string(), - ); + if decision.decision_status == "accepted" { + if let Some(error) = user_locked_capability_acceptance_error(verification_context) { + return Err(error); + } + if let Some(error) = permission_confirmation_acceptance_error(verification_context) { + return Err(error); + } } Ok(()) @@ -825,14 +951,25 @@ fn build_review_decision_suggested_actions( ) -> ReviewDecisionSuggestedActions { let mut suggested_actions = ReviewDecisionSuggestedActions::default(); - if verification_context.permission_confirmation_status == "denied" { + if permission_confirmation_acceptance_block_message(verification_context).is_some() { push_unique_string( &mut suggested_actions.followup_actions, - "先处理被拒绝的权限确认或重新发起用户授权,不要基于当前交接直接判定成功交付。", + "先处理未解决的权限确认或重新发起用户授权,不要基于当前交接直接判定成功交付。", ); push_unique_string( &mut suggested_actions.regression_requirements, - "重新导出 evidence pack / handoff / review decision,并确认 permissionConfirmationStatus 不再是 denied。", + "重新导出 evidence pack / handoff / review decision,并确认 permissionConfirmationStatus 已变为 resolved 或不再阻断 accepted。", + ); + } + + if user_locked_capability_acceptance_block_message(verification_context).is_some() { + push_unique_string( + &mut suggested_actions.followup_actions, + "切换到满足 routingSlot 的模型或取消显式模型锁定,再重跑 turn;不要基于当前模型锁定能力缺口交接直接判定成功交付。", + ); + push_unique_string( + &mut suggested_actions.regression_requirements, + "重新导出 evidence pack / replay case / handoff / analysis / review decision,并确认 limitStatus 不再是 user_locked_capability_gap。", ); } @@ -1356,6 +1493,13 @@ mod tests { confirmation_status: &str, request_id: &str, ) { + let confirmation_request_id = + (!request_id.trim().is_empty()).then(|| request_id.to_string()); + let confirmation_source = if confirmation_status == "not_requested" { + "declared_profile_only" + } else { + "runtime_action_required" + }; thread_read.permission_state = Some(lime_agent::SessionExecutionRuntimePermissionState { status: "requires_confirmation".to_string(), required_profile_keys: vec!["browser_control".to_string()], @@ -1364,12 +1508,26 @@ mod tests { decision_source: "runtime_task_profile".to_string(), decision_scope: "declared_profile_only".to_string(), confirmation_status: Some(confirmation_status.to_string()), - confirmation_request_id: Some(request_id.to_string()), - confirmation_source: Some("runtime_action_required".to_string()), + confirmation_request_id, + confirmation_source: Some(confirmation_source.to_string()), notes: Vec::new(), }); } + fn set_user_locked_capability_gap(thread_read: &mut AgentRuntimeThreadReadModel) { + thread_read.capability_gap = Some("browser_reasoning_candidate_missing".to_string()); + thread_read.limit_state = Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "user_locked_capability_gap".to_string(), + single_candidate_only: true, + provider_locked: false, + settings_locked: true, + oem_locked: false, + candidate_count: 1, + capability_gap: Some("browser_reasoning_candidate_missing".to_string()), + notes: vec!["显式模型锁定不满足 browser_reasoning routingSlot".to_string()], + }); + } + fn seed_recovered_verification(detail: &mut SessionDetail, root: &std::path::Path) { let artifact_relative_path = ".lime/artifacts/thread-1/report.artifact.json"; let artifact_absolute_path = @@ -1637,12 +1795,56 @@ mod tests { .decision .followup_actions .iter() - .any(|item| item.contains("先处理被拒绝的权限确认"))); + .any(|item| item.contains("先处理未解决的权限确认"))); assert!(result .decision .regression_requirements .iter() - .any(|item| item.contains("permissionConfirmationStatus 不再是 denied"))); + .any(|item| item.contains("permissionConfirmationStatus 已变为 resolved"))); + } + + #[test] + fn should_surface_user_locked_capability_gap_in_review_decision() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + set_user_locked_capability_gap(&mut thread_read); + + let result = + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + let markdown_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/review/review-decision.md"); + let json_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/review/review-decision.json"); + + let markdown = fs::read_to_string(markdown_path).expect("markdown"); + let json = fs::read_to_string(json_path).expect("json"); + + assert!(markdown.contains("显式用户模型锁定")); + assert!(markdown.contains("browser_reasoning_candidate_missing")); + assert!(markdown.contains("不应把本次 review decision 标记为 accepted")); + assert!(json.contains("\"limitStatus\": \"user_locked_capability_gap\"")); + assert!(json.contains("\"capabilityGap\": \"browser_reasoning_candidate_missing\"")); + assert_eq!(result.limit_status, "user_locked_capability_gap"); + assert_eq!(result.capability_gap, "browser_reasoning_candidate_missing"); + assert!(result + .review_checklist + .iter() + .any(|item| item.contains("显式用户模型锁定"))); + assert!(result + .decision + .followup_actions + .iter() + .any(|item| item.contains("切换到满足 routingSlot 的模型"))); + assert!(result + .decision + .regression_requirements + .iter() + .any(|item| item.contains("limitStatus 不再是 user_locked_capability_gap"))); } #[test] @@ -1701,6 +1903,112 @@ mod tests { assert_eq!(saved.permission_confirmation_request_id, "approval-denied"); } + #[test] + fn should_reject_accepted_review_decision_when_user_locked_capability_gap() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + set_user_locked_capability_gap(&mut thread_read); + + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + let error = save_runtime_review_decision( + &detail, + &thread_read, + temp_dir.path(), + RuntimeReviewDecisionContent { + decision_status: "accepted".to_string(), + decision_summary: "错误接受模型锁定能力缺口。".to_string(), + chosen_fix_strategy: "直接接受。".to_string(), + risk_level: "low".to_string(), + risk_tags: vec!["model-routing".to_string()], + human_reviewer: "Lime Maintainer".to_string(), + reviewed_at: None, + followup_actions: Vec::new(), + regression_requirements: Vec::new(), + notes: String::new(), + }, + ) + .expect_err("user locked capability gap must block accepted decision"); + + assert!(error.contains("显式用户模型锁定")); + assert!(error.contains("不能把本次 review decision 保存为 accepted")); + } + + #[test] + fn should_reject_accepted_review_decision_when_permission_confirmation_not_requested() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + set_permission_confirmation(&mut thread_read, "not_requested", ""); + + let exported = + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + assert_eq!(exported.permission_confirmation_status, "not_requested"); + assert!(exported + .review_checklist + .iter() + .any(|item| item.contains("尚未发起真实审批请求"))); + + let error = save_runtime_review_decision( + &detail, + &thread_read, + temp_dir.path(), + RuntimeReviewDecisionContent { + decision_status: "accepted".to_string(), + decision_summary: "错误接受未确认权限。".to_string(), + chosen_fix_strategy: "直接接受。".to_string(), + risk_level: "low".to_string(), + risk_tags: vec!["permission".to_string()], + human_reviewer: "Lime Maintainer".to_string(), + reviewed_at: None, + followup_actions: Vec::new(), + regression_requirements: Vec::new(), + notes: String::new(), + }, + ) + .expect_err("not_requested permission confirmation must block accepted decision"); + + assert!(error.contains("尚未发起真实审批请求")); + assert!(error.contains("不能把本次 review decision 保存为 accepted")); + } + + #[test] + fn should_reject_accepted_review_decision_when_permission_confirmation_requested() { + let temp_dir = TempDir::new().expect("temp dir"); + let detail = build_detail(); + let mut thread_read = build_thread_read(); + set_permission_confirmation(&mut thread_read, "requested", "approval-pending"); + + export_runtime_review_decision_template(&detail, &thread_read, temp_dir.path()) + .expect("export"); + + let error = save_runtime_review_decision( + &detail, + &thread_read, + temp_dir.path(), + RuntimeReviewDecisionContent { + decision_status: "accepted".to_string(), + decision_summary: "错误接受等待处理的权限确认。".to_string(), + chosen_fix_strategy: "直接接受。".to_string(), + risk_level: "low".to_string(), + risk_tags: vec!["permission".to_string()], + human_reviewer: "Lime Maintainer".to_string(), + reviewed_at: None, + followup_actions: Vec::new(), + regression_requirements: Vec::new(), + notes: String::new(), + }, + ) + .expect_err("requested permission confirmation must block accepted decision"); + + assert!(error.contains("仍在等待处理")); + assert!(error.contains("不能把本次 review decision 保存为 accepted")); + } + #[test] fn should_surface_resolved_permission_confirmation_in_review_decision() { let temp_dir = TempDir::new().expect("temp dir"); diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index c05898117..16c793437 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.27.0", + "version": "1.28.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0d329f628..9dd69dc79 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.27.0", + "version": "1.28.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index 9691eb937..4b1f9951d 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -3,7 +3,7 @@ * @description 根路由组件 - 根据 URL 路径渲染对应的组件 */ -import { useEffect } from "react"; +import { lazy, Suspense, useEffect } from "react"; import App from "./App"; import { SmartInputPage } from "./pages/smart-input"; import { UpdateNotificationPage } from "./pages/update-notification"; @@ -16,6 +16,12 @@ import { finalizeModuleImportAutoReload } from "./components/layout/CrashRecover import { getRuntimeAppVersion } from "./lib/appVersion"; import { startOemCloudStartupLoginIfRequired } from "./lib/oemCloudStartupLogin"; +const DesignCanvasSmokePage = lazy(() => + import("./pages/design-canvas-smoke").then((module) => ({ + default: module.DesignCanvasSmokePage, + })), +); + /** * 根据 URL 路径渲染对应的组件 * @@ -102,6 +108,17 @@ export function RootRouter() { ); } + if (pathname === "/design-canvas-smoke" && import.meta.env.DEV) { + return ( + + + + + + + ); + } + // 默认渲染主应用 return ( diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 29bf5730d..d3a84b38b 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -153,6 +153,17 @@ function serializeInitialInputCapabilityKey(params: AgentPageParams): string { return `${route.kind}:${routeKey}:${params.initialInputCapability?.requestKey ?? 0}`; } +function serializeInitialKnowledgePackSelectionKey( + params: AgentPageParams, +): string { + const selection = params.initialKnowledgePackSelection; + if (!selection) { + return "::0"; + } + + return `${selection.enabled ? "1" : "0"}:${selection.workingDir}:${selection.packName}`; +} + interface AppPageContentProps { currentPage: Page; pageParams: PageParams; @@ -260,7 +271,7 @@ export function AppPageContent({ const content = (
{ expect(container.textContent).not.toContain("生成"); expect(container.textContent).toContain("我的方法"); expect(container.textContent).toContain("灵感库"); - expect(container.textContent).toContain("知识库"); + expect(container.textContent).toContain("项目资料"); expect(container.textContent).not.toContain("设置"); expect(container.textContent).not.toContain("持续流程"); expect(container.textContent).not.toContain("消息渠道"); @@ -502,7 +502,6 @@ describe("AppSidebar", () => { expect(container.textContent).not.toContain("支撑"); expect(container.textContent).not.toContain("技能"); expect(container.textContent).not.toContain("能力"); - expect(container.textContent).not.toContain("资料"); expect(container.textContent).not.toContain("系统"); const mainNavButtons = Array.from( @@ -516,7 +515,7 @@ describe("AppSidebar", () => { "新建任务", "我的方法", "灵感库", - "知识库", + "项目资料", ]); expect( container.querySelector('[data-testid="app-sidebar-footer-nav"]'), diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index cedddd8fb..a3218e729 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -632,6 +632,17 @@ function normalizeVideoResolution(value?: string): "480p" | "720p" | "1080p" { } } +function isUsableKnowledgeSourceText(value: string): boolean { + const normalized = value.trim().replace(/\s+/g, " "); + if (normalized.length < 24) { + return false; + } + + return !/请先.*(提供|补充).*(资料|素材|原文)|还没有.*(资料|素材|原文)|不能编造|无法.*沉淀/.test( + normalized, + ); +} + export type { AgentChatWorkspaceProps, WorkflowProgressSnapshot, @@ -664,6 +675,7 @@ export function AgentChatWorkspace({ entryBannerMessage, initialPendingServiceSkillLaunch, initialInputCapability, + initialKnowledgePackSelection, initialProjectFileOpenTarget, onInitialUserPromptConsumed, newChatAt, @@ -7478,6 +7490,7 @@ export function AgentChatWorkspace({ onSelectServiceSkill: workspaceServiceSkillEntryActions.handleServiceSkillSelect, initialInputCapability: effectiveInitialInputCapability, + initialKnowledgePackSelection, setChatToolPreferences, handleNavigateToSkillSettings, handleRefreshSkills, @@ -7531,6 +7544,28 @@ export function AgentChatWorkspace({ : undefined, inputCompletionEnabled, }); + const importTextAsKnowledge = inputbarScene.onImportTextAsKnowledge; + const handleSaveMessageAsKnowledge = useCallback( + (source: { messageId: string; content: string }) => { + const sourceText = source.content.trim(); + if (!sourceText) { + toast.error("这条结果暂时没有可沉淀的内容"); + return; + } + if (!isUsableKnowledgeSourceText(sourceText)) { + toast.info("这条结果还不是可复用资料,请先补充原始内容后再沉淀。"); + return; + } + + importTextAsKnowledge({ + sourceName: `agent-output-${source.messageId}.md`, + sourceText, + description: teamSessionRuntime.currentSessionTitle || "对话结果资料", + packType: "custom", + }); + }, + [importTextAsKnowledge, teamSessionRuntime.currentSessionTitle], + ); const canvasScene = useWorkspaceCanvasSceneRuntime({ shouldBootstrapCanvasOnEntry, @@ -7924,6 +7959,8 @@ export function AgentChatWorkspace({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences: handleAddPathReferences, + onImportPathReferenceAsKnowledge: + inputbarScene.onImportPathReferenceAsKnowledge, onRemovePathReference: handleRemovePathReference, onClearPathReferences: handleClearPathReferences, fileManagerOpen: fileManagerAvailable && fileManagerSidebarOpen, @@ -8048,6 +8085,7 @@ export function AgentChatWorkspace({ handleOpenMessagePreview, handleSaveMessageAsSkill, handleSaveMessageAsInspiration, + handleSaveMessageAsKnowledge, handleOpenSubagentSession, handlePermissionResponse, pendingPromotedA2UIActionRequest, @@ -8080,6 +8118,7 @@ export function AgentChatWorkspace({ handleSetFileManagerSidebarOpen(false)} onAddPathReferences={handleAddPathReferences} + onImportAsKnowledge={inputbarScene.onImportPathReferenceAsKnowledge} /> ) : null; diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts index 97536c191..7e044e192 100644 --- a/src/components/agent/chat/agentChatWorkspaceContract.ts +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -4,6 +4,7 @@ import type { StepStatus } from "@/lib/workspace/workbenchContract"; import type { Page, PageParams } from "@/types/page"; import type { AgentInitialInputCapabilityParams, + AgentInitialKnowledgePackSelectionParams, AgentPendingServiceSkillLaunchParams, AgentProjectFileOpenTarget, AgentSiteSkillLaunchParams, @@ -57,5 +58,6 @@ export interface AgentChatWorkspaceProps { initialSiteSkillLaunch?: AgentSiteSkillLaunchParams; initialPendingServiceSkillLaunch?: AgentPendingServiceSkillLaunchParams; initialInputCapability?: AgentInitialInputCapabilityParams; + initialKnowledgePackSelection?: AgentInitialKnowledgePackSelectionParams; initialProjectFileOpenTarget?: AgentProjectFileOpenTarget; } diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index 46380a4f9..a290e76d2 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -151,7 +151,7 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("我的方法"); expect(container.textContent).toContain("资料"); expect(container.textContent).toContain("灵感库"); - expect(container.textContent).toContain("知识库"); + expect(container.textContent).toContain("项目资料"); expect(searchInput).toBeTruthy(); expect( container.querySelector('button[aria-label="新建对话"]'), @@ -194,7 +194,7 @@ describe("ChatSidebar", () => { act(() => { ( Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.includes("知识库"), + button.textContent?.includes("项目资料"), ) as HTMLButtonElement | undefined )?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index a17832f1d..4e401d1fd 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -798,7 +798,7 @@ export const ChatSidebar: React.FC = ({ items: [ { id: "knowledge", - label: "知识库", + label: "项目资料", icon: BookOpen, onClick: onOpenKnowledgePage, }, diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index b7f1fe995..601291e66 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -549,12 +549,16 @@ describe("EmptyState", () => { ).toBeTruthy(); expect(container.textContent).toContain("引导帮助"); expect(container.textContent).toContain("写作"); + expect(container.textContent).toContain("添加资料"); expect(container.textContent).toContain("PPT"); expect(container.textContent).toContain("调研报告"); expect(container.textContent).toContain("更多做法"); expect( container.querySelector('[data-testid="home-guide-cards"]'), ).toBeNull(); + expect( + container.querySelector('[data-testid="entry-home-knowledge-import"]'), + ).toBeTruthy(); const scrollCue = container.querySelector( '[data-testid="home-scroll-cue"]', ); @@ -578,6 +582,62 @@ describe("EmptyState", () => { ).toBeNull(); }); + it("首页添加资料入口应打开输入框资料中枢,而不是预填一段说明", async () => { + const setInput = vi.fn(); + const onToggleKnowledgePack = vi.fn<(enabled: boolean) => void>(); + const container = renderEmptyState({ + setInput, + knowledgePackSelection: { + enabled: false, + packName: "team-notes", + workingDir: "workspace-root", + label: "团队资料", + status: "ready", + }, + knowledgePackOptions: [ + { + packName: "team-notes", + label: "团队资料", + status: "ready", + defaultForWorkspace: true, + }, + ], + onToggleKnowledgePack, + onSelectKnowledgePack: vi.fn(), + onStartKnowledgeOrganize: vi.fn(), + onManageKnowledgePacks: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + }); + + const starter = container.querySelector( + '[data-testid="entry-home-knowledge-import"]', + ) as HTMLButtonElement | null; + expect(starter?.textContent).toContain("添加资料"); + + act(() => { + starter?.click(); + }); + + expect(setInput).not.toHaveBeenCalled(); + expect( + container.querySelector('[data-testid="inputbar-knowledge-hub"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("可使用:团队资料"); + + const useKnowledgeButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("使用这份资料")); + + act(() => { + useKnowledgeButton?.click(); + }); + + expect(onToggleKnowledgePack).toHaveBeenCalledWith(true); + }); + it("点击引导帮助后进入可关闭的帮助模式,关闭后恢复默认起手入口", async () => { const container = renderEmptyState({ activeTheme: "general", @@ -603,6 +663,7 @@ describe("EmptyState", () => { expect( container.querySelector('[data-testid="home-guide-cards"]'), ).toBeTruthy(); + expect(container.textContent).toContain("项目资料怎么添加和使用?"); expect( container.querySelector('[data-testid="home-starter-chips"]'), ).toBeNull(); @@ -1746,6 +1807,133 @@ describe("EmptyState", () => { ); }); + it("首页选择 @资料 兼容入口时应打开资料中枢而不是普通命令标签", async () => { + const onToggleKnowledgePack = vi.fn<(enabled: boolean) => void>(); + const onStartKnowledgeOrganize = vi.fn(); + const command = { + key: "knowledge_pack", + label: "资料", + mentionLabel: "资料", + commandPrefix: "@资料", + description: "查看、添加、选择或使用当前项目资料。", + aliases: [], + }; + const container = renderEmptyState({ + input: "按项目资料写一版介绍", + knowledgePackSelection: { + enabled: false, + packName: "team-notes", + workingDir: "workspace-root", + label: "团队资料", + status: "ready", + }, + knowledgePackOptions: [ + { + packName: "team-notes", + label: "团队资料", + status: "ready", + defaultForWorkspace: true, + }, + ], + onToggleKnowledgePack, + onSelectKnowledgePack: vi.fn(), + onStartKnowledgeOrganize, + onManageKnowledgePacks: vi.fn(), + }); + await act(async () => { + await Promise.resolve(); + }); + + const latestCall = + mockCharacterMention.mock.calls[ + mockCharacterMention.mock.calls.length - 1 + ][0]; + + await act(async () => { + latestCall.onSelectInputCapability?.({ + kind: "builtin_command", + command, + }); + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="inputbar-builtin-command-badge"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="inputbar-knowledge-hub"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("可使用:团队资料"); + expect(container.textContent).toContain("使用这份资料"); + + const useKnowledgeButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("使用这份资料")); + + act(() => { + useKnowledgeButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(onToggleKnowledgePack).toHaveBeenCalledWith(true); + expect(onStartKnowledgeOrganize).not.toHaveBeenCalled(); + }); + + it("首页启用项目资料后发送应携带资料引用 metadata", async () => { + const onSend = vi.fn(); + const container = renderEmptyState({ + input: "按项目资料写一版介绍", + onSend, + knowledgePackSelection: { + enabled: true, + packName: "team-notes", + workingDir: "workspace-root", + label: "团队资料", + status: "ready", + }, + knowledgePackOptions: [ + { + packName: "team-notes", + label: "团队资料", + status: "ready", + defaultForWorkspace: true, + }, + ], + onToggleKnowledgePack: vi.fn(), + onSelectKnowledgePack: vi.fn(), + onStartKnowledgeOrganize: vi.fn(), + onManageKnowledgePacks: vi.fn(), + }); + await act(async () => { + await Promise.resolve(); + }); + + const sendButton = container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement | null; + expect(sendButton).toBeTruthy(); + + act(() => { + sendButton?.click(); + }); + + expect(onSend).toHaveBeenCalledWith( + "按项目资料写一版介绍", + "react", + undefined, + { + requestMetadata: { + knowledge_pack: { + pack_name: "team-notes", + working_dir: "workspace-root", + source: "inputbar", + }, + }, + }, + ); + }); + it("首页点击结果模板后发送时,应透传 curated_task capability route", async () => { const template = findCuratedTaskTemplateById("daily-trend-briefing"); expect(template).toBeTruthy(); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 4252e735d..72eeb4e49 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -74,6 +74,10 @@ import { resolveInputCapabilityDispatch, type InputCapabilitySelection, } from "../skill-selection/inputCapabilitySelection"; +import type { + InputbarKnowledgePackOption, + InputbarKnowledgePackSelection, +} from "./Inputbar/types"; import { listSlashEntryUsage, subscribeSlashEntryUsageChanged, @@ -397,9 +401,22 @@ interface EmptyStateProps extends SkillSelectionSourceProps { defaultCuratedTaskReferenceMemoryIds?: string[]; /** 当前结果模板默认带入的参考对象 */ defaultCuratedTaskReferenceEntries?: CuratedTaskReferenceEntry[]; + /** 当前项目资料选择态 */ + knowledgePackSelection?: InputbarKnowledgePackSelection | null; + /** 当前项目可选资料 */ + knowledgePackOptions?: InputbarKnowledgePackOption[]; + /** 启用 / 关闭当前项目资料 */ + onToggleKnowledgePack?: (enabled: boolean) => void; + /** 切换当前项目资料 */ + onSelectKnowledgePack?: (packName: string) => void; + /** 从当前输入或会话沉淀项目资料 */ + onStartKnowledgeOrganize?: () => void; + /** 打开项目资料管理 */ + onManageKnowledgePacks?: () => void; /** 输入框已添加的本地文件/文件夹引用 */ pathReferences?: MessagePathReference[]; onAddPathReferences?: (references: MessagePathReference[]) => void; + onImportPathReferenceAsKnowledge?: (reference: MessagePathReference) => void; onRemovePathReference?: (id: string) => void; onClearPathReferences?: () => void; fileManagerOpen?: boolean; @@ -494,8 +511,15 @@ export const EmptyState: React.FC = ({ creationReplaySurface = null, defaultCuratedTaskReferenceMemoryIds, defaultCuratedTaskReferenceEntries, + knowledgePackSelection = null, + knowledgePackOptions = [], + onToggleKnowledgePack, + onSelectKnowledgePack, + onStartKnowledgeOrganize, + onManageKnowledgePacks, pathReferences = [], onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen = false, @@ -504,6 +528,8 @@ export const EmptyState: React.FC = ({ const pageContainerRef = useRef(null); const [activeCapability, setActiveCapability] = useState(null); + const [knowledgeHubOpenRequestKey, setKnowledgeHubOpenRequestKey] = + useState(0); const activeCuratedTaskCapability = activeCapability?.kind === "curated_task" ? activeCapability : null; const activeCuratedTask = activeCuratedTaskCapability?.task ?? null; @@ -537,6 +563,10 @@ export const EmptyState: React.FC = ({ activeCapability?.kind === "installed_skill" ? activeCapability.skill : null; + const activeBuiltinCommandKey = + activeCapability?.kind === "builtin_command" + ? activeCapability.command.key + : null; const clearSelectedSkill = useCallback(() => { setActiveCapability(null); }, []); @@ -547,10 +577,48 @@ export const EmptyState: React.FC = ({ onSelectServiceSkill?.(capability.skill); return; } + if (capability.kind === "builtin_command") { + if (capability.command.key === "knowledge_pack") { + if (!knowledgePackSelection && !onStartKnowledgeOrganize) { + onManageKnowledgePacks?.(); + } else { + setKnowledgeHubOpenRequestKey((current) => current + 1); + } + setActiveCapability(null); + return; + } + + if (capability.command.key === "knowledge_settle") { + onStartKnowledgeOrganize?.(); + setActiveCapability(null); + return; + } + } setActiveCapability(capability); }, - [onSelectServiceSkill], + [ + knowledgePackSelection, + onManageKnowledgePacks, + onSelectServiceSkill, + onStartKnowledgeOrganize, + ], ); + useEffect(() => { + if (activeBuiltinCommandKey !== "knowledge_pack") { + return; + } + setActiveCapability(null); + if (!knowledgePackSelection && !onStartKnowledgeOrganize) { + onManageKnowledgePacks?.(); + return; + } + setKnowledgeHubOpenRequestKey((current) => current + 1); + }, [ + activeBuiltinCommandKey, + knowledgePackSelection, + onManageKnowledgePacks, + onStartKnowledgeOrganize, + ]); const skillSelection = buildSkillSelectionProps({ skills, serviceSkills, @@ -814,10 +882,23 @@ export const EmptyState: React.FC = ({ activeCapability, inputOverride, ); - const requestMetadata = buildPathReferenceRequestMetadata( + const baseRequestMetadata = buildPathReferenceRequestMetadata( capabilityDispatch.requestMetadata, pathReferences, ); + const requestMetadata = + knowledgePackSelection?.enabled && + knowledgePackSelection.packName.trim() && + knowledgePackSelection.workingDir.trim() + ? { + ...(baseRequestMetadata || {}), + knowledge_pack: { + pack_name: knowledgePackSelection.packName.trim(), + working_dir: knowledgePackSelection.workingDir.trim(), + source: "inputbar", + }, + } + : baseRequestMetadata; const effectiveInput = inputOverride.trim() ? inputOverride : hasPathReferences @@ -1298,6 +1379,15 @@ export const EmptyState: React.FC = ({ } return; } + if (chip.launchKind === "open_knowledge_hub") { + setGuideHelpActive(false); + if (!knowledgePackSelection && !onStartKnowledgeOrganize) { + onManageKnowledgePacks?.(); + } else { + setKnowledgeHubOpenRequestKey((current) => current + 1); + } + return; + } const targetItem = chip.targetItemId ? homeSkillItems.find((item) => item.id === chip.targetItemId) @@ -1335,7 +1425,10 @@ export const EmptyState: React.FC = ({ effectiveDefaultCuratedTaskReferenceMemoryIds, handleSelectHomeSkillItem, homeSkillItems, + knowledgePackSelection, + onManageKnowledgePacks, onOpenSceneAppsDirectory, + onStartKnowledgeOrganize, setInput, ], ); @@ -1461,6 +1554,13 @@ export const EmptyState: React.FC = ({ defaultCuratedTaskReferenceEntries={ effectiveDefaultCuratedTaskReferenceEntries } + knowledgePackSelection={knowledgePackSelection} + knowledgePackOptions={knowledgePackOptions} + knowledgeHubOpenRequestKey={knowledgeHubOpenRequestKey} + onToggleKnowledgePack={onToggleKnowledgePack} + onSelectKnowledgePack={onSelectKnowledgePack} + onStartKnowledgeOrganize={onStartKnowledgeOrganize} + onManageKnowledgePacks={onManageKnowledgePacks} showCreationModeSelector={showCreationModeSelector} creationMode={creationMode} onCreationModeChange={onCreationModeChange} @@ -1482,6 +1582,7 @@ export const EmptyState: React.FC = ({ onDrop={handleDrop} onRemoveImage={handleRemoveImage} pathReferences={pathReferences} + onImportPathReferenceAsKnowledge={onImportPathReferenceAsKnowledge} onRemovePathReference={onRemovePathReference} fileManagerOpen={fileManagerOpen} onToggleFileManager={onToggleFileManager} diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 8b35d89c3..99584c8b8 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -360,6 +360,79 @@ describe("EmptyStateComposerPanel", () => { expect(onToggleFileManager).toHaveBeenCalledTimes(1); }); + it("首页空态输入区应把项目资料作为底栏主入口", () => { + const container = renderPanel({ + knowledgePackSelection: { + enabled: false, + packName: "team-notes", + workingDir: "workspace-root", + label: "团队资料", + status: "ready", + }, + knowledgePackOptions: [ + { + packName: "team-notes", + label: "团队资料", + status: "ready", + defaultForWorkspace: true, + }, + ], + onToggleKnowledgePack: vi.fn(), + onSelectKnowledgePack: vi.fn(), + onStartKnowledgeOrganize: vi.fn(), + onManageKnowledgePacks: vi.fn(), + }); + + const toggleButton = container.querySelector( + '[data-testid="inputbar-knowledge-pack-toggle"]', + ) as HTMLButtonElement | null; + + expect(toggleButton).toBeTruthy(); + expect(toggleButton?.textContent).toContain("项目资料:未使用"); + + act(() => { + toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect( + container.querySelector('[data-testid="inputbar-knowledge-hub"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("可使用:团队资料"); + }); + + it("@资料兼容触发时不应渲染普通命令标签", () => { + const container = renderPanel({ + activeCapability: { + kind: "builtin_command", + command: { + key: "knowledge_pack", + label: "资料", + mentionLabel: "资料", + commandPrefix: "@资料", + description: "打开项目资料。", + aliases: [], + }, + }, + knowledgePackSelection: { + enabled: false, + packName: "team-notes", + workingDir: "workspace-root", + label: "团队资料", + status: "ready", + }, + onToggleKnowledgePack: vi.fn(), + onStartKnowledgeOrganize: vi.fn(), + onManageKnowledgePacks: vi.fn(), + }); + + expect( + container.querySelector('[data-testid="inputbar-builtin-command-badge"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="inputbar-knowledge-pack-toggle"]'), + ).toBeTruthy(); + }); + it("首页空态输入区应展示本地路径 chip 并支持移除", () => { const onRemovePathReference = vi.fn(); const container = renderPanel({ @@ -381,6 +454,7 @@ describe("EmptyStateComposerPanel", () => { container.querySelector('[data-testid="inputbar-path-reference-chip"]') ?.textContent, ).toContain("Downloads"); + expect(container.textContent).not.toContain("/Users/lime/Downloads"); const removeButton = container.querySelector( 'button[aria-label="移除路径 Downloads"]', @@ -395,6 +469,38 @@ describe("EmptyStateComposerPanel", () => { ); }); + it("首页空态输入区的文本文件 chip 应支持设为项目资料", () => { + const onImportPathReferenceAsKnowledge = vi.fn(); + const reference = { + id: "file:/Users/lime/brief.md", + path: "/Users/lime/brief.md", + name: "brief.md", + isDir: false, + size: 128, + mimeType: "text/markdown", + source: "file_manager" as const, + }; + const container = renderPanel({ + pathReferences: [reference], + onImportPathReferenceAsKnowledge, + }); + + expect(container.textContent).toContain("brief.md"); + expect(container.textContent).toContain("本地文件"); + expect(container.textContent).not.toContain("/Users/lime/brief.md"); + + const importButton = container.querySelector( + 'button[aria-label="设为项目资料 brief.md"]', + ) as HTMLButtonElement | null; + expect(importButton).toBeTruthy(); + + act(() => { + importButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onImportPathReferenceAsKnowledge).toHaveBeenCalledWith(reference); + }); + it("输入为空时展示 Tab 起手建议,按 Tab 后填入当前建议", async () => { const container = renderPanel({ inputSuggestions: [ diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index df3078a92..03e2f2144 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -21,6 +21,7 @@ import { BuiltinCommandBadge } from "./Inputbar/components/BuiltinCommandBadge"; import { InputbarAccessModeSelect } from "./Inputbar/components/InputbarAccessModeSelect"; import { InputbarCore } from "./Inputbar/components/InputbarCore"; import { InputbarExecutionStrategySelect } from "./Inputbar/components/InputbarExecutionStrategySelect"; +import { InputbarKnowledgeControl } from "./Inputbar/knowledge/InputbarKnowledgeControl"; import { InputbarModelExtra } from "./Inputbar/components/InputbarModelExtra"; import { RuntimeSceneBadge } from "./Inputbar/components/RuntimeSceneBadge"; import { CuratedTaskBadge } from "../skill-selection/CuratedTaskBadge"; @@ -59,6 +60,10 @@ import type { CuratedTaskReferenceEntry } from "../utils/curatedTaskReferenceSel import type { CreationReplaySurfaceModel } from "../utils/creationReplaySurface"; import type { HomeInputSuggestion } from "../home/homeSurfaceTypes"; import { getProviderLabel } from "@/lib/constants/providerMappings"; +import type { + InputbarKnowledgePackOption, + InputbarKnowledgePackSelection, +} from "./Inputbar/types"; interface EmptyStateComposerPanelProps { input: string; @@ -91,6 +96,13 @@ interface EmptyStateComposerPanelProps { sessionId?: string | null; defaultCuratedTaskReferenceMemoryIds?: string[]; defaultCuratedTaskReferenceEntries?: CuratedTaskReferenceEntry[]; + knowledgePackSelection?: InputbarKnowledgePackSelection | null; + knowledgePackOptions?: InputbarKnowledgePackOption[]; + knowledgeHubOpenRequestKey?: number; + onToggleKnowledgePack?: (enabled: boolean) => void; + onSelectKnowledgePack?: (packName: string) => void; + onStartKnowledgeOrganize?: () => void; + onManageKnowledgePacks?: () => void; showCreationModeSelector: boolean; creationMode: CreationMode; onCreationModeChange?: (mode: CreationMode) => void; @@ -112,6 +124,7 @@ interface EmptyStateComposerPanelProps { onDrop?: (event: React.DragEvent) => void; onRemoveImage?: (index: number) => void; pathReferences?: MessagePathReference[]; + onImportPathReferenceAsKnowledge?: (reference: MessagePathReference) => void; onRemovePathReference?: (id: string) => void; fileManagerOpen?: boolean; onToggleFileManager?: () => void; @@ -200,6 +213,13 @@ export function EmptyStateComposerPanel({ sessionId = null, defaultCuratedTaskReferenceMemoryIds = [], defaultCuratedTaskReferenceEntries = [], + knowledgePackSelection = null, + knowledgePackOptions = [], + knowledgeHubOpenRequestKey, + onToggleKnowledgePack, + onSelectKnowledgePack, + onStartKnowledgeOrganize, + onManageKnowledgePacks, showCreationModeSelector, creationMode, onCreationModeChange, @@ -221,6 +241,7 @@ export function EmptyStateComposerPanel({ onDrop, onRemoveImage, pathReferences = [], + onImportPathReferenceAsKnowledge, onRemovePathReference, fileManagerOpen = false, onToggleFileManager, @@ -240,7 +261,9 @@ export function EmptyStateComposerPanel({ >(null); const [showAdvancedControls, setShowAdvancedControls] = useState(false); const activeBuiltinCommand = - activeCapability?.kind === "builtin_command" + activeCapability?.kind === "builtin_command" && + activeCapability.command.key !== "knowledge_pack" && + activeCapability.command.key !== "knowledge_settle" ? activeCapability.command : null; const activeRuntimeScene = @@ -467,10 +490,24 @@ export function EmptyStateComposerPanel({ const currentModelSummary = hasConfiguredModel ? `${getProviderLabel(trimmedProviderType)} / ${trimmedModel}` : null; + const knowledgePackControl = + knowledgePackSelection || onStartKnowledgeOrganize ? ( + + ) : null; const hasHighlightedAdvancedPreference = thinkingEnabled || webSearchEnabled || subagentEnabled || + knowledgePackSelection?.enabled || executionStrategy === "code_orchestrated" || accessMode === "read-only" || accessMode === "full-access"; @@ -482,31 +519,37 @@ export function EmptyStateComposerPanel({ Boolean(setAccessMode) || shouldShowThemeSpecificExtra || Boolean(onToggleFileManager); - const leftExtra = shouldShowAdvancedToggle ? ( + const shouldShowLeftExtra = + Boolean(knowledgePackControl) || shouldShowAdvancedToggle; + const leftExtra = shouldShowLeftExtra ? ( <> - setShowAdvancedControls((previous) => !previous)} - > - - - - - 高级设置 - {showAdvancedControls ? ( - - ) : ( - - )} - + aria-label={showAdvancedControls ? "收起高级设置" : "展开高级设置"} + aria-expanded={showAdvancedControls} + data-testid="empty-state-advanced-toggle" + title={showAdvancedControls ? "收起高级设置" : "展开高级设置"} + onClick={() => setShowAdvancedControls((previous) => !previous)} + > + + + + + 高级设置 + {showAdvancedControls ? ( + + ) : ( + + )} + + ) : null} {guideHelpActive ? ( {slogan ? ( - - {slogan} ) : null} diff --git a/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx b/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx index 57a151d41..ebadfdeb7 100644 --- a/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx +++ b/src/components/agent/chat/components/FileManager/FileManagerSidebar.test.tsx @@ -76,6 +76,14 @@ function createListing(path: string): DirectoryListing { modifiedAt: Date.now(), mimeType: "text/plain", }, + { + name: "contract.pdf", + path: "/Users/demo/contract.pdf", + isDir: false, + size: 2048, + modifiedAt: Date.now(), + mimeType: "application/pdf", + }, ], error: null, }; @@ -86,18 +94,23 @@ async function renderFileManagerSidebar(props?: { onAddPathReferences?: React.ComponentProps< typeof FileManagerSidebar >["onAddPathReferences"]; + onImportAsKnowledge?: React.ComponentProps< + typeof FileManagerSidebar + >["onImportAsKnowledge"]; }) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); const onClose = props?.onClose ?? vi.fn(); const onAddPathReferences = props?.onAddPathReferences ?? vi.fn(); + const onImportAsKnowledge = props?.onImportAsKnowledge; await act(async () => { root.render( , ); await Promise.resolve(); @@ -250,6 +263,19 @@ describe("FileManagerSidebar", () => { ]); }); + it("顶部位置说明不应直接暴露本机完整路径", async () => { + const { container } = await renderFileManagerSidebar(); + + expect(container.textContent).toContain("个人"); + expect(container.textContent).toContain("本地位置"); + expect(container.textContent).not.toContain("/Users/demo"); + + const locationHint = Array.from(container.querySelectorAll("p")).find( + (element) => element.textContent?.includes("本地位置"), + ); + expect(locationHint?.getAttribute("title")).toBe("当前文件夹"); + }); + it("应支持关闭侧栏", async () => { const onClose = vi.fn(); const { container } = await renderFileManagerSidebar({ onClose }); @@ -267,6 +293,158 @@ describe("FileManagerSidebar", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("右键文本文件应可直接设为项目资料", async () => { + const onImportAsKnowledge = vi.fn(); + const { container } = await renderFileManagerSidebar({ + onImportAsKnowledge, + }); + + const fileEntry = Array.from( + container.querySelectorAll('[data-testid="file-manager-entry"]'), + ).find((entry) => entry.textContent?.includes("brief.txt")); + + await act(async () => { + fileEntry?.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 48, + clientY: 56, + }), + ); + await Promise.resolve(); + }); + + const menu = document.querySelector( + '[data-testid="file-manager-context-menu"]', + ); + expect(menu?.textContent).toContain("设为项目资料"); + + const importAction = Array.from( + menu?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.includes("设为项目资料")); + expect(importAction).toBeTruthy(); + + await act(async () => { + importAction?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onImportAsKnowledge).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/Users/demo/brief.txt", + name: "brief.txt", + isDir: false, + mimeType: "text/plain", + source: "file_manager", + }), + ); + }); + + it("普通点击文本文件应先加入对话,避免直接打开系统应用", async () => { + const onAddPathReferences = vi.fn(); + const onImportAsKnowledge = vi.fn(); + const { container } = await renderFileManagerSidebar({ + onAddPathReferences, + onImportAsKnowledge, + }); + + const fileEntry = Array.from( + container.querySelectorAll('[data-testid="file-manager-entry"]'), + ).find((entry) => entry.textContent?.includes("brief.txt")); + expect(fileEntry?.textContent).toContain("加入对话"); + expect(fileEntry?.textContent).toContain("设为资料"); + + await act(async () => { + fileEntry?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onAddPathReferences).toHaveBeenCalledWith([ + expect.objectContaining({ + path: "/Users/demo/brief.txt", + name: "brief.txt", + isDir: false, + source: "file_manager", + }), + ]); + expect(openPathWithDefaultApp).not.toHaveBeenCalled(); + }); + + it("文件列表里的设为资料按钮应直接进入资料整理", async () => { + const onImportAsKnowledge = vi.fn(); + const { container } = await renderFileManagerSidebar({ + onImportAsKnowledge, + }); + + const fileEntry = Array.from( + container.querySelectorAll('[data-testid="file-manager-entry"]'), + ).find((entry) => entry.textContent?.includes("brief.txt")); + const importButton = Array.from( + fileEntry?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.includes("设为资料")); + + await act(async () => { + importButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onImportAsKnowledge).toHaveBeenCalledWith( + expect.objectContaining({ + path: "/Users/demo/brief.txt", + name: "brief.txt", + isDir: false, + mimeType: "text/plain", + source: "file_manager", + }), + ); + }); + + it("右键非文本文件不应误导为可直接整理资料", async () => { + const onImportAsKnowledge = vi.fn(); + const { container } = await renderFileManagerSidebar({ + onImportAsKnowledge, + }); + + const fileEntry = Array.from( + container.querySelectorAll('[data-testid="file-manager-entry"]'), + ).find((entry) => entry.textContent?.includes("contract.pdf")); + + await act(async () => { + fileEntry?.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 48, + clientY: 56, + }), + ); + await Promise.resolve(); + }); + + const menu = document.querySelector( + '[data-testid="file-manager-context-menu"]', + ); + expect(menu?.textContent).toContain("暂不支持整理为资料"); + expect(menu?.textContent).not.toContain("设为项目资料"); + + const importAction = Array.from( + menu?.querySelectorAll("button") ?? [], + ).find((button) => button.textContent?.includes("暂不支持整理为资料")); + expect(importAction).toBeTruthy(); + expect((importAction as HTMLButtonElement | undefined)?.disabled).toBe( + true, + ); + expect(importAction?.getAttribute("title")).toContain("转成 Markdown"); + + await act(async () => { + importAction?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onImportAsKnowledge).not.toHaveBeenCalled(); + }); + it("应用程序位置应渲染原生应用图标,侧栏保持窄轨", async () => { const { container } = await renderFileManagerSidebar(); const sidebar = container.querySelector( diff --git a/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx b/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx index 642e6f061..dc21559ce 100644 --- a/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx +++ b/src/components/agent/chat/components/FileManager/FileManagerSidebar.tsx @@ -33,6 +33,7 @@ import { type FileEntry, type FileManagerLocation, } from "@/lib/api/fileBrowser"; +import { getKnowledgeUnsupportedSourceMessage } from "@/features/knowledge/import/knowledgeSourceSupport"; import { openPathWithDefaultApp, revealPathInFinder, @@ -57,6 +58,7 @@ type ViewMode = "list" | "grid"; interface FileManagerSidebarProps { onClose: () => void; onAddPathReferences: (references: MessagePathReference[]) => void; + onImportAsKnowledge?: (reference: MessagePathReference) => void; } interface ContextMenuState { @@ -71,6 +73,14 @@ interface EntryGroup { entries: FileEntry[]; } +interface ContextMenuAction { + action: string; + label: string; + icon: LucideIcon; + disabled?: boolean; + title?: string; +} + function asPinnedLocation(value: unknown): FileManagerLocation | null { if (typeof value !== "object" || value === null || Array.isArray(value)) { return null; @@ -248,9 +258,41 @@ async function copyText(value: string, successMessage: string): Promise { } } +function buildContextMenuActions( + entry: FileEntry, + knowledgeImportEnabled: boolean, +): ContextMenuAction[] { + const actions: ContextMenuAction[] = [ + { action: "open", label: "打开", icon: ExternalLink }, + { action: "reveal", label: "在系统文件管理器中显示", icon: Folder }, + { action: "add", label: "添加到对话", icon: PlusCircle }, + ]; + + if (knowledgeImportEnabled && !entry.isDir) { + const unsupportedMessage = getKnowledgeUnsupportedSourceMessage(entry); + actions.push({ + action: "import-knowledge", + label: unsupportedMessage ? "暂不支持整理为资料" : "设为项目资料", + icon: FileText, + disabled: Boolean(unsupportedMessage), + title: unsupportedMessage || "整理后可在当前项目里复用。", + }); + } + + actions.push( + { action: "copy-path", label: "复制路径", icon: Copy }, + { action: "copy-name", label: "复制文件名", icon: FileText }, + { action: "pin", label: "固定到侧栏", icon: Pin }, + { action: "refresh", label: "刷新", icon: RefreshCw }, + ); + + return actions; +} + export const FileManagerSidebar: React.FC = ({ onClose, onAddPathReferences, + onImportAsKnowledge, }) => { const [locations, setLocations] = useState([]); const [pinnedLocations, setPinnedLocations] = useState( @@ -496,6 +538,35 @@ export const FileManagerSidebar: React.FC = ({ [onAddPathReferences], ); + const handleEntryPrimaryAction = useCallback( + (entry: FileEntry) => { + const isApplication = isApplicationEntry(entry, activeLocationKind); + if (entry.isDir || isApplication) { + handleOpenEntry(entry); + return; + } + + handleAddEntry(entry); + }, + [activeLocationKind, handleAddEntry, handleOpenEntry], + ); + + const handleImportEntryAsKnowledge = useCallback( + (entry: FileEntry) => { + const unsupportedMessage = getKnowledgeUnsupportedSourceMessage(entry); + if (unsupportedMessage) { + toast.info(unsupportedMessage); + return; + } + const reference = createReferenceFromEntry(entry); + if (!reference) { + return; + } + onImportAsKnowledge?.(reference); + }, + [onImportAsKnowledge], + ); + const handlePinEntry = useCallback((entry: FileEntry) => { if (!entry.isDir) { toast.info("只有文件夹可以固定到侧栏"); @@ -541,6 +612,9 @@ export const FileManagerSidebar: React.FC = ({ case "add": handleAddEntry(entry); break; + case "import-knowledge": + handleImportEntryAsKnowledge(entry); + break; case "pin": handlePinEntry(entry); break; @@ -549,7 +623,13 @@ export const FileManagerSidebar: React.FC = ({ break; } }, - [handleAddEntry, handleOpenEntry, handlePinEntry, loadActiveDirectory], + [ + handleAddEntry, + handleImportEntryAsKnowledge, + handleOpenEntry, + handlePinEntry, + loadActiveDirectory, + ], ); const handleDragStart = useCallback( @@ -583,17 +663,35 @@ export const FileManagerSidebar: React.FC = ({ ? "folder" : "file"; const hasNativeIcon = Boolean(entry.iconDataUrl); + const knowledgeUnsupportedMessage = + !entry.isDir && onImportAsKnowledge + ? getKnowledgeUnsupportedSourceMessage(entry) + : ""; + const canImportAsKnowledge = Boolean( + onImportAsKnowledge && !entry.isDir && !knowledgeUnsupportedMessage, + ); + const handleEntryKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + event.preventDefault(); + handleEntryPrimaryAction(entry); + }; + return ( - + {!entry.isDir && !isApplication && viewMode === "list" ? ( + + + {canImportAsKnowledge ? ( + + ) : null} + + ) : null} +
); }; @@ -704,9 +834,9 @@ export const FileManagerSidebar: React.FC = ({

- {activePath || "正在准备文件位置"} + {activePath ? "本地位置" : "正在准备文件位置"}

@@ -796,27 +926,28 @@ export const FileManagerSidebar: React.FC = ({ style={{ left: contextMenu.x, top: contextMenu.y }} onMouseDown={(event) => event.stopPropagation()} > - {[ - ["open", "打开", ExternalLink], - ["reveal", "在系统文件管理器中显示", Folder], - ["add", "添加到对话", PlusCircle], - ["copy-path", "复制路径", Copy], - ["copy-name", "复制文件名", FileText], - ["pin", "固定到侧栏", Pin], - ["refresh", "刷新", RefreshCw], - ].map(([action, label, Icon]) => { - const MenuIcon = Icon as LucideIcon; + {buildContextMenuActions( + contextMenu.entry, + Boolean(onImportAsKnowledge), + ).map(({ action, label, icon: MenuIcon, disabled, title }) => { return ( ); })} diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 465b14e37..3a75c88ac 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -149,7 +149,7 @@ function findButtonByText(text: string): HTMLButtonElement | null { } async function flushUntilTextAppears(text: string): Promise { - for (let index = 0; index < 20; index += 1) { + for (let index = 0; index < 80; index += 1) { if (document.body.textContent?.includes(text)) { return; } @@ -972,6 +972,62 @@ describe("HarnessStatusPanel", () => { modality_runtime_contracts: { snapshot_count: 2, snapshot_index: { + task_index: { + snapshot_count: 2, + thread_ids: ["thread-evidence-1", "thread-evidence-2"], + turn_ids: ["turn-evidence-1", "turn-evidence-2"], + content_ids: ["content-browser-1", "content-search-1"], + entry_keys: ["at_browser_agent_command", "at_search_command"], + modalities: ["browser", "web_research"], + skill_ids: ["browser_assist", "research"], + model_ids: ["gpt-5.2-browser", "gpt-5.2"], + executor_kinds: ["browser_action", "search_query"], + executor_binding_keys: ["lime_browser_mcp", "web_search"], + cost_states: ["estimated", "metered"], + limit_states: ["within_limit", "quota_low"], + estimated_cost_classes: ["low", "medium"], + limit_event_kinds: ["quota_low"], + quota_low_count: 1, + items: [ + { + artifact_path: + "runtime_timeline/browser-tool-1/mcp__lime-browser__navigate", + contract_key: "browser_control", + thread_id: "thread-evidence-1", + turn_id: "turn-evidence-1", + content_id: "content-browser-1", + entry_key: "at_browser_agent_command", + modality: "browser", + skill_id: "browser_assist", + model_id: "gpt-5.2-browser", + executor_kind: "browser_action", + executor_binding_key: "lime_browser_mcp", + cost_state: "estimated", + limit_state: "within_limit", + estimated_cost_class: "low", + limit_event_kind: "quota_low", + quota_low: true, + }, + { + artifact_path: "runtime_timeline/search-tool-1/search_query", + contract_key: "web_research", + thread_id: "thread-evidence-2", + turn_id: "turn-evidence-2", + content_id: "content-search-1", + entry_key: "at_search_command", + modality: "web_research", + skill_id: "research", + model_id: "gpt-5.2", + executor_kind: "search_query", + executor_binding_key: "web_search", + cost_state: "metered", + limit_state: "quota_low", + estimated_cost_class: "medium", + limit_event_kind: "quota_low", + quota_low: true, + }, + ], + }, browser_action_index: { action_count: 2, session_count: 1, @@ -1121,6 +1177,12 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("browser_snapshot"); expect(document.body.textContent).toContain("get_page_info"); expect(document.body.textContent).toContain("observation / screenshot"); + expect(document.body.textContent).toContain("多模态任务索引"); + expect(document.body.textContent).toContain("任务中心过滤列表"); + expect(document.body.textContent).toContain("thread-evidence-1"); + expect(document.body.textContent).toContain("content-browser-1"); + expect(document.body.textContent).toContain("lime_browser_mcp"); + expect(document.body.textContent).toContain("within_limit"); expect(document.body.textContent).toContain("LimeCore 策略缺口"); expect(document.body.textContent).toContain("model_catalog"); expect(document.body.textContent).toContain("provider_offer"); @@ -1816,7 +1878,7 @@ describe("HarnessStatusPanel", () => { ); expect(acceptedOption?.disabled).toBe(true); expect(reviewDialog?.textContent).toContain( - "权限确认已拒绝时不能保存“接受”", + "权限确认未解决时不能保存“接受”", ); expect(saveButton?.disabled).toBe(true); @@ -1858,7 +1920,9 @@ describe("HarnessStatusPanel", () => { notes: "拒绝状态来自真实权限确认。", }); expect(document.body.textContent).toContain("当前人工审核结论"); - expect(document.body.textContent).toContain("权限确认已拒绝,拒绝本次交付。"); + expect(document.body.textContent).toContain( + "权限确认已拒绝,拒绝本次交付。", + ); expect(document.body.textContent).toContain("Lime Maintainer"); expect(document.body.textContent).toContain("处理 approval-denied-dialog"); expect(mockToast.success).toHaveBeenCalledWith("已保存人工审核结果"); diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index e3191f66c..56a8d9292 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -122,6 +122,7 @@ import type { TeamRoleDefinition } from "../utils/teamDefinitions"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel"; import { HarnessVerificationSummarySection } from "./HarnessVerificationSummarySection"; +import { HarnessTaskIndexSection } from "./HarnessTaskIndexSection"; import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog"; interface HarnessEnvironmentSummary { @@ -3993,6 +3994,18 @@ export function HarnessStatusPanel({ })() : null} + {evidencePack.observability_summary + ?.modality_runtime_contracts?.snapshot_index + ?.task_index ? ( + + ) : null} + {evidencePack.observability_summary ?.modality_runtime_contracts?.snapshot_index ?.limecore_policy_index ? ( diff --git a/src/components/agent/chat/components/HarnessTaskIndexSection.test.tsx b/src/components/agent/chat/components/HarnessTaskIndexSection.test.tsx new file mode 100644 index 000000000..c2be96f4b --- /dev/null +++ b/src/components/agent/chat/components/HarnessTaskIndexSection.test.tsx @@ -0,0 +1,170 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentRuntimeEvidenceTaskIndex } from "@/lib/api/agentRuntime"; +import { HarnessTaskIndexSection } from "./HarnessTaskIndexSection"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +interface RenderResult { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: RenderResult[] = []; + +function createTaskIndex(): AgentRuntimeEvidenceTaskIndex { + return { + snapshot_count: 2, + thread_ids: ["thread-evidence-1", "thread-evidence-2"], + turn_ids: ["turn-evidence-1", "turn-evidence-2"], + content_ids: ["content-browser-1", "content-search-1"], + entry_keys: ["at_browser_agent_command", "at_search_command"], + modalities: ["browser", "web_research"], + skill_ids: ["browser_assist", "research"], + model_ids: ["gpt-5.2-browser", "gpt-5.2"], + executor_kinds: ["browser_action", "search_query"], + executor_binding_keys: ["lime_browser_mcp", "web_search"], + cost_states: ["estimated", "metered"], + limit_states: ["within_limit", "quota_low"], + estimated_cost_classes: ["low", "medium"], + limit_event_kinds: ["quota_low"], + quota_low_count: 1, + items: [ + { + artifact_path: + "runtime_timeline/browser-tool-1/mcp__lime-browser__navigate", + contract_key: "browser_control", + thread_id: "thread-evidence-1", + turn_id: "turn-evidence-1", + content_id: "content-browser-1", + entry_key: "at_browser_agent_command", + modality: "browser", + skill_id: "browser_assist", + model_id: "gpt-5.2-browser", + executor_kind: "browser_action", + executor_binding_key: "lime_browser_mcp", + cost_state: "estimated", + limit_state: "within_limit", + estimated_cost_class: "low", + limit_event_kind: "within_limit", + quota_low: false, + }, + { + artifact_path: "runtime_timeline/search-tool-1/search_query", + contract_key: "web_research", + thread_id: "thread-evidence-2", + turn_id: "turn-evidence-2", + content_id: "content-search-1", + entry_key: "at_search_command", + modality: "web_research", + skill_id: "research", + model_id: "gpt-5.2", + executor_kind: "search_query", + executor_binding_key: "web_search", + cost_state: "metered", + limit_state: "quota_low", + estimated_cost_class: "medium", + limit_event_kind: "quota_low", + quota_low: true, + }, + ], + }; +} + +function renderSection(index: AgentRuntimeEvidenceTaskIndex): RenderResult { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const rendered = { container, root }; + mountedRoots.push(rendered); + return rendered; +} + +function setInputValue(input: HTMLSelectElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLSelectElement.prototype, + "value", + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +function findSelectByLabel(labelText: string): HTMLSelectElement | null { + return ( + Array.from(document.body.querySelectorAll("label")) + .find((label) => label.textContent?.includes(labelText)) + ?.querySelector("select") ?? null + ); +} + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("HarnessTaskIndexSection", () => { + it("应展示 taskIndex 摘要与任务中心过滤列表", () => { + renderSection(createTaskIndex()); + + expect(document.body.textContent).toContain("多模态任务索引"); + expect(document.body.textContent).toContain("任务中心过滤列表"); + expect(document.body.textContent).toContain("2 / 2"); + expect(document.body.textContent).toContain("thread-evidence-1"); + expect(document.body.textContent).toContain("content-browser-1"); + expect(document.body.textContent).toContain("lime_browser_mcp"); + expect(document.body.textContent).toContain( + "runtime_timeline/browser-tool-1", + ); + }); + + it("应按入口过滤 taskIndex rows 并支持清空过滤", async () => { + renderSection(createTaskIndex()); + + const entryFilterSelect = findSelectByLabel("入口"); + expect(entryFilterSelect).not.toBeNull(); + + await act(async () => { + if (entryFilterSelect) { + setInputValue(entryFilterSelect, "at_search_command"); + } + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("1 / 2"); + expect(document.body.textContent).toContain("content-search-1"); + expect(document.body.textContent).not.toContain( + "runtime_timeline/browser-tool-1", + ); + expect(document.body.textContent).toContain("清空过滤"); + + const clearButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.trim() === "清空过滤"); + + await act(async () => { + clearButton?.click(); + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("2 / 2"); + expect(document.body.textContent).toContain("content-browser-1"); + expect(document.body.textContent).toContain("content-search-1"); + }); +}); diff --git a/src/components/agent/chat/components/HarnessTaskIndexSection.tsx b/src/components/agent/chat/components/HarnessTaskIndexSection.tsx new file mode 100644 index 000000000..44817c28e --- /dev/null +++ b/src/components/agent/chat/components/HarnessTaskIndexSection.tsx @@ -0,0 +1,339 @@ +import { useCallback, useMemo, useState } from "react"; +import { ListChecks } from "lucide-react"; +import type { AgentRuntimeEvidenceTaskIndex } from "@/lib/api/agentRuntime"; +import { + buildModalityTaskIndexFacets, + buildModalityTaskIndexRows, + filterModalityTaskIndexRows, + type ModalityTaskIndexQueryFilters, + type ModalityTaskIndexRow, +} from "@/lib/agentRuntime/modalityTaskIndexPresentation"; +import { Badge } from "@/components/ui/badge"; + +const TASK_INDEX_FILTER_ALL_VALUE = "__all__"; + +function TaskIndexStatCard({ + title, + value, + hint, +}: { + title: string; + value: string; + hint: string; +}) { + return ( +
+
{title}
+
+ {value} +
+
{hint}
+
+ ); +} + +function TaskIndexItemCard({ item }: { item: ModalityTaskIndexRow }) { + return ( +
+
+ + {item.title} + + {item.modality ? ( + {item.modality} + ) : null} + {item.executorKind ? ( + {item.executorKind} + ) : null} + {item.contractKey ? ( + {item.contractKey} + ) : null} + {item.costState ? ( + {item.costState} + ) : null} + {item.limitState ? ( + + {item.limitState} + + ) : null} +
+ +
+
+ {item.threadId ? ( + + thread: + + {item.threadId} + + + ) : null} + {item.turnId ? ( + + turn: + + {item.turnId} + + + ) : null} + {item.contentId ? ( + + content: + + {item.contentId} + + + ) : null} +
+
+ {item.skillId ? ( + + skill: + + {item.skillId} + + + ) : null} + {item.modelId ? ( + + model: + + {item.modelId} + + + ) : null} + {item.executorBindingKey ? ( + + binding: + + {item.executorBindingKey} + + + ) : null} + {item.entryKey ? ( + + entry: + + {item.entryKey} + + + ) : null} +
+ {item.estimatedCostClass || item.limitEventKind ? ( +
+ cost/limit: + + {[item.estimatedCostClass, item.limitEventKind] + .filter(Boolean) + .join(" / ")} + +
+ ) : null} + {item.artifactPath ? ( +
+ artifact: + + {item.artifactPath} + +
+ ) : null} +
+
+ ); +} + +function TaskIndexFilterSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value?: string; + options: string[]; + onChange: (value?: string) => void; +}) { + return ( + + ); +} + +export function HarnessTaskIndexSection({ + index, +}: { + index: AgentRuntimeEvidenceTaskIndex; +}) { + const facets = buildModalityTaskIndexFacets(index); + const rows = useMemo(() => buildModalityTaskIndexRows(index), [index]); + const [filters, setFilters] = useState({}); + const filteredRows = useMemo( + () => filterModalityTaskIndexRows(rows, filters), + [filters, rows], + ); + const visibleRows = filteredRows.slice(0, 8); + const hasActiveFilters = Object.values(filters).some(Boolean); + const updateFilter = useCallback( + ( + key: Key, + value?: ModalityTaskIndexQueryFilters[Key], + ) => { + setFilters((current) => { + const next = { ...current }; + if (value) { + next[key] = value; + } else { + delete next[key]; + } + return next; + }); + }, + [], + ); + + if (index.snapshot_count <= 0 && index.items.length === 0) { + return null; + } + + return ( +
+
+ + 多模态任务索引 +
+

+ 来自 modalityRuntimeContracts.snapshotIndex.taskIndex;用于按 thread / + turn / content / entry / executor / cost / limit 诊断非媒体任务, + 不另建任务事实源。 +

+ +
+ + + + +
+ + {rows.length > 0 ? ( +
+
+
+
+
+ 任务中心过滤列表 +
+

+ 直接消费同一 taskIndex rows;用于把非媒体任务按身份、入口、 + 执行器和成本/限额过滤,不另建索引。 +

+
+ + {filteredRows.length} / {rows.length} + +
+
+ updateFilter("entryKey", value)} + /> + updateFilter("contentId", value)} + /> + updateFilter("executorKind", value)} + /> + updateFilter("costState", value)} + /> + updateFilter("limitState", value)} + /> +
+ {hasActiveFilters ? ( + + ) : null} +
+ + {visibleRows.length > 0 ? ( + visibleRows.map((item, indexInList) => ( + + )) + ) : ( +
+ 当前过滤条件下没有匹配的任务索引行。 +
+ )} + {filteredRows.length > visibleRows.length ? ( +

+ 仅展示前 {visibleRows.length} 条;请继续缩小过滤条件查看具体任务。 +

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/components/agent/chat/components/Inputbar/components/BuiltinCommandBadge.tsx b/src/components/agent/chat/components/Inputbar/components/BuiltinCommandBadge.tsx index e207cd602..f69318429 100644 --- a/src/components/agent/chat/components/Inputbar/components/BuiltinCommandBadge.tsx +++ b/src/components/agent/chat/components/Inputbar/components/BuiltinCommandBadge.tsx @@ -11,7 +11,10 @@ export const BuiltinCommandBadge: React.FC = ({ command, onClear, }) => ( -
+
{command.label} ) : null} + {canSaveMessageAsKnowledge ? ( + + ) : null} ) : null} diff --git a/src/components/agent/chat/components/RuntimeReviewDecisionDialog.tsx b/src/components/agent/chat/components/RuntimeReviewDecisionDialog.tsx index 3969c70c8..a63ac98eb 100644 --- a/src/components/agent/chat/components/RuntimeReviewDecisionDialog.tsx +++ b/src/components/agent/chat/components/RuntimeReviewDecisionDialog.tsx @@ -108,6 +108,21 @@ function formatPermissionConfirmationStatusLabel(status?: string): string { } } +function blocksAcceptedReviewDecision( + permissionStatus?: string, + confirmationStatus?: string, +): boolean { + const normalizedPermissionStatus = permissionStatus?.trim(); + const normalizedConfirmationStatus = confirmationStatus?.trim(); + if (normalizedConfirmationStatus === "denied") { + return true; + } + return ( + normalizedPermissionStatus === "requires_confirmation" && + normalizedConfirmationStatus !== "resolved" + ); +} + function createFormState( template: AgentRuntimeReviewDecisionTemplate, ): ReviewDecisionFormState { @@ -170,20 +185,24 @@ export function RuntimeReviewDecisionDialog({ : DEFAULT_RISK_LEVEL_OPTIONS; const permissionConfirmationStatus = template?.permission_confirmation_status?.trim(); + const permissionStatus = template?.permission_status?.trim(); const permissionConfirmationSummary = template?.permission_confirmation_summary || template?.permission_confirmation_request_id || "未导出权限确认摘要"; - const permissionConfirmationDenied = - permissionConfirmationStatus === "denied"; - const acceptanceBlockedByDeniedPermission = - permissionConfirmationDenied && formState?.decision_status === "accepted"; + const permissionConfirmationBlocksAccepted = blocksAcceptedReviewDecision( + permissionStatus, + permissionConfirmationStatus, + ); + const acceptanceBlockedByPermissionConfirmation = + permissionConfirmationBlocksAccepted && + formState?.decision_status === "accepted"; const handleSave = async () => { if (!template || !formState) { return; } - if (acceptanceBlockedByDeniedPermission) { + if (acceptanceBlockedByPermissionConfirmation) { return; } @@ -234,7 +253,7 @@ export function RuntimeReviewDecisionDialog({ {permissionConfirmationStatus ? (
权限确认 ) : null} - {permissionConfirmationDenied ? ( + {permissionConfirmationBlocksAccepted ? (

当前 review decision 不能作为成功交付证据,请先处理真实权限确认。

@@ -299,16 +318,17 @@ export function RuntimeReviewDecisionDialog({ key={status} value={status} disabled={ - permissionConfirmationDenied && status === "accepted" + permissionConfirmationBlocksAccepted && + status === "accepted" } > {formatStatusLabel(status)} ))} - {permissionConfirmationDenied ? ( + {permissionConfirmationBlocksAccepted ? (

- 权限确认已拒绝时不能保存“接受”,请选择拒绝、延后或需要更多证据。 + 权限确认未解决时不能保存“接受”,请选择拒绝、延后或需要更多证据。

) : null}
@@ -548,7 +568,7 @@ export function RuntimeReviewDecisionDialog({ !template || !formState || saving || - acceptanceBlockedByDeniedPermission + acceptanceBlockedByPermissionConfirmation } > {saving ? "保存中..." : "保存审核结果"} diff --git a/src/components/agent/chat/home/HomeStarterChips.tsx b/src/components/agent/chat/home/HomeStarterChips.tsx index 36260a851..48a1153ac 100644 --- a/src/components/agent/chat/home/HomeStarterChips.tsx +++ b/src/components/agent/chat/home/HomeStarterChips.tsx @@ -1,6 +1,6 @@ import type { HomeStarterChip } from "./homeSurfaceTypes"; import styled from "styled-components"; -import { Lightbulb, Settings } from "lucide-react"; +import { BookOpen, Lightbulb, Settings } from "lucide-react"; const StarterRow = styled.div` display: flex; @@ -87,6 +87,13 @@ function renderStarterIcon(chip: HomeStarterChip) { ); } + if (chip.launchKind === "open_knowledge_hub" || token === "knowledge") { + return ( + + + + ); + } return null; } diff --git a/src/components/agent/chat/home/buildHomeSkillSurface.test.ts b/src/components/agent/chat/home/buildHomeSkillSurface.test.ts index 0157a07ea..14af43897 100644 --- a/src/components/agent/chat/home/buildHomeSkillSurface.test.ts +++ b/src/components/agent/chat/home/buildHomeSkillSurface.test.ts @@ -79,6 +79,7 @@ describe("buildHomeSkillSurface", () => { expect(labels).toEqual([ "引导帮助", "写作", + "添加资料", "PPT", "调研报告", "需求分析", diff --git a/src/components/agent/chat/home/homeSurfaceCopy.ts b/src/components/agent/chat/home/homeSurfaceCopy.ts index 23616cddf..7c8843537 100644 --- a/src/components/agent/chat/home/homeSurfaceCopy.ts +++ b/src/components/agent/chat/home/homeSurfaceCopy.ts @@ -50,6 +50,14 @@ export const HOME_STARTER_CHIPS: HomeStarterChip[] = [ primary: true, testId: "entry-recommended-social-post-starter", }, + { + id: "starter-knowledge-import", + label: "添加资料", + launchKind: "open_knowledge_hub", + category: "other", + iconToken: "knowledge", + testId: "entry-home-knowledge-import", + }, { id: "starter-ppt", label: "PPT", @@ -133,6 +141,14 @@ export const HOME_INPUT_SUGGESTIONS: HomeInputSuggestion[] = [ order: 5, testId: "home-input-suggestion-meeting-notes", }, + { + id: "suggestion-knowledge-import", + label: "帮我整理项目资料", + prompt: + "帮我把接下来这份资料整理成项目资料,保留确定事实,标出待确认信息,并告诉我后续怎么在生成内容时使用。", + order: 8, + testId: "home-input-suggestion-knowledge-import", + }, { id: "suggestion-research-report", label: "帮我写一份调研报告", @@ -195,6 +211,15 @@ export const HOME_GUIDE_CARDS: HomeGuideCard[] = [ groupKey: "guide_help", testId: "home-guide-install-skill", }, + { + id: "guide-knowledge", + title: "项目资料怎么添加和使用?", + summary: "从文件、输入框资料图标或对话结果沉淀资料。", + prompt: + "请告诉我 Lime 的项目资料怎么添加、确认和使用。我想知道如何从文件管理器、输入框资料图标和对话结果里完成资料沉淀与后续生成。", + groupKey: "guide_help", + testId: "home-guide-knowledge", + }, { id: "guide-voice-input", title: "语音输入怎么设置?", diff --git a/src/components/agent/chat/home/homeSurfaceTypes.ts b/src/components/agent/chat/home/homeSurfaceTypes.ts index 15cb42788..30cbb8298 100644 --- a/src/components/agent/chat/home/homeSurfaceTypes.ts +++ b/src/components/agent/chat/home/homeSurfaceTypes.ts @@ -21,6 +21,7 @@ export type HomeSkillLaunchKind = | "scene_app" | "skill_catalog_scene" | "prefill_prompt" + | "open_knowledge_hub" | "toggle_guide" | "open_drawer" | "open_manager" diff --git a/src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts b/src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts new file mode 100644 index 000000000..7e0802af5 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamArtifactActionController.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamActionRequiredPreApplyPlan, + buildAgentStreamArtifactSnapshotPreApplyPlan, +} from "./agentStreamArtifactActionController"; + +describe("agentStreamArtifactActionController", () => { + it("应构造 artifact snapshot 前置计划,并继续标记 meaningful completion", () => { + expect( + buildAgentStreamArtifactSnapshotPreApplyPlan({ + artifact: { + artifactId: "artifact-a", + filePath: "docs/out.md", + content: "result", + }, + }), + ).toEqual({ + artifactId: "artifact-a", + hasFilePath: true, + hasInlineContent: true, + shouldActivateStream: true, + shouldClearOptimisticItem: true, + shouldMarkMeaningfulCompletionSignal: true, + }); + }); + + it("无文件路径或正文时仍保持 artifact 完成信号语义", () => { + expect( + buildAgentStreamArtifactSnapshotPreApplyPlan({ + artifact: { + artifactId: "artifact-a", + }, + }), + ).toMatchObject({ + artifactId: "artifact-a", + hasFilePath: false, + hasInlineContent: false, + shouldMarkMeaningfulCompletionSignal: true, + }); + }); + + it("应构造 action required 前置计划", () => { + expect( + buildAgentStreamActionRequiredPreApplyPlan({ + type: "action_required", + request_id: "request-a", + action_type: "ask_user", + prompt: "需要补充信息", + }), + ).toEqual({ + actionType: "ask_user", + requestId: "request-a", + shouldActivateStream: true, + shouldClearOptimisticItem: true, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamArtifactActionController.ts b/src/components/agent/chat/hooks/agentStreamArtifactActionController.ts new file mode 100644 index 000000000..7b9b60308 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamArtifactActionController.ts @@ -0,0 +1,44 @@ +import type { + AgentArtifactSignal, + AgentEventActionRequired, +} from "@/lib/api/agentProtocol"; + +export interface AgentStreamArtifactSnapshotPreApplyPlan { + artifactId: string | null; + hasFilePath: boolean; + hasInlineContent: boolean; + shouldActivateStream: boolean; + shouldClearOptimisticItem: boolean; + shouldMarkMeaningfulCompletionSignal: boolean; +} + +export interface AgentStreamActionRequiredPreApplyPlan { + actionType: AgentEventActionRequired["action_type"]; + requestId: string; + shouldActivateStream: boolean; + shouldClearOptimisticItem: boolean; +} + +export function buildAgentStreamArtifactSnapshotPreApplyPlan(params: { + artifact: AgentArtifactSignal; +}): AgentStreamArtifactSnapshotPreApplyPlan { + return { + artifactId: params.artifact.artifactId || null, + hasFilePath: Boolean(params.artifact.filePath?.trim()), + hasInlineContent: Boolean(params.artifact.content?.trim()), + shouldActivateStream: true, + shouldClearOptimisticItem: true, + shouldMarkMeaningfulCompletionSignal: true, + }; +} + +export function buildAgentStreamActionRequiredPreApplyPlan( + event: AgentEventActionRequired, +): AgentStreamActionRequiredPreApplyPlan { + return { + actionType: event.action_type, + requestId: event.request_id, + shouldActivateStream: true, + shouldClearOptimisticItem: true, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamCompletionController.test.ts b/src/components/agent/chat/hooks/agentStreamCompletionController.test.ts new file mode 100644 index 000000000..6e9e347d3 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamCompletionController.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "../types"; +import { + AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + buildAgentStreamCompletedAssistantMessagePatch, + buildAgentStreamEmptyFinalErrorPlan, + buildAgentStreamFinalDonePlan, + buildAgentStreamMissingFinalReplyFailurePlan, + buildAgentStreamMissingFinalReplyFailureSideEffectPlan, + isAgentStreamEmptyFinalReplyError, + reconcileAgentStreamFinalContentParts, + resolveAgentStreamGracefulCompletionContent, + shouldFailAgentStreamMissingFinalReply, +} from "./agentStreamCompletionController"; + +describe("agentStreamCompletionController", () => { + it("应识别空最终回复错误", () => { + expect( + isAgentStreamEmptyFinalReplyError( + `runtime error: ${AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE}`, + ), + ).toBe(true); + expect(isAgentStreamEmptyFinalReplyError("普通错误")).toBe(false); + }); + + it("应判断空最终回复是否需要失败", () => { + expect( + shouldFailAgentStreamMissingFinalReply({ + accumulatedContent: "", + }), + ).toBe(true); + expect( + shouldFailAgentStreamMissingFinalReply({ + accumulatedContent: "", + }), + ).toBe(true); + expect( + shouldFailAgentStreamMissingFinalReply({ + accumulatedContent: "", + hasMeaningfulCompletionSignal: true, + }), + ).toBe(false); + expect( + shouldFailAgentStreamMissingFinalReply({ + accumulatedContent: "最终答复", + }), + ).toBe(false); + }); + + it("应解析可降级完成内容并剥离协议残留", () => { + expect( + resolveAgentStreamGracefulCompletionContent({ + accumulatedContent: " 最终答复 ", + }), + ).toBe("最终答复"); + expect( + resolveAgentStreamGracefulCompletionContent({ + accumulatedContent: "", + fallbackContent: "兜底内容", + }), + ).toBe("兜底内容"); + }); + + it("应在最终文本变化时重建 text part 并保留过程 part", () => { + const parts = [ + { type: "text", text: "原始" }, + { type: "tool_use", toolCall: { id: "tool-a" } }, + ] as unknown as Message["contentParts"]; + + expect( + reconcileAgentStreamFinalContentParts({ + parts, + finalContent: "最终", + rawContent: "原始", + surfaceThinkingDeltas: true, + }), + ).toEqual([ + { type: "tool_use", toolCall: { id: "tool-a" } }, + { type: "text", text: "最终" }, + ]); + }); + + it("应在不展示 thinking 时过滤 thinking part", () => { + const parts = [ + { type: "thinking", text: "推理" }, + { type: "text", text: "最终" }, + ] satisfies Message["contentParts"]; + + expect( + reconcileAgentStreamFinalContentParts({ + parts, + finalContent: "最终", + rawContent: "最终", + surfaceThinkingDeltas: false, + }), + ).toEqual([{ type: "text", text: "最终" }]); + }); + + it("应构造完成态 assistant 消息 patch 并带回 usage", () => { + const usage = { input_tokens: 1, output_tokens: 2 }; + + expect( + buildAgentStreamCompletedAssistantMessagePatch({ + parts: [{ type: "text", text: "原始" }], + finalContent: "最终", + rawContent: "原始", + surfaceThinkingDeltas: true, + usage, + }), + ).toEqual({ + isThinking: false, + content: "最终", + contentParts: [{ type: "text", text: "最终" }], + runtimeStatus: undefined, + usage, + }); + }); + + it("应为 final_done 构造完成副作用计划", () => { + expect( + buildAgentStreamFinalDonePlan({ + accumulatedContent: + '{"output":"saved"}\n\n已保存。', + queuedTurnId: "queued-1", + toolCallCount: 2, + }), + ).toEqual({ + type: "complete", + finalContent: "已保存。", + queuedTurnIds: ["queued-1"], + requestLogPayload: { + eventType: "chat_request_complete", + status: "success", + description: "请求完成,工具调用 2 次", + }, + }); + }); + + it("应为缺少最终回复的 final_done 构造失败计划并保留 usage", () => { + const usage = { input_tokens: 5, output_tokens: 0 }; + + expect( + buildAgentStreamFinalDonePlan({ + accumulatedContent: "", + hasMeaningfulCompletionSignal: false, + queuedTurnId: "queued-missing", + toolCallCount: 0, + usage, + }), + ).toEqual({ + type: "missing_final_reply_failure", + errorMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + queuedTurnIds: ["queued-missing"], + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + }, + toastMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + usage, + }); + }); + + it("应构造缺少最终回复失败副作用计划", () => { + expect( + buildAgentStreamMissingFinalReplyFailurePlan({ + errorMessage: "模型未输出最终答复:工具已完成", + queuedTurnId: "queued-1", + }), + ).toEqual({ + type: "missing_final_reply_failure", + errorMessage: "模型未输出最终答复:工具已完成", + queuedTurnIds: ["queued-1"], + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: "模型未输出最终答复:工具已完成", + }, + toastMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + }); + }); + + it("应构造缺少最终回复失败的执行层副作用计划", () => { + const usage = { input_tokens: 10, output_tokens: 0 }; + const failurePlan = buildAgentStreamMissingFinalReplyFailurePlan({ + errorMessage: "模型未输出最终答复:工具已完成", + queuedTurnId: "queued-1", + usage, + }); + + expect( + buildAgentStreamMissingFinalReplyFailureSideEffectPlan(failurePlan), + ).toEqual({ + errorMessage: "模型未输出最终答复:工具已完成", + observerErrorMessage: "模型未输出最终答复:工具已完成", + queuedTurnIds: ["queued-1"], + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: "模型未输出最终答复:工具已完成", + }, + shouldClearActiveStream: true, + shouldClearPendingTextRenderTimer: true, + shouldDisposeListener: true, + shouldMarkFailedTimeline: true, + toastMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + usage, + }); + }); + + it("应为空 final error 按产物信号决定失败或软完成", () => { + expect( + buildAgentStreamEmptyFinalErrorPlan({ + errorMessage: "模型未输出最终答复:工具已完成", + accumulatedContent: "", + hasMeaningfulCompletionSignal: false, + }), + ).toEqual({ + type: "missing_final_reply_failure", + errorMessage: "模型未输出最终答复:工具已完成", + queuedTurnIds: [], + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: "模型未输出最终答复:工具已完成", + }, + toastMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + }); + + expect( + buildAgentStreamEmptyFinalErrorPlan({ + errorMessage: "模型未输出最终答复:工具已完成", + accumulatedContent: "", + hasMeaningfulCompletionSignal: true, + queuedTurnId: "queued-2", + }), + ).toEqual({ + type: "complete", + finalContent: "本轮执行已完成,详细过程与产物已保留在当前对话中。", + queuedTurnIds: ["queued-2"], + requestLogPayload: { + eventType: "chat_request_complete", + status: "success", + description: "请求完成,模型未补充最终总结,已降级保留当前过程结果", + }, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamCompletionController.ts b/src/components/agent/chat/hooks/agentStreamCompletionController.ts new file mode 100644 index 000000000..9b8073615 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamCompletionController.ts @@ -0,0 +1,270 @@ +import type { Message } from "../types"; +import { + containsAssistantProtocolResidue, + stripAssistantProtocolResidue, +} from "../utils/protocolResidue"; + +export const AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_HINT = + "模型未输出最终答复"; +export const AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE = + "模型未输出最终答复,请重试"; +export const AGENT_STREAM_EMPTY_FINAL_REPLY_FALLBACK_CONTENT = + "本轮执行已完成,详细过程与产物已保留在当前对话中。"; + +interface AgentStreamCompletionRequestLogPayload { + eventType: "chat_request_complete"; + status: "success"; + description: string; +} + +interface AgentStreamCompletionErrorRequestLogPayload { + eventType: "chat_request_error"; + status: "error"; + error: string; +} + +export interface AgentStreamMissingFinalReplyPlan { + type: "missing_final_reply_failure"; + errorMessage: string; + queuedTurnIds: string[]; + requestLogPayload: AgentStreamCompletionErrorRequestLogPayload; + toastMessage: string; + usage?: Message["usage"]; +} + +export interface AgentStreamMissingFinalReplyFailureSideEffectPlan { + errorMessage: string; + observerErrorMessage: string; + queuedTurnIds: string[]; + requestLogPayload: AgentStreamCompletionErrorRequestLogPayload; + shouldClearActiveStream: boolean; + shouldClearPendingTextRenderTimer: boolean; + shouldDisposeListener: boolean; + shouldMarkFailedTimeline: boolean; + toastMessage: string; + usage?: Message["usage"]; +} + +interface AgentStreamCompletionSuccessPlan { + type: "complete"; + finalContent: string; + queuedTurnIds: string[]; + requestLogPayload: AgentStreamCompletionRequestLogPayload; +} + +export type AgentStreamFinalDonePlan = + | AgentStreamMissingFinalReplyPlan + | AgentStreamCompletionSuccessPlan; + +export type AgentStreamEmptyFinalErrorPlan = + | AgentStreamMissingFinalReplyPlan + | AgentStreamCompletionSuccessPlan; + +const resolveQueuedTurnIds = (queuedTurnId?: string | null): string[] => + queuedTurnId ? [queuedTurnId] : []; + +export function buildAgentStreamMissingFinalReplyFailurePlan(params: { + errorMessage: string; + queuedTurnId?: string | null; + usage?: Message["usage"]; +}): AgentStreamMissingFinalReplyPlan { + return { + type: "missing_final_reply_failure", + errorMessage: params.errorMessage, + queuedTurnIds: resolveQueuedTurnIds(params.queuedTurnId), + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: params.errorMessage, + }, + toastMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + ...(params.usage !== undefined ? { usage: params.usage } : {}), + }; +} + +export function buildAgentStreamMissingFinalReplyFailureSideEffectPlan( + failurePlan: AgentStreamMissingFinalReplyPlan, +): AgentStreamMissingFinalReplyFailureSideEffectPlan { + return { + errorMessage: failurePlan.errorMessage, + observerErrorMessage: failurePlan.errorMessage, + queuedTurnIds: failurePlan.queuedTurnIds, + requestLogPayload: failurePlan.requestLogPayload, + shouldClearActiveStream: true, + shouldClearPendingTextRenderTimer: true, + shouldDisposeListener: true, + shouldMarkFailedTimeline: true, + toastMessage: failurePlan.toastMessage, + ...(failurePlan.usage !== undefined ? { usage: failurePlan.usage } : {}), + }; +} + +export function isAgentStreamEmptyFinalReplyError(message: string): boolean { + return message.includes(AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_HINT); +} + +export function shouldFailAgentStreamMissingFinalReply(params: { + accumulatedContent: string; + hasMeaningfulCompletionSignal?: boolean; +}): boolean { + if (params.hasMeaningfulCompletionSignal) { + return false; + } + + const rawFinalContent = params.accumulatedContent.trim(); + const cleanedFinalContent = stripAssistantProtocolResidue( + params.accumulatedContent, + ); + + return ( + !cleanedFinalContent && + (containsAssistantProtocolResidue(params.accumulatedContent) || + !rawFinalContent) + ); +} + +export function resolveAgentStreamGracefulCompletionContent(params: { + accumulatedContent: string; + fallbackContent?: string; +}): string { + const rawFinalContent = params.accumulatedContent.trim(); + const cleanedFinalContent = stripAssistantProtocolResidue( + params.accumulatedContent, + ); + + return ( + cleanedFinalContent || + (!containsAssistantProtocolResidue(params.accumulatedContent) + ? rawFinalContent + : "") || + params.fallbackContent || + AGENT_STREAM_EMPTY_FINAL_REPLY_FALLBACK_CONTENT + ); +} + +export function reconcileAgentStreamFinalContentParts(params: { + parts: Message["contentParts"]; + finalContent: string; + rawContent: string; + surfaceThinkingDeltas: boolean; +}): Message["contentParts"] { + if (!params.parts?.length) { + return params.parts; + } + + const visibleParts = params.surfaceThinkingDeltas + ? params.parts + : params.parts.filter((part) => part.type !== "thinking"); + if (visibleParts.length === 0) { + return undefined; + } + + const textContent = visibleParts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + const finalTextChanged = + params.finalContent !== params.rawContent || + (textContent.length > 0 && textContent !== params.finalContent); + + if (!finalTextChanged) { + return visibleParts; + } + + const processParts = visibleParts.filter((part) => part.type !== "text"); + if (processParts.length === 0) { + return params.finalContent + ? [{ type: "text", text: params.finalContent }] + : undefined; + } + + return params.finalContent + ? [...processParts, { type: "text", text: params.finalContent }] + : processParts; +} + +export function buildAgentStreamCompletedAssistantMessagePatch(params: { + finalContent: string; + parts: Message["contentParts"]; + rawContent: string; + surfaceThinkingDeltas: boolean; + usage?: Message["usage"]; +}): Pick< + Message, + "content" | "contentParts" | "isThinking" | "runtimeStatus" +> & + Partial> { + return { + isThinking: false, + content: params.finalContent, + contentParts: reconcileAgentStreamFinalContentParts({ + parts: params.parts, + finalContent: params.finalContent, + rawContent: params.rawContent, + surfaceThinkingDeltas: params.surfaceThinkingDeltas, + }), + runtimeStatus: undefined, + ...(params.usage !== undefined ? { usage: params.usage } : {}), + }; +} + +export function buildAgentStreamFinalDonePlan(params: { + accumulatedContent: string; + hasMeaningfulCompletionSignal?: boolean; + queuedTurnId?: string | null; + toolCallCount: number; + usage?: Message["usage"]; +}): AgentStreamFinalDonePlan { + if ( + shouldFailAgentStreamMissingFinalReply({ + accumulatedContent: params.accumulatedContent, + hasMeaningfulCompletionSignal: params.hasMeaningfulCompletionSignal, + }) + ) { + return buildAgentStreamMissingFinalReplyFailurePlan({ + errorMessage: AGENT_STREAM_EMPTY_FINAL_REPLY_ERROR_MESSAGE, + queuedTurnId: params.queuedTurnId, + usage: params.usage, + }); + } + + return { + type: "complete", + finalContent: resolveAgentStreamGracefulCompletionContent({ + accumulatedContent: params.accumulatedContent, + }), + queuedTurnIds: resolveQueuedTurnIds(params.queuedTurnId), + requestLogPayload: { + eventType: "chat_request_complete", + status: "success", + description: `请求完成,工具调用 ${params.toolCallCount} 次`, + }, + }; +} + +export function buildAgentStreamEmptyFinalErrorPlan(params: { + errorMessage: string; + accumulatedContent: string; + hasMeaningfulCompletionSignal?: boolean; + queuedTurnId?: string | null; +}): AgentStreamEmptyFinalErrorPlan { + if (!params.hasMeaningfulCompletionSignal) { + return buildAgentStreamMissingFinalReplyFailurePlan({ + errorMessage: params.errorMessage, + queuedTurnId: params.queuedTurnId, + }); + } + + return { + type: "complete", + finalContent: resolveAgentStreamGracefulCompletionContent({ + accumulatedContent: params.accumulatedContent, + }), + queuedTurnIds: resolveQueuedTurnIds(params.queuedTurnId), + requestLogPayload: { + eventType: "chat_request_complete", + status: "success", + description: "请求完成,模型未补充最终总结,已降级保留当前过程结果", + }, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamErrorController.test.ts b/src/components/agent/chat/hooks/agentStreamErrorController.test.ts new file mode 100644 index 000000000..476f42d27 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamErrorController.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentThreadItem, AgentThreadTurn } from "../types"; +import { + applyAgentStreamErrorToastPlan, + buildAgentStreamErrorFailurePlan, + buildAgentStreamErrorToastPlan, + buildAgentStreamFailedAssistantMessagePatch, + buildAgentStreamFailedTimelineItemUpdate, + buildAgentStreamFailedTimelineStatePlan, + buildAgentStreamFailedTimelineTurnUpdate, + selectAgentStreamFailedTimelineTurn, +} from "./agentStreamErrorController"; + +describe("agentStreamErrorController", () => { + it("应把 rate limit 错误展示为 warning toast", () => { + expect(buildAgentStreamErrorToastPlan("HTTP 429 rate limit")).toEqual({ + level: "warning", + message: "请求过于频繁,请稍后重试", + }); + }); + + it("应把普通错误展示为 runtime error toast", () => { + expect(buildAgentStreamErrorToastPlan("provider failed")).toEqual({ + level: "error", + message: "响应错误: provider failed", + }); + }); + + it("应构造失败 assistant 消息 patch,并保留局部输出", () => { + expect( + buildAgentStreamFailedAssistantMessagePatch({ + accumulatedContent: "已输出一半", + errorMessage: "provider failed", + previousContent: "旧内容", + }), + ).toMatchObject({ + isThinking: false, + content: "已输出一半\n\n执行失败:provider failed", + runtimeStatus: { + phase: "failed", + title: "当前处理失败", + detail: "provider failed", + }, + }); + }); + + it("应在无局部输出时回退 previousContent,并按需带回 usage", () => { + const usage = { input_tokens: 1, output_tokens: 2 }; + expect( + buildAgentStreamFailedAssistantMessagePatch({ + accumulatedContent: "", + errorMessage: "boom", + previousContent: "旧内容", + usage, + }), + ).toMatchObject({ + content: "旧内容\n\n执行失败:boom", + usage, + }); + }); + + it("应构造错误失败副作用计划", () => { + expect( + buildAgentStreamErrorFailurePlan({ + errorMessage: "provider failed", + queuedTurnId: "queued-1", + }), + ).toEqual({ + errorMessage: "provider failed", + queuedTurnIds: ["queued-1"], + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: "provider failed", + }, + toast: { + level: "error", + message: "响应错误: provider failed", + }, + }); + }); + + it("错误失败计划应保留 rate limit toast 降级", () => { + expect( + buildAgentStreamErrorFailurePlan({ + errorMessage: "HTTP 429 rate limit", + }), + ).toMatchObject({ + queuedTurnIds: [], + toast: { + level: "warning", + message: "请求过于频繁,请稍后重试", + }, + }); + }); + + it("应按错误 toast level 调用对应 dispatcher", () => { + const dispatcher = { + error: vi.fn(), + warning: vi.fn(), + }; + + applyAgentStreamErrorToastPlan( + { level: "warning", message: "请求过于频繁" }, + dispatcher, + ); + applyAgentStreamErrorToastPlan( + { level: "error", message: "响应错误" }, + dispatcher, + ); + + expect(dispatcher.warning).toHaveBeenCalledWith("请求过于频繁"); + expect(dispatcher.error).toHaveBeenCalledWith("响应错误"); + }); + + it("应构造失败 timeline 执行层计划", () => { + expect( + buildAgentStreamFailedTimelineStatePlan({ + activeSessionId: "session-1", + errorMessage: "provider failed", + failedAt: "2026-05-05T08:02:00.000Z", + pendingItemKey: "pending-item", + pendingTurnKey: "pending-turn", + }), + ).toEqual({ + activeSessionId: "session-1", + errorMessage: "provider failed", + failedAt: "2026-05-05T08:02:00.000Z", + pendingItemKey: "pending-item", + pendingTurnKey: "pending-turn", + }); + }); + + it("应优先选择 pending turn 标记为失败", () => { + const turns: AgentThreadTurn[] = [ + { + id: "turn-running-old", + thread_id: "session-1", + prompt_text: "旧 turn", + status: "running", + started_at: "2026-05-05T08:00:00.000Z", + created_at: "2026-05-05T08:00:00.000Z", + updated_at: "2026-05-05T08:00:00.000Z", + }, + { + id: "pending-turn", + thread_id: "session-1", + prompt_text: "当前 turn", + status: "running", + started_at: "2026-05-05T08:01:00.000Z", + created_at: "2026-05-05T08:01:00.000Z", + updated_at: "2026-05-05T08:01:00.000Z", + }, + ]; + + expect( + selectAgentStreamFailedTimelineTurn({ + activeSessionId: "session-1", + pendingTurnKey: "pending-turn", + turns, + })?.id, + ).toBe("pending-turn"); + expect( + buildAgentStreamFailedTimelineTurnUpdate({ + activeSessionId: "session-1", + errorMessage: "provider failed", + failedAt: "2026-05-05T08:02:00.000Z", + pendingTurnKey: "pending-turn", + turns, + }), + ).toMatchObject({ + id: "pending-turn", + status: "failed", + error_message: "provider failed", + completed_at: "2026-05-05T08:02:00.000Z", + updated_at: "2026-05-05T08:02:00.000Z", + }); + }); + + it("pending turn 不存在时应回退当前会话最后一个 running turn", () => { + const turns: AgentThreadTurn[] = [ + { + id: "turn-other-session", + thread_id: "session-other", + prompt_text: "其他会话", + status: "running", + started_at: "2026-05-05T08:00:00.000Z", + created_at: "2026-05-05T08:00:00.000Z", + updated_at: "2026-05-05T08:00:00.000Z", + }, + { + id: "turn-old", + thread_id: "session-1", + prompt_text: "旧 turn", + status: "running", + started_at: "2026-05-05T08:01:00.000Z", + created_at: "2026-05-05T08:01:00.000Z", + updated_at: "2026-05-05T08:01:00.000Z", + }, + { + id: "turn-latest", + thread_id: "session-1", + prompt_text: "最新 turn", + status: "running", + started_at: "2026-05-05T08:02:00.000Z", + created_at: "2026-05-05T08:02:00.000Z", + updated_at: "2026-05-05T08:02:00.000Z", + }, + ]; + + expect( + selectAgentStreamFailedTimelineTurn({ + activeSessionId: "session-1", + pendingTurnKey: "missing-turn", + turns, + })?.id, + ).toBe("turn-latest"); + }); + + it("应构造失败 turn summary item 更新并保留已完成时间", () => { + const items: AgentThreadItem[] = [ + { + id: "pending-item", + thread_id: "session-1", + turn_id: "pending-turn", + sequence: 1, + status: "in_progress", + started_at: "2026-05-05T08:01:00.000Z", + completed_at: "2026-05-05T08:01:30.000Z", + updated_at: "2026-05-05T08:01:00.000Z", + type: "turn_summary", + text: "处理中", + }, + ]; + + expect( + buildAgentStreamFailedTimelineItemUpdate({ + errorMessage: "provider failed", + failedAt: "2026-05-05T08:02:00.000Z", + items, + pendingItemKey: "pending-item", + }), + ).toMatchObject({ + id: "pending-item", + status: "failed", + completed_at: "2026-05-05T08:01:30.000Z", + updated_at: "2026-05-05T08:02:00.000Z", + text: "当前处理失败\n\nprovider failed", + }); + }); + + it("pending item 不存在或不是 turn_summary 时应跳过更新", () => { + const items: AgentThreadItem[] = [ + { + id: "agent-message", + thread_id: "session-1", + turn_id: "pending-turn", + sequence: 1, + status: "in_progress", + started_at: "2026-05-05T08:01:00.000Z", + updated_at: "2026-05-05T08:01:00.000Z", + type: "agent_message", + text: "正文", + }, + ]; + + expect( + buildAgentStreamFailedTimelineItemUpdate({ + errorMessage: "provider failed", + failedAt: "2026-05-05T08:02:00.000Z", + items, + pendingItemKey: "agent-message", + }), + ).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamErrorController.ts b/src/components/agent/chat/hooks/agentStreamErrorController.ts new file mode 100644 index 000000000..012aa3367 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamErrorController.ts @@ -0,0 +1,189 @@ +import type { AgentThreadItem, AgentThreadTurn, Message } from "../types"; +import { + buildFailedAgentMessageContent, + buildFailedAgentRuntimeStatus, + formatAgentRuntimeStatusSummary, +} from "../utils/agentRuntimeStatus"; +import { resolveAgentRuntimeErrorPresentation } from "../utils/agentRuntimeErrorPresentation"; + +export interface AgentStreamErrorToastPlan { + level: "error" | "warning"; + message: string; +} + +export interface AgentStreamErrorToastDispatcher { + error: (message: string) => void; + warning: (message: string) => void; +} + +interface AgentStreamErrorRequestLogPayload { + eventType: "chat_request_error"; + status: "error"; + error: string; +} + +export interface AgentStreamErrorFailurePlan { + errorMessage: string; + queuedTurnIds: string[]; + requestLogPayload: AgentStreamErrorRequestLogPayload; + toast: AgentStreamErrorToastPlan; +} + +export interface AgentStreamFailedTimelineStatePlan { + activeSessionId: string; + errorMessage: string; + failedAt: string; + pendingItemKey: string; + pendingTurnKey: string; +} + +const resolveQueuedTurnIds = (queuedTurnId?: string | null): string[] => + queuedTurnId ? [queuedTurnId] : []; + +export function buildAgentStreamErrorToastPlan( + errorMessage: string, +): AgentStreamErrorToastPlan { + const lowerMessage = errorMessage.toLowerCase(); + if (errorMessage.includes("429") || lowerMessage.includes("rate limit")) { + return { + level: "warning", + message: "请求过于频繁,请稍后重试", + }; + } + + return { + level: "error", + message: resolveAgentRuntimeErrorPresentation(errorMessage).toastMessage, + }; +} + +export function buildAgentStreamFailedAssistantMessagePatch(params: { + accumulatedContent: string; + errorMessage: string; + previousContent: string; + usage?: Message["usage"]; +}): Pick & + Partial> { + return { + isThinking: false, + content: buildFailedAgentMessageContent( + params.errorMessage, + params.accumulatedContent || params.previousContent, + ), + runtimeStatus: buildFailedAgentRuntimeStatus(params.errorMessage), + ...(params.usage !== undefined ? { usage: params.usage } : {}), + }; +} + +export function buildAgentStreamErrorFailurePlan(params: { + errorMessage: string; + queuedTurnId?: string | null; +}): AgentStreamErrorFailurePlan { + return { + errorMessage: params.errorMessage, + queuedTurnIds: resolveQueuedTurnIds(params.queuedTurnId), + requestLogPayload: { + eventType: "chat_request_error", + status: "error", + error: params.errorMessage, + }, + toast: buildAgentStreamErrorToastPlan(params.errorMessage), + }; +} + +export function applyAgentStreamErrorToastPlan( + toastPlan: AgentStreamErrorToastPlan, + dispatcher: AgentStreamErrorToastDispatcher, +): void { + if (toastPlan.level === "warning") { + dispatcher.warning(toastPlan.message); + return; + } + + dispatcher.error(toastPlan.message); +} + +export function buildAgentStreamFailedTimelineStatePlan(params: { + activeSessionId: string; + errorMessage: string; + failedAt: string; + pendingItemKey: string; + pendingTurnKey: string; +}): AgentStreamFailedTimelineStatePlan { + return { + activeSessionId: params.activeSessionId, + errorMessage: params.errorMessage, + failedAt: params.failedAt, + pendingItemKey: params.pendingItemKey, + pendingTurnKey: params.pendingTurnKey, + }; +} + +export function selectAgentStreamFailedTimelineTurn(params: { + activeSessionId: string; + pendingTurnKey: string; + turns: readonly AgentThreadTurn[]; +}): AgentThreadTurn | null { + const pendingTurn = params.turns.find( + (turn) => turn.id === params.pendingTurnKey, + ); + if (pendingTurn) { + return pendingTurn; + } + + return ( + [...params.turns] + .reverse() + .find( + (turn) => + turn.thread_id === params.activeSessionId && + turn.status === "running", + ) ?? null + ); +} + +export function buildAgentStreamFailedTimelineTurnUpdate(params: { + activeSessionId: string; + errorMessage: string; + failedAt: string; + pendingTurnKey: string; + turns: readonly AgentThreadTurn[]; +}): AgentThreadTurn | null { + const runningTurn = selectAgentStreamFailedTimelineTurn(params); + if (!runningTurn) { + return null; + } + + return { + ...runningTurn, + status: "failed", + error_message: params.errorMessage, + completed_at: runningTurn.completed_at || params.failedAt, + updated_at: params.failedAt, + }; +} + +export function buildAgentStreamFailedTimelineItemUpdate(params: { + errorMessage: string; + failedAt: string; + items: readonly AgentThreadItem[]; + pendingItemKey: string; +}): AgentThreadItem | null { + const pendingItem = params.items.find( + (item) => item.id === params.pendingItemKey, + ); + if (!pendingItem || pendingItem.type !== "turn_summary") { + return null; + } + + const failedRuntimeStatus = buildFailedAgentRuntimeStatus( + params.errorMessage, + ); + return { + ...pendingItem, + status: "failed", + completed_at: pendingItem.completed_at || params.failedAt, + updated_at: params.failedAt, + text: formatAgentRuntimeStatusSummary(failedRuntimeStatus), + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamInactivityController.test.ts b/src/components/agent/chat/hooks/agentStreamInactivityController.test.ts new file mode 100644 index 000000000..2a103b7fb --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamInactivityController.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_STREAM_FIRST_EVENT_TIMEOUT_MESSAGE, + AGENT_STREAM_INACTIVITY_TIMEOUT_MESSAGE, + buildAgentStreamFirstEventDeferredWarning, + buildAgentStreamFirstEventSilentRecoveryWarning, + buildAgentStreamInactivitySilentRecoveryWarning, + resolveAgentStreamFirstEventTimeoutAction, + resolveAgentStreamInactivityTimeoutAction, +} from "./agentStreamInactivityController"; + +describe("agentStreamInactivityController", () => { + it("应导出首包与 inactivity timeout 的用户文案", () => { + expect(AGENT_STREAM_FIRST_EVENT_TIMEOUT_MESSAGE).toContain( + "未返回任何进度事件", + ); + expect(AGENT_STREAM_INACTIVITY_TIMEOUT_MESSAGE).toContain( + "长时间没有返回新进度", + ); + }); + + it("应构造 silent recovery 与 deferred warning 文案", () => { + expect( + buildAgentStreamFirstEventSilentRecoveryWarning({ + eventName: "event-a", + }), + ).toBe( + "[AsterChat] 首个运行时事件静默,已降级切换为会话快照同步: event-a", + ); + expect( + buildAgentStreamFirstEventDeferredWarning({ + eventName: "event-a", + }), + ).toBe( + "[AsterChat] 首个运行时事件暂未到达,已基于提交派发继续等待后续进度: event-a", + ); + expect( + buildAgentStreamInactivitySilentRecoveryWarning({ + eventName: "event-a", + }), + ).toBe( + "[AsterChat] 运行时事件静默,已降级切换为会话快照同步: event-a", + ); + }); + + it("应按首包超时状态选择恢复动作", () => { + expect( + resolveAgentStreamFirstEventTimeoutAction({ + canDeferAfterSubmission: true, + firstEventReceived: true, + recovered: true, + requestFinished: false, + }), + ).toBe("ignore"); + expect( + resolveAgentStreamFirstEventTimeoutAction({ + canDeferAfterSubmission: true, + firstEventReceived: false, + recovered: true, + requestFinished: false, + }), + ).toBe("recover"); + expect( + resolveAgentStreamFirstEventTimeoutAction({ + canDeferAfterSubmission: true, + firstEventReceived: false, + recovered: false, + requestFinished: false, + }), + ).toBe("defer"); + expect( + resolveAgentStreamFirstEventTimeoutAction({ + canDeferAfterSubmission: false, + firstEventReceived: false, + recovered: false, + requestFinished: false, + }), + ).toBe("fail"); + }); + + it("应按 inactivity timeout 状态选择恢复动作", () => { + expect( + resolveAgentStreamInactivityTimeoutAction({ + recovered: true, + shouldIgnore: true, + }), + ).toBe("ignore"); + expect( + resolveAgentStreamInactivityTimeoutAction({ + recovered: true, + shouldIgnore: false, + }), + ).toBe("recover"); + expect( + resolveAgentStreamInactivityTimeoutAction({ + recovered: false, + shouldIgnore: false, + }), + ).toBe("fail"); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamInactivityController.ts b/src/components/agent/chat/hooks/agentStreamInactivityController.ts new file mode 100644 index 000000000..e9b5bdd69 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamInactivityController.ts @@ -0,0 +1,65 @@ +export const AGENT_STREAM_FIRST_EVENT_TIMEOUT_MESSAGE = + "执行已中断:运行时未返回任何进度事件,请重试。"; + +export const AGENT_STREAM_INACTIVITY_TIMEOUT_MESSAGE = + "执行已中断:运行时长时间没有返回新进度,请重试。"; + +export type AgentStreamFirstEventTimeoutAction = + | "defer" + | "fail" + | "ignore" + | "recover"; + +export type AgentStreamInactivityTimeoutAction = + | "fail" + | "ignore" + | "recover"; + +export function buildAgentStreamFirstEventSilentRecoveryWarning(params: { + eventName: string; +}): string { + return `[AsterChat] 首个运行时事件静默,已降级切换为会话快照同步: ${params.eventName}`; +} + +export function buildAgentStreamFirstEventDeferredWarning(params: { + eventName: string; +}): string { + return `[AsterChat] 首个运行时事件暂未到达,已基于提交派发继续等待后续进度: ${params.eventName}`; +} + +export function buildAgentStreamInactivitySilentRecoveryWarning(params: { + eventName: string; +}): string { + return `[AsterChat] 运行时事件静默,已降级切换为会话快照同步: ${params.eventName}`; +} + +export function resolveAgentStreamFirstEventTimeoutAction(params: { + canDeferAfterSubmission: boolean; + firstEventReceived: boolean; + recovered: boolean; + requestFinished: boolean; +}): AgentStreamFirstEventTimeoutAction { + if (params.firstEventReceived || params.requestFinished) { + return "ignore"; + } + if (params.recovered) { + return "recover"; + } + if (params.canDeferAfterSubmission) { + return "defer"; + } + return "fail"; +} + +export function resolveAgentStreamInactivityTimeoutAction(params: { + recovered: boolean; + shouldIgnore: boolean; +}): AgentStreamInactivityTimeoutAction { + if (params.shouldIgnore) { + return "ignore"; + } + if (params.recovered) { + return "recover"; + } + return "fail"; +} diff --git a/src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts b/src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts new file mode 100644 index 000000000..fb61b4a1d --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamListenerReadinessController.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamFirstEventContext, + buildAgentStreamFirstEventDeferredContext, + buildAgentStreamListenerBoundContext, + extractAgentStreamRuntimeEventType, + shouldDeferAgentStreamFirstEventTimeout, + shouldIgnoreAgentStreamInactivityResult, + shouldScheduleAgentStreamInactivityWatchdog, +} from "./agentStreamListenerReadinessController"; + +describe("agentStreamListenerReadinessController", () => { + it("应提取结构化 runtime event type", () => { + expect( + extractAgentStreamRuntimeEventType({ + type: "runtime_status", + }), + ).toBe("runtime_status"); + expect(extractAgentStreamRuntimeEventType({ type: " " })).toBeNull(); + expect(extractAgentStreamRuntimeEventType(["runtime_status"])).toBeNull(); + expect(extractAgentStreamRuntimeEventType(null)).toBeNull(); + }); + + it("应构造 listener bound 与 first event 指标上下文", () => { + expect( + buildAgentStreamListenerBoundContext({ + activeSessionId: "session-a", + eventName: "event-a", + expectingQueue: false, + listenerBoundAt: 145, + requestStartedAt: 100, + }), + ).toEqual({ + elapsedMs: 45, + eventName: "event-a", + expectingQueue: false, + sessionId: "session-a", + }); + + expect( + buildAgentStreamFirstEventContext({ + activeSessionId: "session-a", + eventName: "event-a", + eventReceivedAt: 260, + eventType: "text_delta", + recognized: true, + requestStartedAt: 100, + submissionDispatchedAt: 180, + }), + ).toEqual({ + elapsedMs: 160, + eventName: "event-a", + eventType: "text_delta", + recognized: true, + sessionId: "session-a", + submissionDispatchedDeltaMs: 80, + }); + }); + + it("应构造 first event deferred 上下文并保留未派发 delta", () => { + expect( + buildAgentStreamFirstEventDeferredContext({ + activeSessionId: "session-a", + deferredAt: 12_100, + eventName: "event-a", + requestStartedAt: 100, + submissionDispatchedAt: null, + }), + ).toEqual({ + elapsedMs: 12_000, + eventName: "event-a", + sessionId: "session-a", + submissionDispatchedDeltaMs: null, + }); + }); + + it("应判断 first event timeout 是否可转为提交后继续等待", () => { + expect( + shouldDeferAgentStreamFirstEventTimeout({ + firstEventReceived: false, + requestFinished: false, + submissionDispatchedAt: 200, + }), + ).toBe(true); + + expect( + shouldDeferAgentStreamFirstEventTimeout({ + firstEventReceived: true, + requestFinished: false, + submissionDispatchedAt: 200, + }), + ).toBe(false); + expect( + shouldDeferAgentStreamFirstEventTimeout({ + firstEventReceived: false, + requestFinished: false, + submissionDispatchedAt: null, + }), + ).toBe(false); + }); + + it("应判断 inactivity watchdog 调度与过期结果丢弃", () => { + expect( + shouldScheduleAgentStreamInactivityWatchdog({ + firstEventReceived: true, + requestFinished: false, + streamActivated: true, + }), + ).toBe(true); + expect( + shouldScheduleAgentStreamInactivityWatchdog({ + firstEventReceived: false, + requestFinished: false, + streamActivated: true, + }), + ).toBe(false); + + expect( + shouldIgnoreAgentStreamInactivityResult({ + lastEventReceivedAt: 220, + requestFinished: false, + streamActivated: true, + timeoutStartedAt: 200, + }), + ).toBe(true); + expect( + shouldIgnoreAgentStreamInactivityResult({ + lastEventReceivedAt: 180, + requestFinished: false, + streamActivated: true, + timeoutStartedAt: 200, + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts b/src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts new file mode 100644 index 000000000..854561879 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamListenerReadinessController.ts @@ -0,0 +1,112 @@ +export interface AgentStreamListenerBoundContextParams { + activeSessionId: string; + eventName: string; + expectingQueue: boolean; + listenerBoundAt: number; + requestStartedAt: number; +} + +export interface AgentStreamFirstEventContextParams { + activeSessionId: string; + eventName: string; + eventReceivedAt: number; + eventType: string; + recognized: boolean; + requestStartedAt: number; + submissionDispatchedAt?: number | null; +} + +export interface AgentStreamFirstEventDeferredContextParams { + activeSessionId: string; + deferredAt: number; + eventName: string; + requestStartedAt: number; + submissionDispatchedAt?: number | null; +} + +export function extractAgentStreamRuntimeEventType( + payload: unknown, +): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + + const type = (payload as { type?: unknown }).type; + return typeof type === "string" && type.trim() ? type : null; +} + +export function buildAgentStreamListenerBoundContext( + params: AgentStreamListenerBoundContextParams, +): Record { + return { + elapsedMs: params.listenerBoundAt - params.requestStartedAt, + eventName: params.eventName, + expectingQueue: params.expectingQueue, + sessionId: params.activeSessionId, + }; +} + +export function buildAgentStreamFirstEventContext( + params: AgentStreamFirstEventContextParams, +): Record { + return { + elapsedMs: params.eventReceivedAt - params.requestStartedAt, + eventName: params.eventName, + eventType: params.eventType, + recognized: params.recognized, + sessionId: params.activeSessionId, + submissionDispatchedDeltaMs: params.submissionDispatchedAt + ? params.eventReceivedAt - params.submissionDispatchedAt + : null, + }; +} + +export function buildAgentStreamFirstEventDeferredContext( + params: AgentStreamFirstEventDeferredContextParams, +): Record { + return { + elapsedMs: params.deferredAt - params.requestStartedAt, + eventName: params.eventName, + sessionId: params.activeSessionId, + submissionDispatchedDeltaMs: params.submissionDispatchedAt + ? params.deferredAt - params.submissionDispatchedAt + : null, + }; +} + +export function shouldDeferAgentStreamFirstEventTimeout(params: { + firstEventReceived: boolean; + requestFinished: boolean; + submissionDispatchedAt?: number | null; +}): boolean { + return ( + !params.firstEventReceived && + !params.requestFinished && + Boolean(params.submissionDispatchedAt) + ); +} + +export function shouldScheduleAgentStreamInactivityWatchdog(params: { + firstEventReceived: boolean; + requestFinished: boolean; + streamActivated: boolean; +}): boolean { + return ( + params.firstEventReceived && + !params.requestFinished && + params.streamActivated + ); +} + +export function shouldIgnoreAgentStreamInactivityResult(params: { + lastEventReceivedAt: number; + requestFinished: boolean; + streamActivated: boolean; + timeoutStartedAt: number; +}): boolean { + return ( + params.requestFinished || + !params.streamActivated || + params.lastEventReceivedAt > params.timeoutStartedAt + ); +} diff --git a/src/components/agent/chat/hooks/agentStreamQueueController.test.ts b/src/components/agent/chat/hooks/agentStreamQueueController.test.ts new file mode 100644 index 000000000..d7b59f532 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamQueueController.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamQueuedDraftMessagePatch, + buildAgentStreamQueuedDraftStatePlan, + shouldWatchAgentStreamQueuedDraftCleanup, + shouldWatchAgentStreamQueuedDraftCleanupForCleared, +} from "./agentStreamQueueController"; + +describe("agentStreamQueueController", () => { + it("应构造 queued draft 消息 patch,并优先使用 queued message text", () => { + const patch = buildAgentStreamQueuedDraftMessagePatch({ + contentFallback: "fallback", + executionStrategy: "auto", + queuedMessageText: " queued text ", + webSearch: true, + }); + + expect(patch.isThinking).toBe(false); + expect(patch.runtimeStatus).toMatchObject({ + phase: "routing", + title: "已加入排队列表", + detail: expect.stringContaining("queued text"), + }); + }); + + it("queued message text 为空时应回退当前内容", () => { + expect( + buildAgentStreamQueuedDraftMessagePatch({ + contentFallback: "fallback text", + executionStrategy: "react", + queuedMessageText: " ", + }).runtimeStatus?.detail, + ).toContain("fallback text"); + }); + + it("应构造 queued draft 状态副作用计划", () => { + const plan = buildAgentStreamQueuedDraftStatePlan({ + contentFallback: "fallback", + executionStrategy: "code_orchestrated", + queuedMessageText: "queued draft", + webSearch: false, + }); + + expect(plan).toMatchObject({ + shouldClearActiveStream: true, + shouldClearOptimisticItem: true, + shouldClearOptimisticTurn: true, + shouldSetSendingFalse: true, + messagePatch: { + isThinking: false, + runtimeStatus: { + phase: "routing", + title: "已加入排队列表", + }, + }, + }); + expect(plan.messagePatch.runtimeStatus?.detail).toContain("queued draft"); + }); + + it("应判断单个 queue removed 是否需要继续观察当前 draft", () => { + expect( + shouldWatchAgentStreamQueuedDraftCleanup({ + affectedQueuedTurnId: "queued-a", + currentQueuedTurnId: null, + }), + ).toBe(true); + expect( + shouldWatchAgentStreamQueuedDraftCleanup({ + affectedQueuedTurnId: "queued-a", + currentQueuedTurnId: "queued-a", + }), + ).toBe(true); + expect( + shouldWatchAgentStreamQueuedDraftCleanup({ + affectedQueuedTurnId: "queued-a", + currentQueuedTurnId: "queued-b", + }), + ).toBe(false); + }); + + it("应判断 queue cleared 是否覆盖当前 draft", () => { + expect( + shouldWatchAgentStreamQueuedDraftCleanupForCleared({ + clearedQueuedTurnIds: ["queued-a", "queued-b"], + currentQueuedTurnId: "queued-b", + }), + ).toBe(true); + expect( + shouldWatchAgentStreamQueuedDraftCleanupForCleared({ + clearedQueuedTurnIds: ["queued-a"], + currentQueuedTurnId: "queued-b", + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamQueueController.ts b/src/components/agent/chat/hooks/agentStreamQueueController.ts new file mode 100644 index 000000000..71f11ae70 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamQueueController.ts @@ -0,0 +1,60 @@ +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; +import type { Message } from "../types"; +import { buildQueuedRuntimeStatus } from "./agentStreamSubmitDraft"; + +export function buildAgentStreamQueuedDraftMessagePatch(params: { + contentFallback: string; + executionStrategy: AsterExecutionStrategy; + queuedMessageText?: string | null; + webSearch?: boolean; +}): Pick { + return { + isThinking: false, + runtimeStatus: buildQueuedRuntimeStatus( + params.executionStrategy, + params.queuedMessageText?.trim() || params.contentFallback, + params.webSearch, + ), + }; +} + +export function buildAgentStreamQueuedDraftStatePlan(params: { + contentFallback: string; + executionStrategy: AsterExecutionStrategy; + queuedMessageText?: string | null; + webSearch?: boolean; +}): { + messagePatch: Pick; + shouldClearActiveStream: boolean; + shouldClearOptimisticItem: boolean; + shouldClearOptimisticTurn: boolean; + shouldSetSendingFalse: boolean; +} { + return { + messagePatch: buildAgentStreamQueuedDraftMessagePatch(params), + shouldClearActiveStream: true, + shouldClearOptimisticItem: true, + shouldClearOptimisticTurn: true, + shouldSetSendingFalse: true, + }; +} + +export function shouldWatchAgentStreamQueuedDraftCleanup(params: { + affectedQueuedTurnId: string; + currentQueuedTurnId?: string | null; +}): boolean { + return ( + !params.currentQueuedTurnId || + params.currentQueuedTurnId === params.affectedQueuedTurnId + ); +} + +export function shouldWatchAgentStreamQueuedDraftCleanupForCleared(params: { + clearedQueuedTurnIds: string[]; + currentQueuedTurnId?: string | null; +}): boolean { + return ( + !params.currentQueuedTurnId || + params.clearedQueuedTurnIds.includes(params.currentQueuedTurnId) + ); +} diff --git a/src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts b/src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts new file mode 100644 index 000000000..dd35e525e --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRequestLogController.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentStreamRequestLogFinishPlan } from "./agentStreamRequestLogController"; + +describe("agentStreamRequestLogController", () => { + it("无 requestLogId 时不应更新 activity log", () => { + expect( + buildAgentStreamRequestLogFinishPlan({ + requestLogId: null, + requestFinished: false, + requestStartedAt: 100, + finishedAt: 180, + payload: { + eventType: "chat_request_complete", + status: "success", + description: "请求完成", + }, + }), + ).toEqual({ + shouldUpdate: false, + nextRequestFinished: false, + logId: null, + }); + }); + + it("request 已完成时不应重复更新 activity log", () => { + expect( + buildAgentStreamRequestLogFinishPlan({ + requestLogId: "log-a", + requestFinished: true, + requestStartedAt: 100, + finishedAt: 180, + payload: { + eventType: "chat_request_complete", + status: "success", + description: "请求完成", + }, + }), + ).toEqual({ + shouldUpdate: false, + nextRequestFinished: true, + logId: "log-a", + }); + }); + + it("完成请求时应生成 success update payload 和 duration", () => { + expect( + buildAgentStreamRequestLogFinishPlan({ + requestLogId: "log-a", + requestFinished: false, + requestStartedAt: 100, + finishedAt: 245, + payload: { + eventType: "chat_request_complete", + status: "success", + description: "请求完成,工具调用 2 次", + }, + }), + ).toEqual({ + shouldUpdate: true, + nextRequestFinished: true, + logId: "log-a", + updatePayload: { + eventType: "chat_request_complete", + status: "success", + duration: 145, + description: "请求完成,工具调用 2 次", + error: undefined, + }, + }); + }); + + it("失败请求时应保留 error 字段", () => { + expect( + buildAgentStreamRequestLogFinishPlan({ + requestLogId: "log-b", + requestFinished: false, + requestStartedAt: 200, + finishedAt: 260, + payload: { + eventType: "chat_request_error", + status: "error", + error: "模型未输出最终答复", + }, + }), + ).toEqual({ + shouldUpdate: true, + nextRequestFinished: true, + logId: "log-b", + updatePayload: { + eventType: "chat_request_error", + status: "error", + duration: 60, + description: undefined, + error: "模型未输出最终答复", + }, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRequestLogController.ts b/src/components/agent/chat/hooks/agentStreamRequestLogController.ts new file mode 100644 index 000000000..8d078b35e --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRequestLogController.ts @@ -0,0 +1,48 @@ +export interface AgentStreamRequestLogFinishPayload { + eventType: "chat_request_complete" | "chat_request_error"; + status: "success" | "error"; + description?: string; + error?: string; +} + +export interface AgentStreamRequestLogFinishUpdatePayload + extends AgentStreamRequestLogFinishPayload { + duration: number; +} + +export interface AgentStreamRequestLogFinishPlan { + shouldUpdate: boolean; + nextRequestFinished: boolean; + logId: string | null; + updatePayload?: AgentStreamRequestLogFinishUpdatePayload; +} + +export function buildAgentStreamRequestLogFinishPlan(params: { + requestLogId?: string | null; + requestFinished: boolean; + requestStartedAt: number; + finishedAt: number; + payload: AgentStreamRequestLogFinishPayload; +}): AgentStreamRequestLogFinishPlan { + const logId = params.requestLogId ?? null; + if (!logId || params.requestFinished) { + return { + shouldUpdate: false, + nextRequestFinished: params.requestFinished, + logId, + }; + } + + return { + shouldUpdate: true, + nextRequestFinished: true, + logId, + updatePayload: { + eventType: params.payload.eventType, + status: params.payload.status, + duration: params.finishedAt - params.requestStartedAt, + description: params.payload.description, + error: params.payload.error, + }, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts b/src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts new file mode 100644 index 000000000..db39cdb0a --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRequestStartController.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; +import { + buildAgentStreamRequestStartActivityLog, + buildAgentStreamRequestStartMetricContext, + startAgentStreamRequest, +} from "./agentStreamRequestStartController"; + +function createRequestState(): StreamRequestState { + return { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + queuedTurnId: null, + performanceTrace: { + requestId: "request-a", + sessionId: "session-a", + workspaceId: "workspace-a", + source: "home-input", + submittedAt: 100, + }, + }; +} + +describe("agentStreamRequestStartController", () => { + it("应构造 request start metric context", () => { + expect( + buildAgentStreamRequestStartMetricContext({ + activeSessionId: "session-a", + content: " 继续生成提纲 ", + effectiveExecutionStrategy: "react", + effectiveModel: "deepseek-chat", + effectiveProviderType: "deepseek", + eventName: "event-a", + expectingQueue: false, + resolvedWorkspaceId: "workspace-a", + skipUserMessage: false, + systemPrompt: "0123456789".repeat(6), + }), + ).toEqual({ + contentLength: 6, + eventName: "event-a", + expectingQueue: false, + model: "deepseek-chat", + provider: "deepseek", + sessionId: "session-a", + skipUserMessage: false, + systemPromptLength: 60, + systemPromptPreview: "0123456789".repeat(4) + "01234567", + }); + }); + + it("应构造 request start activity log payload", () => { + const autoContinue = { + enabled: true, + fast_mode_enabled: false, + continuation_length: 2, + sensitivity: 0.5, + }; + + expect( + buildAgentStreamRequestStartActivityLog({ + activeSessionId: "session-a", + autoContinue, + content: "系统启动", + effectiveExecutionStrategy: "code_orchestrated", + effectiveModel: "gpt-5.4", + effectiveProviderType: "openai", + eventName: "event-a", + expectingQueue: true, + resolvedWorkspaceId: "workspace-a", + skipUserMessage: true, + systemPrompt: "system", + }), + ).toEqual({ + eventType: "chat_request_start", + status: "pending", + title: "系统引导请求", + description: "模型: gpt-5.4 · 策略: code_orchestrated", + workspaceId: "workspace-a", + sessionId: "session-a", + source: "aster-chat", + metadata: { + provider: "openai", + model: "gpt-5.4", + executionStrategy: "code_orchestrated", + contentLength: 4, + skipUserMessage: true, + systemPromptLength: 6, + autoContinueEnabled: true, + autoContinue, + queuedSubmission: true, + }, + }); + }); + + it("应写入 requestState 并记录 metric/activity", () => { + const requestState = createRequestState(); + const recordMetric = vi.fn(); + const logActivity = vi.fn(() => "log-a"); + + const requestLogId = startAgentStreamRequest({ + activeSessionId: "session-a", + content: "你好", + effectiveExecutionStrategy: "react", + effectiveModel: "deepseek-chat", + effectiveProviderType: "deepseek", + eventName: "event-a", + expectingQueue: false, + requestState, + resolvedWorkspaceId: "workspace-a", + skipUserMessage: false, + deps: { + now: () => 250, + recordMetric, + logActivity, + }, + }); + + expect(requestLogId).toBe("log-a"); + expect(requestState.requestStartedAt).toBe(250); + expect(requestState.requestLogId).toBe("log-a"); + expect(recordMetric).toHaveBeenCalledWith( + "agentStream.request.start", + requestState.performanceTrace, + expect.objectContaining({ + contentLength: 2, + eventName: "event-a", + model: "deepseek-chat", + provider: "deepseek", + sessionId: "session-a", + }), + ); + expect(logActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "chat_request_start", + title: "发送请求", + sessionId: "session-a", + workspaceId: "workspace-a", + }), + ); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRequestStartController.ts b/src/components/agent/chat/hooks/agentStreamRequestStartController.ts new file mode 100644 index 000000000..88dca00b0 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRequestStartController.ts @@ -0,0 +1,129 @@ +import type { + AsterExecutionStrategy, + AutoContinueRequestPayload, +} from "@/lib/api/agentRuntime"; +import { + activityLogger, + type ActivityLog, +} from "@/lib/workspace/workbenchRuntime"; +import { mapProviderName } from "./agentChatCoreUtils"; +import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; +import { + recordAgentStreamPerformanceMetric, + type AgentUiPerformanceTraceMetadata, +} from "./agentStreamPerformanceMetrics"; + +type AgentStreamRequestStartMetricRecorder = ( + phase: string, + trace: AgentUiPerformanceTraceMetadata | null | undefined, + context: Record, +) => unknown; + +type AgentStreamActivityLogInput = Omit; +type AgentStreamActivityLogger = (event: AgentStreamActivityLogInput) => string; + +export interface AgentStreamRequestStartDeps { + logActivity?: AgentStreamActivityLogger; + now?: () => number; + recordMetric?: AgentStreamRequestStartMetricRecorder; +} + +export interface AgentStreamRequestStartParams { + activeSessionId: string; + autoContinue?: AutoContinueRequestPayload; + content: string; + effectiveExecutionStrategy: AsterExecutionStrategy; + effectiveModel: string; + effectiveProviderType: string; + eventName: string; + expectingQueue: boolean; + requestState: StreamRequestState; + resolvedWorkspaceId: string; + skipUserMessage: boolean; + systemPrompt?: string; + deps?: AgentStreamRequestStartDeps; +} + +interface AgentStreamRequestStartPayloadParams { + activeSessionId: string; + autoContinue?: AutoContinueRequestPayload; + content: string; + effectiveExecutionStrategy: AsterExecutionStrategy; + effectiveModel: string; + effectiveProviderType: string; + eventName: string; + expectingQueue: boolean; + resolvedWorkspaceId: string; + skipUserMessage: boolean; + systemPrompt?: string; +} + +function resolveContentLength(content: string): number { + return content.trim().length; +} + +export function buildAgentStreamRequestStartMetricContext( + params: AgentStreamRequestStartPayloadParams, +): Record { + return { + contentLength: resolveContentLength(params.content), + eventName: params.eventName, + expectingQueue: params.expectingQueue, + model: params.effectiveModel, + provider: params.effectiveProviderType, + sessionId: params.activeSessionId, + skipUserMessage: params.skipUserMessage, + systemPromptLength: params.systemPrompt?.length ?? 0, + systemPromptPreview: params.systemPrompt?.slice(0, 48) ?? null, + }; +} + +export function buildAgentStreamRequestStartActivityLog( + params: AgentStreamRequestStartPayloadParams, +): AgentStreamActivityLogInput { + return { + eventType: "chat_request_start", + status: "pending", + title: params.skipUserMessage ? "系统引导请求" : "发送请求", + description: `模型: ${params.effectiveModel} · 策略: ${params.effectiveExecutionStrategy}`, + workspaceId: params.resolvedWorkspaceId, + sessionId: params.activeSessionId, + source: "aster-chat", + metadata: { + provider: mapProviderName(params.effectiveProviderType), + model: params.effectiveModel, + executionStrategy: params.effectiveExecutionStrategy, + contentLength: resolveContentLength(params.content), + skipUserMessage: params.skipUserMessage, + systemPromptLength: params.systemPrompt?.length ?? 0, + autoContinueEnabled: params.autoContinue?.enabled ?? false, + autoContinue: params.autoContinue?.enabled + ? params.autoContinue + : undefined, + queuedSubmission: params.expectingQueue, + }, + }; +} + +export function startAgentStreamRequest( + params: AgentStreamRequestStartParams, +): string { + const now = params.deps?.now ?? Date.now; + const recordMetric = + params.deps?.recordMetric ?? recordAgentStreamPerformanceMetric; + const logActivity = params.deps?.logActivity ?? activityLogger.log.bind( + activityLogger, + ); + + params.requestState.requestStartedAt = now(); + recordMetric( + "agentStream.request.start", + params.requestState.performanceTrace, + buildAgentStreamRequestStartMetricContext(params), + ); + const requestLogId = logActivity( + buildAgentStreamRequestStartActivityLog(params), + ); + params.requestState.requestLogId = requestLogId; + return requestLogId; +} diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts new file mode 100644 index 000000000..0991a5778 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeContextController.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime"; +import { + applyAgentStreamModelChangeExecutionRuntime, + applyAgentStreamTurnContextExecutionRuntime, + buildAgentStreamContextTracePreApplyPlan, + buildAgentStreamModelChangePreApplyPlan, + buildAgentStreamTurnContextPreApplyPlan, +} from "./agentStreamRuntimeContextController"; + +describe("agentStreamRuntimeContextController", () => { + it("应构造 context trace 前置计划", () => { + expect( + buildAgentStreamContextTracePreApplyPlan({ + type: "context_trace", + steps: [ + { stage: "准备", detail: "读取上下文" }, + { stage: "检索", detail: "匹配记忆" }, + ], + }), + ).toEqual({ + latestStage: "检索", + shouldActivateStream: true, + shouldClearOptimisticItem: true, + stepCount: 2, + }); + }); + + it("应构造 turn context 前置计划并应用 execution runtime", () => { + const event = { + type: "turn_context" as const, + session_id: "session-a", + thread_id: "thread-a", + turn_id: "turn-a", + output_schema_runtime: { + source: "turn" as const, + strategy: "native" as const, + providerName: "deepseek", + modelName: "deepseek-chat", + }, + }; + + expect(buildAgentStreamTurnContextPreApplyPlan(event)).toEqual({ + latestTurnId: "turn-a", + shouldActivateStream: true, + source: "turn_context", + }); + expect(applyAgentStreamTurnContextExecutionRuntime(null, event)) + .toMatchObject({ + session_id: "session-a", + provider_name: "deepseek", + model_name: "deepseek-chat", + latest_turn_id: "turn-a", + latest_turn_status: "running", + source: "turn_context", + }); + }); + + it("应构造 model change 前置计划并保留当前 turn 状态", () => { + const current: AsterSessionExecutionRuntime = { + session_id: "session-a", + provider_selector: null, + provider_name: "deepseek", + model_name: "deepseek-chat", + execution_strategy: null, + output_schema_runtime: null, + recent_access_mode: null, + recent_preferences: null, + recent_team_selection: null, + recent_theme: null, + recent_session_mode: null, + recent_gate_key: null, + recent_run_title: null, + recent_content_id: null, + source: "turn_context", + mode: null, + latest_turn_id: "turn-a", + latest_turn_status: "completed", + }; + const event = { + type: "model_change" as const, + model: "deepseek-reasoner", + mode: "chat", + }; + + expect(buildAgentStreamModelChangePreApplyPlan(event)).toEqual({ + latestTurnId: null, + shouldActivateStream: true, + source: "model_change", + }); + expect(applyAgentStreamModelChangeExecutionRuntime(current, event)) + .toMatchObject({ + session_id: "session-a", + model_name: "deepseek-reasoner", + mode: "chat", + latest_turn_status: "completed", + source: "model_change", + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeContextController.ts b/src/components/agent/chat/hooks/agentStreamRuntimeContextController.ts new file mode 100644 index 000000000..26881890a --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeContextController.ts @@ -0,0 +1,69 @@ +import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime"; +import type { + AgentEventContextTrace, + AgentEventModelChange, + AgentEventTurnContext, +} from "@/lib/api/agentProtocol"; +import { + applyModelChangeExecutionRuntime, + applyTurnContextExecutionRuntime, +} from "../utils/sessionExecutionRuntime"; + +export interface AgentStreamContextTracePreApplyPlan { + latestStage: string | null; + shouldActivateStream: boolean; + shouldClearOptimisticItem: boolean; + stepCount: number; +} + +export interface AgentStreamRuntimeContextPreApplyPlan { + latestTurnId: string | null; + shouldActivateStream: boolean; + source: "turn_context" | "model_change"; +} + +export function buildAgentStreamContextTracePreApplyPlan( + event: AgentEventContextTrace, +): AgentStreamContextTracePreApplyPlan { + const latestStep = event.steps.at(-1); + return { + latestStage: latestStep?.stage || null, + shouldActivateStream: true, + shouldClearOptimisticItem: true, + stepCount: event.steps.length, + }; +} + +export function buildAgentStreamTurnContextPreApplyPlan( + event: AgentEventTurnContext, +): AgentStreamRuntimeContextPreApplyPlan { + return { + latestTurnId: event.turn_id || null, + shouldActivateStream: true, + source: "turn_context", + }; +} + +export function buildAgentStreamModelChangePreApplyPlan( + _event: AgentEventModelChange, +): AgentStreamRuntimeContextPreApplyPlan { + return { + latestTurnId: null, + shouldActivateStream: true, + source: "model_change", + }; +} + +export function applyAgentStreamTurnContextExecutionRuntime( + current: AsterSessionExecutionRuntime | null, + event: AgentEventTurnContext, +): AsterSessionExecutionRuntime | null { + return applyTurnContextExecutionRuntime(current, event); +} + +export function applyAgentStreamModelChangeExecutionRuntime( + current: AsterSessionExecutionRuntime | null, + event: AgentEventModelChange, +): AsterSessionExecutionRuntime | null { + return applyModelChangeExecutionRuntime(current, event); +} diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts index 77cb5a851..360b09068 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts @@ -13,12 +13,8 @@ import type { import { activityLogger } from "@/lib/workspace/workbenchRuntime"; import { logAgentDebug } from "@/lib/agentDebug"; import type { ActionRequired, Message } from "../types"; -import { - appendTextToParts, - appendTextWithOverlapDetection, -} from "./agentChatHistory"; +import { appendTextToParts } from "./agentChatHistory"; import { updateMessageArtifactsStatus } from "../utils/messageArtifacts"; -import { WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE } from "./agentChatCoreUtils"; import { removeThreadItemState, removeThreadTurnState, @@ -34,33 +30,78 @@ import { } from "./agentStreamEventProcessor"; import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; import { - buildFailedAgentMessageContent, - buildFailedAgentRuntimeStatus, - formatAgentRuntimeStatusSummary, -} from "../utils/agentRuntimeStatus"; -import { resolveAgentRuntimeErrorPresentation } from "../utils/agentRuntimeErrorPresentation"; -import { normalizeLegacyRuntimeStatusTitle } from "@/lib/api/agentTextNormalization"; -import { resolveRuntimeWarningToastPresentation } from "./runtimeWarningPresentation"; -import { buildQueuedRuntimeStatus } from "./agentStreamSubmitDraft"; + buildAgentStreamCompletedAssistantMessagePatch, + buildAgentStreamEmptyFinalErrorPlan, + buildAgentStreamFinalDonePlan, + buildAgentStreamMissingFinalReplyFailureSideEffectPlan, + type AgentStreamMissingFinalReplyPlan, + isAgentStreamEmptyFinalReplyError, +} from "./agentStreamCompletionController"; import { - applyModelChangeExecutionRuntime, - applyTurnContextExecutionRuntime, -} from "../utils/sessionExecutionRuntime"; -import { - containsAssistantProtocolResidue, - stripAssistantProtocolResidue, -} from "../utils/protocolResidue"; -import { normalizeIncomingToolResult } from "./agentChatToolResult"; -import { hasMeaningfulSiteToolResultSignal } from "../utils/siteToolResultSummary"; -import { - buildImageTaskPreviewFromToolResult, - buildTaskPreviewFromToolResult, - buildToolResultArtifactFromToolResult, -} from "../utils/taskPreviewFromToolResult"; + applyAgentStreamErrorToastPlan, + buildAgentStreamErrorFailurePlan, + buildAgentStreamFailedAssistantMessagePatch, + buildAgentStreamFailedTimelineStatePlan, + buildAgentStreamFailedTimelineItemUpdate, + buildAgentStreamFailedTimelineTurnUpdate, +} from "./agentStreamErrorController"; import { recordAgentStreamPerformanceMetric, type AgentUiPerformanceTraceMetadata, } from "./agentStreamPerformanceMetrics"; +import { + buildAgentStreamRequestLogFinishPlan, + type AgentStreamRequestLogFinishPayload, +} from "./agentStreamRequestLogController"; +import { + buildAgentStreamFirstRuntimeStatusMetricContext, + shouldRecordAgentStreamFirstRuntimeStatus, +} from "./agentStreamRuntimeMetricsController"; +import { + buildAgentStreamRuntimeStatusApplyPlan, + buildAgentStreamRuntimeSummaryItemUpdate, +} from "./agentStreamRuntimeStatusController"; +import { buildAgentStreamTextDeltaApplyPlan } from "./agentStreamTextDeltaController"; +import { + buildAgentStreamFirstTextPaintContext, + buildAgentStreamTextRenderFlushPlan, +} from "./agentStreamTextRenderFlushController"; +import { + buildAgentStreamQueuedDraftCleanupTimerFirePlan, + buildAgentStreamQueuedDraftCleanupTimerSchedulePlan, + buildAgentStreamTextRenderTimerSchedulePlan, + buildAgentStreamTimerClearPlan, +} from "./agentStreamTimerController"; +import { + applyAgentStreamWarningToastAction, + buildAgentStreamWarningPlan, + buildAgentStreamWarningToastAction, +} from "./agentStreamWarningController"; +import { + buildAgentStreamQueuedDraftStatePlan, + shouldWatchAgentStreamQueuedDraftCleanup, + shouldWatchAgentStreamQueuedDraftCleanupForCleared, +} from "./agentStreamQueueController"; +import { + buildAgentStreamTurnStartedPendingItemUpdate, + shouldDeferAgentStreamThreadItemUpdate, +} from "./agentStreamThreadItemController"; +import { buildAgentStreamToolEndPreApplyPlan } from "./agentStreamToolEventController"; +import { + buildAgentStreamActionRequiredPreApplyPlan, + buildAgentStreamArtifactSnapshotPreApplyPlan, +} from "./agentStreamArtifactActionController"; +import { + applyAgentStreamModelChangeExecutionRuntime, + applyAgentStreamTurnContextExecutionRuntime, + buildAgentStreamContextTracePreApplyPlan, + buildAgentStreamModelChangePreApplyPlan, + buildAgentStreamTurnContextPreApplyPlan, +} from "./agentStreamRuntimeContextController"; +import { + buildAgentStreamThinkingDeltaMessagePatch, + buildAgentStreamThinkingDeltaPreApplyPlan, +} from "./agentStreamThinkingDeltaController"; type MessageParts = NonNullable; @@ -95,13 +136,6 @@ interface StreamRequestState { performanceTrace?: AgentUiPerformanceTraceMetadata | null; } -const EMPTY_FINAL_REPLY_ERROR_HINT = "模型未输出最终答复"; -const EMPTY_FINAL_REPLY_ERROR_MESSAGE = "模型未输出最终答复,请重试"; -const EMPTY_FINAL_REPLY_FALLBACK_CONTENT = - "本轮执行已完成,详细过程与产物已保留在当前对话中。"; -const QUEUED_DRAFT_CLEANUP_GRACE_MS = 1800; -const TEXT_DELTA_RENDER_FLUSH_MS = 32; - interface StreamLifecycleCallbacks { activateStream: () => void; isStreamActivated: () => boolean; @@ -159,110 +193,25 @@ interface HandleTurnStreamEventOptions { function finishRequestLog( requestState: StreamRequestState, - payload: { - eventType: "chat_request_complete" | "chat_request_error"; - status: "success" | "error"; - description?: string; - error?: string; - }, + payload: AgentStreamRequestLogFinishPayload, ) { - if (!requestState.requestLogId || requestState.requestFinished) { + const requestLogPlan = buildAgentStreamRequestLogFinishPlan({ + requestLogId: requestState.requestLogId, + requestFinished: requestState.requestFinished, + requestStartedAt: requestState.requestStartedAt, + finishedAt: Date.now(), + payload, + }); + if ( + !requestLogPlan.shouldUpdate || + !requestLogPlan.logId || + !requestLogPlan.updatePayload + ) { return; } - requestState.requestFinished = true; - activityLogger.updateLog(requestState.requestLogId, { - eventType: payload.eventType, - status: payload.status, - duration: Date.now() - requestState.requestStartedAt, - description: payload.description, - error: payload.error, - }); -} - -function shouldDeferHighFrequencyThreadItemUpdate( - item: AgentThreadItem, -): boolean { - return ( - item.status === "in_progress" && - (item.type === "reasoning" || item.type === "agent_message") - ); -} - -function reconcileFinalContentParts(params: { - parts: Message["contentParts"]; - finalContent: string; - rawContent: string; - surfaceThinkingDeltas: boolean; -}): Message["contentParts"] { - if (!params.parts?.length) { - return params.parts; - } - - const visibleParts = params.surfaceThinkingDeltas - ? params.parts - : params.parts.filter((part) => part.type !== "thinking"); - if (visibleParts.length === 0) { - return undefined; - } - - const textContent = visibleParts - .filter((part) => part.type === "text") - .map((part) => part.text) - .join(""); - const finalTextChanged = - params.finalContent !== params.rawContent || - (textContent.length > 0 && textContent !== params.finalContent); - - if (!finalTextChanged) { - return visibleParts; - } - - const processParts = visibleParts.filter((part) => part.type !== "text"); - if (processParts.length === 0) { - return params.finalContent - ? [{ type: "text", text: params.finalContent }] - : undefined; - } - - return params.finalContent - ? [...processParts, { type: "text", text: params.finalContent }] - : processParts; -} - -function hasMeaningfulCompletionSignalFromToolResult(params: { - toolId: string; - toolName: string; - normalizedResult: - | { - metadata?: unknown; - } - | undefined; -}): boolean { - const resultRecord = - params.normalizedResult && - typeof params.normalizedResult === "object" && - !Array.isArray(params.normalizedResult) - ? (params.normalizedResult as Record) - : undefined; - - if (hasMeaningfulSiteToolResultSignal(resultRecord?.metadata)) { - return true; - } - - const previewParams = { - toolId: params.toolId, - toolName: params.toolName, - toolArguments: undefined, - toolResult: resultRecord, - fallbackPrompt: "", - }; - - return Boolean( - buildImageTaskPreviewFromToolResult(previewParams) || - buildTaskPreviewFromToolResult(previewParams) || - buildToolResultArtifactFromToolResult(previewParams), - ); + requestState.requestFinished = requestLogPlan.nextRequestFinished; + activityLogger.updateLog(requestLogPlan.logId, requestLogPlan.updatePayload); } export function handleTurnStreamEvent({ @@ -311,94 +260,76 @@ export function handleTurnStreamEvent({ } = callbacks; const clearQueuedDraftCleanupTimer = () => { - if (requestState.queuedDraftCleanupTimerId) { + const clearPlan = buildAgentStreamTimerClearPlan({ + hasTimer: Boolean(requestState.queuedDraftCleanupTimerId), + }); + if (clearPlan.shouldClearTimer && requestState.queuedDraftCleanupTimerId) { clearTimeout(requestState.queuedDraftCleanupTimerId); - requestState.queuedDraftCleanupTimerId = null; } + requestState.queuedDraftCleanupTimerId = clearPlan.nextTimerId; }; const clearPendingTextRenderTimer = () => { - if (requestState.pendingTextRenderTimerId) { + const clearPlan = buildAgentStreamTimerClearPlan({ + hasTimer: Boolean(requestState.pendingTextRenderTimerId), + }); + if (clearPlan.shouldClearTimer && requestState.pendingTextRenderTimerId) { clearTimeout(requestState.pendingTextRenderTimerId); - requestState.pendingTextRenderTimerId = null; } - }; - - const resolvePendingRenderedTextDelta = ( - renderedContent: string, - accumulatedContent: string, - ): string => { - if (!renderedContent) { - return accumulatedContent; - } - if (accumulatedContent.startsWith(renderedContent)) { - return accumulatedContent.slice(renderedContent.length); - } - return accumulatedContent; + requestState.pendingTextRenderTimerId = clearPlan.nextTimerId; }; const flushPendingTextRender = () => { clearPendingTextRenderTimer(); const renderedContent = requestState.renderedContent || ""; const nextContent = requestState.accumulatedContent; - if (nextContent === renderedContent) { + const flushStartedAt = Date.now(); + const flushPlan = buildAgentStreamTextRenderFlushPlan({ + activeSessionId, + eventName, + firstTextDeltaAt: requestState.firstTextDeltaAt, + firstTextPaintAt: requestState.firstTextPaintAt, + firstTextPaintScheduled: requestState.firstTextPaintScheduled, + firstTextRenderFlushAt: requestState.firstTextRenderFlushAt, + flushStartedAt, + maxTextDeltaBacklogChars: requestState.maxTextDeltaBacklogChars, + nextContent, + renderedContent, + requestStartedAt: requestState.requestStartedAt, + textDeltaFlushCount: requestState.textDeltaFlushCount, + }); + if (!flushPlan) { return; } - const flushStartedAt = Date.now(); - const textDelta = resolvePendingRenderedTextDelta( - renderedContent, - nextContent, - ); - requestState.renderedContent = nextContent; - requestState.textDeltaFlushCount = - (requestState.textDeltaFlushCount ?? 0) + 1; - requestState.lastTextRenderFlushAt = flushStartedAt; - if (!requestState.firstTextRenderFlushAt) { - requestState.firstTextRenderFlushAt = flushStartedAt; + requestState.renderedContent = flushPlan.nextRenderedContent; + requestState.textDeltaFlushCount = flushPlan.nextTextDeltaFlushCount; + requestState.lastTextRenderFlushAt = + flushPlan.nextLastTextRenderFlushAt; + requestState.maxTextDeltaBacklogChars = + flushPlan.nextMaxTextDeltaBacklogChars; + if ( + flushPlan.firstTextRenderFlushAt && + flushPlan.firstTextRenderFlushContext + ) { + requestState.firstTextRenderFlushAt = + flushPlan.firstTextRenderFlushAt; recordAgentStreamPerformanceMetric( "agentStream.firstTextRenderFlush", requestState.performanceTrace, - { - elapsedMs: flushStartedAt - requestState.requestStartedAt, - eventName, - firstTextDeltaDeltaMs: requestState.firstTextDeltaAt - ? flushStartedAt - requestState.firstTextDeltaAt - : null, - sessionId: activeSessionId, - }, + flushPlan.firstTextRenderFlushContext, ); } - const shouldScheduleFirstTextPaint = - !requestState.firstTextPaintAt && - !requestState.firstTextPaintScheduled && - nextContent.trim().length > 0; - if (shouldScheduleFirstTextPaint) { + if (flushPlan.shouldScheduleFirstTextPaint) { requestState.firstTextPaintScheduled = true; } - const backlogChars = Math.max( - 0, - nextContent.length - renderedContent.length, - ); - requestState.maxTextDeltaBacklogChars = Math.max( - requestState.maxTextDeltaBacklogChars ?? 0, - backlogChars, - ); - if (backlogChars >= 80 || (requestState.textDeltaFlushCount ?? 0) === 1) { + if (flushPlan.shouldLogFlush) { logAgentDebug( "AgentStream", "textRenderFlush", + flushPlan.flushLogContext, { - accumulatedChars: nextContent.length, - backlogChars, - elapsedMs: flushStartedAt - requestState.requestStartedAt, - eventName, - flushCount: requestState.textDeltaFlushCount, - maxBacklogChars: requestState.maxTextDeltaBacklogChars ?? 0, - sessionId: activeSessionId, - }, - { - dedupeKey: `AgentStream:textRenderFlush:${eventName}:${requestState.textDeltaFlushCount}`, + dedupeKey: flushPlan.flushLogDedupeKey, throttleMs: 250, }, ); @@ -410,46 +341,38 @@ export function handleTurnStreamEvent({ ...msg, content: nextContent, thinkingContent: undefined, - contentParts: textDelta + contentParts: flushPlan.textDelta ? appendTextToParts( surfaceThinkingDeltas ? msg.contentParts || [] : (msg.contentParts || []).filter( (part) => part.type !== "thinking", ), - textDelta, + flushPlan.textDelta, ) : msg.contentParts, } : msg, ), ); - if (shouldScheduleFirstTextPaint) { + if (flushPlan.shouldScheduleFirstTextPaint) { const recordFirstTextPaint = () => { const paintedAt = Date.now(); requestState.firstTextPaintAt = paintedAt; + const paintContext = buildAgentStreamFirstTextPaintContext({ + activeSessionId, + eventName, + firstTextDeltaAt: requestState.firstTextDeltaAt, + flushStartedAt, + paintedAt, + requestStartedAt: requestState.requestStartedAt, + }); recordAgentStreamPerformanceMetric( "agentStream.firstTextPaint", requestState.performanceTrace, - { - elapsedMs: paintedAt - requestState.requestStartedAt, - eventName, - firstTextDeltaDeltaMs: requestState.firstTextDeltaAt - ? paintedAt - requestState.firstTextDeltaAt - : null, - renderFlushDeltaMs: paintedAt - flushStartedAt, - sessionId: activeSessionId, - }, + paintContext, ); - logAgentDebug("AgentStream", "firstTextPaint", { - elapsedMs: paintedAt - requestState.requestStartedAt, - eventName, - firstTextDeltaDeltaMs: requestState.firstTextDeltaAt - ? paintedAt - requestState.firstTextDeltaAt - : null, - renderFlushDeltaMs: paintedAt - flushStartedAt, - sessionId: activeSessionId, - }); + logAgentDebug("AgentStream", "firstTextPaint", paintContext); }; if ( @@ -467,151 +390,159 @@ export function handleTurnStreamEvent({ const scheduleTextRenderFlush = () => { const renderedContent = requestState.renderedContent || ""; - const hasVisibleFirstText = - !renderedContent && requestState.accumulatedContent.trim().length > 0; - if (hasVisibleFirstText) { + const schedulePlan = buildAgentStreamTextRenderTimerSchedulePlan({ + accumulatedContent: requestState.accumulatedContent, + hasPendingTimer: Boolean(requestState.pendingTextRenderTimerId), + renderedContent, + }); + if (schedulePlan.action === "flush_now") { flushPendingTextRender(); return; } - if (requestState.pendingTextRenderTimerId) { + if (schedulePlan.action !== "schedule_timer" || !schedulePlan.delayMs) { return; } requestState.pendingTextRenderTimerId = setTimeout(() => { requestState.pendingTextRenderTimerId = null; flushPendingTextRender(); - }, TEXT_DELTA_RENDER_FLUSH_MS); + }, schedulePlan.delayMs); }; const scheduleQueuedDraftCleanup = (shouldWatchCurrentRequest: boolean) => { - clearQueuedDraftCleanupTimer(); - if (!shouldWatchCurrentRequest || isStreamActivated()) { + const cleanupSchedulePlan = + buildAgentStreamQueuedDraftCleanupTimerSchedulePlan({ + shouldWatchCurrentRequest, + streamActivated: isStreamActivated(), + }); + if (cleanupSchedulePlan.shouldClearExistingTimer) { + clearQueuedDraftCleanupTimer(); + } + if ( + !cleanupSchedulePlan.shouldScheduleTimer || + !cleanupSchedulePlan.delayMs + ) { return; } requestState.queuedDraftCleanupTimerId = setTimeout(() => { requestState.queuedDraftCleanupTimerId = null; - if (requestState.requestFinished || isStreamActivated()) { + const cleanupFirePlan = + buildAgentStreamQueuedDraftCleanupTimerFirePlan({ + requestFinished: requestState.requestFinished, + streamActivated: isStreamActivated(), + }); + if (!cleanupFirePlan.shouldCleanup) { return; } disposeListener(); removeQueuedDraftMessages(); - }, QUEUED_DRAFT_CLEANUP_GRACE_MS); + }, cleanupSchedulePlan.delayMs); }; const markFailedTimelineState = (errorMessage: string) => { - const failedAt = new Date().toISOString(); - const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage); + const failedTimelinePlan = buildAgentStreamFailedTimelineStatePlan({ + activeSessionId, + errorMessage, + failedAt: new Date().toISOString(), + pendingItemKey, + pendingTurnKey, + }); setThreadTurns((prev) => { - const runningTurn = - prev.find((turn) => turn.id === pendingTurnKey) || - [...prev] - .reverse() - .find( - (turn) => - turn.thread_id === activeSessionId && turn.status === "running", - ); - - if (!runningTurn) { + const failedTurn = buildAgentStreamFailedTimelineTurnUpdate({ + activeSessionId: failedTimelinePlan.activeSessionId, + errorMessage: failedTimelinePlan.errorMessage, + failedAt: failedTimelinePlan.failedAt, + pendingTurnKey: failedTimelinePlan.pendingTurnKey, + turns: prev, + }); + if (!failedTurn) { return prev; } - return upsertThreadTurnState(prev, { - ...runningTurn, - status: "failed", - error_message: errorMessage, - completed_at: runningTurn.completed_at || failedAt, - updated_at: failedAt, - }); + return upsertThreadTurnState(prev, failedTurn); }); setThreadItems((prev) => { - const pendingItem = prev.find((item) => item.id === pendingItemKey); - if (!pendingItem || pendingItem.type !== "turn_summary") { + const failedItem = buildAgentStreamFailedTimelineItemUpdate({ + errorMessage: failedTimelinePlan.errorMessage, + failedAt: failedTimelinePlan.failedAt, + items: prev, + pendingItemKey: failedTimelinePlan.pendingItemKey, + }); + if (!failedItem) { return prev; } - return upsertThreadItemState(prev, { - ...pendingItem, - status: "failed", - completed_at: pendingItem.completed_at || failedAt, - updated_at: failedAt, - text: formatAgentRuntimeStatusSummary(failedRuntimeStatus), - }); + return upsertThreadItemState(prev, failedItem); }); }; - const resolveGracefulCompletionContent = () => { - const rawFinalContent = requestState.accumulatedContent.trim(); - const cleanedFinalContent = stripAssistantProtocolResidue( - requestState.accumulatedContent, - ); - return ( - cleanedFinalContent || - (!containsAssistantProtocolResidue(requestState.accumulatedContent) - ? rawFinalContent - : "") || - EMPTY_FINAL_REPLY_FALLBACK_CONTENT - ); - }; - const finalizeMissingFinalReplyFailure = ( - errorMessage: string, - usage?: Message["usage"], + failurePlan: AgentStreamMissingFinalReplyPlan, ) => { - clearPendingTextRenderTimer(); - markFailedTimelineState(errorMessage); - removeQueuedTurnState( - requestState.queuedTurnId ? [requestState.queuedTurnId] : [], - ); - finishRequestLog(requestState, { - eventType: "chat_request_error", - status: "error", - error: errorMessage, - }); - observer?.onError?.(errorMessage); - const failedRuntimeStatus = buildFailedAgentRuntimeStatus(errorMessage); - toast.error(EMPTY_FINAL_REPLY_ERROR_MESSAGE); + const sideEffectPlan = + buildAgentStreamMissingFinalReplyFailureSideEffectPlan(failurePlan); + if (sideEffectPlan.shouldClearPendingTextRenderTimer) { + clearPendingTextRenderTimer(); + } + if (sideEffectPlan.shouldMarkFailedTimeline) { + markFailedTimelineState(sideEffectPlan.errorMessage); + } + removeQueuedTurnState(sideEffectPlan.queuedTurnIds); + finishRequestLog(requestState, sideEffectPlan.requestLogPayload); + observer?.onError?.(sideEffectPlan.observerErrorMessage); + toast.error(sideEffectPlan.toastMessage); setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...updateMessageArtifactsStatus(msg, "error"), - isThinking: false, - content: buildFailedAgentMessageContent( - errorMessage, - requestState.accumulatedContent || msg.content, - ), - runtimeStatus: failedRuntimeStatus, - usage: usage ?? msg.usage, + ...buildAgentStreamFailedAssistantMessagePatch({ + errorMessage: sideEffectPlan.errorMessage, + accumulatedContent: requestState.accumulatedContent, + previousContent: msg.content, + usage: sideEffectPlan.usage ?? msg.usage, + }), } : msg, ), ); - clearActiveStreamIfMatch(eventName); - disposeListener(); + if (sideEffectPlan.shouldClearActiveStream) { + clearActiveStreamIfMatch(eventName); + } + if (sideEffectPlan.shouldDisposeListener) { + disposeListener(); + } }; const markQueuedDraftState = (queuedMessageText?: string | null) => { - clearActiveStreamIfMatch(eventName); - clearOptimisticItem(); - clearOptimisticTurn(); - setIsSending(false); - - const queuedRuntimeStatus = buildQueuedRuntimeStatus( - effectiveExecutionStrategy, - queuedMessageText?.trim() || content, + const queuedDraftPlan = buildAgentStreamQueuedDraftStatePlan({ + contentFallback: content, + executionStrategy: effectiveExecutionStrategy, + queuedMessageText, webSearch, - ); + }); + if (queuedDraftPlan.shouldClearActiveStream) { + clearActiveStreamIfMatch(eventName); + } + if (queuedDraftPlan.shouldClearOptimisticItem) { + clearOptimisticItem(); + } + if (queuedDraftPlan.shouldClearOptimisticTurn) { + clearOptimisticTurn(); + } + if (queuedDraftPlan.shouldSetSendingFalse) { + setIsSending(false); + } setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...msg, - isThinking: false, - runtimeStatus: queuedRuntimeStatus, + ...queuedDraftPlan.messagePatch, } : msg, ), @@ -636,8 +567,10 @@ export function handleTurnStreamEvent({ case "queue_removed": removeQueuedTurnState([data.queued_turn_id]); scheduleQueuedDraftCleanup( - !requestState.queuedTurnId || - requestState.queuedTurnId === data.queued_turn_id, + shouldWatchAgentStreamQueuedDraftCleanup({ + affectedQueuedTurnId: data.queued_turn_id, + currentQueuedTurnId: requestState.queuedTurnId, + }), ); break; @@ -651,8 +584,10 @@ export function handleTurnStreamEvent({ case "queue_cleared": removeQueuedTurnState(data.queued_turn_ids); scheduleQueuedDraftCleanup( - !requestState.queuedTurnId || - data.queued_turn_ids.includes(requestState.queuedTurnId), + shouldWatchAgentStreamQueuedDraftCleanupForCleared({ + clearedQueuedTurnIds: data.queued_turn_ids, + currentQueuedTurnId: requestState.queuedTurnId, + }), ); break; @@ -668,21 +603,18 @@ export function handleTurnStreamEvent({ ); setThreadItems((prev) => { const pendingItem = prev.find((item) => item.id === pendingItemKey); - if (!pendingItem) { + const updatedPendingItem = + buildAgentStreamTurnStartedPendingItemUpdate({ + pendingItem, + turn: data.turn, + }); + if (!updatedPendingItem) { return prev; } return upsertThreadItemState( removeThreadItemState(prev, pendingItemKey), - { - ...pendingItem, - thread_id: data.turn.thread_id, - turn_id: data.turn.id, - updated_at: - data.turn.updated_at || - data.turn.started_at || - pendingItem.updated_at, - }, + updatedPendingItem, ); }); break; @@ -700,7 +632,7 @@ export function handleTurnStreamEvent({ case "item_updated": activateStream(); - if (shouldDeferHighFrequencyThreadItemUpdate(data.item)) { + if (shouldDeferAgentStreamThreadItemUpdate(data.item)) { break; } setThreadItems((prev) => @@ -728,75 +660,57 @@ export function handleTurnStreamEvent({ case "runtime_status": activateStream(); { - if (!requestState.firstRuntimeStatusAt) { + if ( + shouldRecordAgentStreamFirstRuntimeStatus({ + firstRuntimeStatusAt: requestState.firstRuntimeStatusAt, + }) + ) { requestState.firstRuntimeStatusAt = Date.now(); + const firstRuntimeStatusContext = + buildAgentStreamFirstRuntimeStatusMetricContext({ + activeSessionId, + eventName, + firstEventReceivedAt: requestState.firstEventReceivedAt, + firstRuntimeStatusAt: requestState.firstRuntimeStatusAt, + requestStartedAt: requestState.requestStartedAt, + statusPhase: data.status.phase, + statusTitle: data.status.title, + }); recordAgentStreamPerformanceMetric( "agentStream.firstRuntimeStatus", requestState.performanceTrace, - { - elapsedMs: - requestState.firstRuntimeStatusAt - - requestState.requestStartedAt, - eventName, - firstEventDeltaMs: requestState.firstEventReceivedAt - ? requestState.firstRuntimeStatusAt - - requestState.firstEventReceivedAt - : null, - phase: data.status.phase, - sessionId: activeSessionId, - title: data.status.title, - }, + firstRuntimeStatusContext, + ); + logAgentDebug( + "AgentStream", + "firstRuntimeStatus", + firstRuntimeStatusContext, ); - logAgentDebug("AgentStream", "firstRuntimeStatus", { - elapsedMs: - requestState.firstRuntimeStatusAt - requestState.requestStartedAt, - eventName, - firstEventDeltaMs: requestState.firstEventReceivedAt - ? requestState.firstRuntimeStatusAt - - requestState.firstEventReceivedAt - : null, - phase: data.status.phase, - sessionId: activeSessionId, - title: data.status.title, - }); } - const normalizedStatus = { - ...data.status, - title: normalizeLegacyRuntimeStatusTitle(data.status.title), - }; - const nextSummaryText = - formatAgentRuntimeStatusSummary(normalizedStatus); - const updatedAt = new Date().toISOString(); + const runtimeStatusPlan = buildAgentStreamRuntimeStatusApplyPlan({ + status: data.status, + updatedAt: new Date().toISOString(), + }); setThreadItems((prev) => { - const runtimeSummaryItem = - prev.find((item) => item.id === pendingItemKey) || - [...prev] - .reverse() - .find( - (item) => - item.thread_id === activeSessionId && - item.type === "turn_summary" && - item.status === "in_progress", - ); - if ( - !runtimeSummaryItem || - runtimeSummaryItem.type !== "turn_summary" - ) { + const runtimeSummaryItem = buildAgentStreamRuntimeSummaryItemUpdate({ + activeSessionId, + items: prev, + pendingItemKey, + summaryText: runtimeStatusPlan.summaryText, + updatedAt: runtimeStatusPlan.updatedAt, + }); + if (!runtimeSummaryItem) { return prev; } - return upsertThreadItemState(prev, { - ...runtimeSummaryItem, - text: nextSummaryText, - updated_at: updatedAt, - }); + return upsertThreadItemState(prev, runtimeSummaryItem); }); setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...msg, - runtimeStatus: normalizedStatus, + runtimeStatus: runtimeStatusPlan.normalizedStatus, } : msg, ), @@ -805,88 +719,91 @@ export function handleTurnStreamEvent({ break; case "turn_context": - activateStream(); + if (buildAgentStreamTurnContextPreApplyPlan(data).shouldActivateStream) { + activateStream(); + } setExecutionRuntime((current) => - applyTurnContextExecutionRuntime(current, data), + applyAgentStreamTurnContextExecutionRuntime(current, data), ); break; case "model_change": - activateStream(); + if (buildAgentStreamModelChangePreApplyPlan(data).shouldActivateStream) { + activateStream(); + } setExecutionRuntime((current) => - applyModelChangeExecutionRuntime(current, data), + applyAgentStreamModelChangeExecutionRuntime(current, data), ); break; case "thinking_delta": - activateStream(); - if (!surfaceThinkingDeltas) { - break; + { + const thinkingPlan = buildAgentStreamThinkingDeltaPreApplyPlan({ + surfaceThinkingDeltas, + }); + if (thinkingPlan.shouldActivateStream) { + activateStream(); + } + if (!thinkingPlan.shouldApplyThinkingDelta) { + break; + } } setMessages((prev) => - prev.map((msg) => - msg.id === assistantMsgId - ? { - ...msg, - isThinking: true, - thinkingContent: appendTextWithOverlapDetection( - msg.thinkingContent || "", - data.text, - ), - contentParts: appendThinkingToParts( - msg.contentParts || [], - data.text, - ), - } - : msg, - ), + prev.map((msg) => { + if (msg.id !== assistantMsgId) { + return msg; + } + + return { + ...msg, + ...buildAgentStreamThinkingDeltaMessagePatch({ + appendThinkingToParts, + contentParts: msg.contentParts, + textDelta: data.text, + thinkingContent: msg.thinkingContent, + }), + }; + }), ); break; case "text_delta": activateStream(); clearOptimisticItem(); - requestState.textDeltaBufferedCount = - (requestState.textDeltaBufferedCount ?? 0) + 1; - if (!requestState.firstTextDeltaAt) { - requestState.firstTextDeltaAt = Date.now(); - recordAgentStreamPerformanceMetric( - "agentStream.firstTextDelta", - requestState.performanceTrace, - { - deltaChars: data.text.length, - elapsedMs: - requestState.firstTextDeltaAt - requestState.requestStartedAt, - eventName, - firstEventDeltaMs: requestState.firstEventReceivedAt - ? requestState.firstTextDeltaAt - - requestState.firstEventReceivedAt - : null, - firstRuntimeStatusDeltaMs: requestState.firstRuntimeStatusAt - ? requestState.firstTextDeltaAt - - requestState.firstRuntimeStatusAt - : null, - sessionId: activeSessionId, - }, - ); - logAgentDebug("AgentStream", "firstTextDelta", { - deltaChars: data.text.length, - elapsedMs: - requestState.firstTextDeltaAt - requestState.requestStartedAt, + { + const textDeltaPlan = buildAgentStreamTextDeltaApplyPlan({ + activeSessionId, + accumulatedContent: requestState.accumulatedContent, + deltaText: data.text, eventName, - firstEventDeltaMs: requestState.firstEventReceivedAt - ? requestState.firstTextDeltaAt - requestState.firstEventReceivedAt - : null, - firstRuntimeStatusDeltaMs: requestState.firstRuntimeStatusAt - ? requestState.firstTextDeltaAt - requestState.firstRuntimeStatusAt - : null, - sessionId: activeSessionId, + firstEventReceivedAt: requestState.firstEventReceivedAt, + firstRuntimeStatusAt: requestState.firstRuntimeStatusAt, + firstTextDeltaAt: requestState.firstTextDeltaAt, + now: Date.now(), + requestStartedAt: requestState.requestStartedAt, + textDeltaBufferedCount: requestState.textDeltaBufferedCount, }); + requestState.textDeltaBufferedCount = + textDeltaPlan.nextBufferedCount; + if ( + textDeltaPlan.firstTextDeltaAt && + textDeltaPlan.firstTextDeltaContext + ) { + requestState.firstTextDeltaAt = textDeltaPlan.firstTextDeltaAt; + recordAgentStreamPerformanceMetric( + "agentStream.firstTextDelta", + requestState.performanceTrace, + textDeltaPlan.firstTextDeltaContext, + ); + logAgentDebug( + "AgentStream", + "firstTextDelta", + textDeltaPlan.firstTextDeltaContext, + ); + } + requestState.accumulatedContent = + textDeltaPlan.nextAccumulatedContent; } - requestState.accumulatedContent = appendTextWithOverlapDetection( - requestState.accumulatedContent, - data.text, - ); observer?.onTextDelta?.(data.text, requestState.accumulatedContent); playTypewriterSound(); scheduleTextRenderFlush(); @@ -914,15 +831,12 @@ export function handleTurnStreamEvent({ activateStream(); clearOptimisticItem(); { - const normalizedResult = normalizeIncomingToolResult(data.result); - const toolName = toolNameByToolId.get(data.tool_id) || ""; - if ( - hasMeaningfulCompletionSignalFromToolResult({ - toolId: data.tool_id, - toolName, - normalizedResult, - }) - ) { + const toolEndPlan = buildAgentStreamToolEndPreApplyPlan({ + result: data.result, + toolId: data.tool_id, + toolNameByToolId, + }); + if (toolEndPlan.hasMeaningfulCompletionSignal) { requestState.hasMeaningfulCompletionSignal = true; } } @@ -940,9 +854,20 @@ export function handleTurnStreamEvent({ break; case "artifact_snapshot": - activateStream(); - clearOptimisticItem(); - requestState.hasMeaningfulCompletionSignal = true; + { + const artifactPlan = buildAgentStreamArtifactSnapshotPreApplyPlan({ + artifact: data.artifact, + }); + if (artifactPlan.shouldActivateStream) { + activateStream(); + } + if (artifactPlan.shouldClearOptimisticItem) { + clearOptimisticItem(); + } + if (artifactPlan.shouldMarkMeaningfulCompletionSignal) { + requestState.hasMeaningfulCompletionSignal = true; + } + } handleArtifactSnapshotEvent({ data, onWriteFile, @@ -954,8 +879,15 @@ export function handleTurnStreamEvent({ break; case "action_required": - activateStream(); - clearOptimisticItem(); + { + const actionPlan = buildAgentStreamActionRequiredPreApplyPlan(data); + if (actionPlan.shouldActivateStream) { + activateStream(); + } + if (actionPlan.shouldClearOptimisticItem) { + clearOptimisticItem(); + } + } handleActionRequiredEvent({ data, actionLoggedKeys, @@ -970,8 +902,16 @@ export function handleTurnStreamEvent({ break; case "context_trace": - activateStream(); - clearOptimisticItem(); + { + const contextTracePlan = + buildAgentStreamContextTracePreApplyPlan(data); + if (contextTracePlan.shouldActivateStream) { + activateStream(); + } + if (contextTracePlan.shouldClearOptimisticItem) { + clearOptimisticItem(); + } + } handleContextTraceEvent({ data, assistantMsgId, @@ -986,31 +926,22 @@ export function handleTurnStreamEvent({ flushPendingTextRender(); clearOptimisticItem(); clearOptimisticTurn(); - const rawFinalContent = requestState.accumulatedContent.trim(); - const cleanedFinalContent = stripAssistantProtocolResidue( - requestState.accumulatedContent, - ); - const missingFinalReply = - !cleanedFinalContent && - (containsAssistantProtocolResidue(requestState.accumulatedContent) || - !rawFinalContent); - if (missingFinalReply && !requestState.hasMeaningfulCompletionSignal) { - finalizeMissingFinalReplyFailure( - EMPTY_FINAL_REPLY_ERROR_MESSAGE, - data.usage, - ); + const finalDonePlan = buildAgentStreamFinalDonePlan({ + accumulatedContent: requestState.accumulatedContent, + hasMeaningfulCompletionSignal: + requestState.hasMeaningfulCompletionSignal, + queuedTurnId: requestState.queuedTurnId, + toolCallCount: toolLogIdByToolId.size, + usage: data.usage, + }); + if (finalDonePlan.type === "missing_final_reply_failure") { + finalizeMissingFinalReplyFailure(finalDonePlan); break; } - removeQueuedTurnState( - requestState.queuedTurnId ? [requestState.queuedTurnId] : [], - ); - finishRequestLog(requestState, { - eventType: "chat_request_complete", - status: "success", - description: `请求完成,工具调用 ${toolLogIdByToolId.size} 次`, - }); - const finalContent = resolveGracefulCompletionContent(); + removeQueuedTurnState(finalDonePlan.queuedTurnIds); + finishRequestLog(requestState, finalDonePlan.requestLogPayload); + const finalContent = finalDonePlan.finalContent; observer?.onComplete?.(finalContent); setMessages((prev) => prev.map((msg) => { @@ -1020,16 +951,13 @@ export function handleTurnStreamEvent({ return { ...updateMessageArtifactsStatus(msg, "complete"), - isThinking: false, - content: finalContent, - contentParts: reconcileFinalContentParts({ + ...buildAgentStreamCompletedAssistantMessagePatch({ parts: msg.contentParts, finalContent, rawContent: requestState.accumulatedContent, surfaceThinkingDeltas, + usage: data.usage ?? msg.usage, }), - runtimeStatus: undefined, - usage: data.usage ?? msg.usage, }; }), ); @@ -1041,37 +969,35 @@ export function handleTurnStreamEvent({ case "error": { clearQueuedDraftCleanupTimer(); flushPendingTextRender(); - if (data.message.includes(EMPTY_FINAL_REPLY_ERROR_HINT)) { + if (isAgentStreamEmptyFinalReplyError(data.message)) { clearOptimisticItem(); clearOptimisticTurn(); - if (!requestState.hasMeaningfulCompletionSignal) { - finalizeMissingFinalReplyFailure(data.message); + const emptyFinalErrorPlan = buildAgentStreamEmptyFinalErrorPlan({ + errorMessage: data.message, + accumulatedContent: requestState.accumulatedContent, + hasMeaningfulCompletionSignal: + requestState.hasMeaningfulCompletionSignal, + queuedTurnId: requestState.queuedTurnId, + }); + if (emptyFinalErrorPlan.type === "missing_final_reply_failure") { + finalizeMissingFinalReplyFailure(emptyFinalErrorPlan); break; } - removeQueuedTurnState( - requestState.queuedTurnId ? [requestState.queuedTurnId] : [], - ); - finishRequestLog(requestState, { - eventType: "chat_request_complete", - status: "success", - description: "请求完成,模型未补充最终总结,已降级保留当前过程结果", - }); - const gracefulContent = resolveGracefulCompletionContent(); + removeQueuedTurnState(emptyFinalErrorPlan.queuedTurnIds); + finishRequestLog(requestState, emptyFinalErrorPlan.requestLogPayload); + const gracefulContent = emptyFinalErrorPlan.finalContent; observer?.onComplete?.(gracefulContent); setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...updateMessageArtifactsStatus(msg, "complete"), - isThinking: false, - content: gracefulContent, - contentParts: reconcileFinalContentParts({ + ...buildAgentStreamCompletedAssistantMessagePatch({ parts: msg.contentParts, finalContent: gracefulContent, rawContent: requestState.accumulatedContent, surfaceThinkingDeltas, }), - runtimeStatus: undefined, } : msg, ), @@ -1081,38 +1007,25 @@ export function handleTurnStreamEvent({ break; } - markFailedTimelineState(data.message); - removeQueuedTurnState( - requestState.queuedTurnId ? [requestState.queuedTurnId] : [], - ); - finishRequestLog(requestState, { - eventType: "chat_request_error", - status: "error", - error: data.message, + const errorFailurePlan = buildAgentStreamErrorFailurePlan({ + errorMessage: data.message, + queuedTurnId: requestState.queuedTurnId, }); - observer?.onError?.(data.message); - const failedRuntimeStatus = buildFailedAgentRuntimeStatus(data.message); - if ( - data.message.includes("429") || - data.message.toLowerCase().includes("rate limit") - ) { - toast.warning("请求过于频繁,请稍后重试"); - } else { - toast.error( - resolveAgentRuntimeErrorPresentation(data.message).toastMessage, - ); - } + markFailedTimelineState(errorFailurePlan.errorMessage); + removeQueuedTurnState(errorFailurePlan.queuedTurnIds); + finishRequestLog(requestState, errorFailurePlan.requestLogPayload); + observer?.onError?.(errorFailurePlan.errorMessage); + applyAgentStreamErrorToastPlan(errorFailurePlan.toast, toast); setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId ? { ...updateMessageArtifactsStatus(msg, "error"), - isThinking: false, - content: buildFailedAgentMessageContent( - data.message, - requestState.accumulatedContent || msg.content, - ), - runtimeStatus: failedRuntimeStatus, + ...buildAgentStreamFailedAssistantMessagePatch({ + errorMessage: errorFailurePlan.errorMessage, + accumulatedContent: requestState.accumulatedContent, + previousContent: msg.content, + }), } : msg, ), @@ -1123,32 +1036,20 @@ export function handleTurnStreamEvent({ } case "warning": { - if (data.code === WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE) { - break; - } const warningKey = `${activeSessionId}:${data.code || data.message}`; - if (!warnedKeysRef.current.has(warningKey)) { - warnedKeysRef.current.add(warningKey); - const presentation = resolveRuntimeWarningToastPresentation({ - code: data.code, - message: data.message, - }); - if (!presentation.shouldToast) { - break; - } - switch (presentation.level) { - case "info": - toast.info(presentation.message); - break; - case "error": - toast.error(presentation.message); - break; - case "warning": - default: - toast.warning(presentation.message); - break; - } + const warningPlan = buildAgentStreamWarningPlan({ + activeSessionId, + alreadyWarned: warnedKeysRef.current.has(warningKey), + code: data.code, + message: data.message, + }); + if (warningPlan.shouldMarkWarned && warningPlan.warningKey) { + warnedKeysRef.current.add(warningPlan.warningKey); } + applyAgentStreamWarningToastAction( + buildAgentStreamWarningToastAction(warningPlan.toast), + toast, + ); break; } diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts new file mode 100644 index 000000000..3b9674c2a --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamFirstRuntimeStatusMetricContext, + buildAgentStreamFirstTextDeltaMetricContext, + shouldRecordAgentStreamFirstRuntimeStatus, + shouldRecordAgentStreamFirstTextDelta, +} from "./agentStreamRuntimeMetricsController"; + +describe("agentStreamRuntimeMetricsController", () => { + it("应判断 first runtime status / first text delta 是否需要记录", () => { + expect( + shouldRecordAgentStreamFirstRuntimeStatus({ + firstRuntimeStatusAt: null, + }), + ).toBe(true); + expect( + shouldRecordAgentStreamFirstRuntimeStatus({ + firstRuntimeStatusAt: 120, + }), + ).toBe(false); + expect( + shouldRecordAgentStreamFirstTextDelta({ firstTextDeltaAt: undefined }), + ).toBe(true); + expect(shouldRecordAgentStreamFirstTextDelta({ firstTextDeltaAt: 130 })).toBe( + false, + ); + }); + + it("应构造 first runtime status 指标上下文", () => { + expect( + buildAgentStreamFirstRuntimeStatusMetricContext({ + activeSessionId: "session-a", + eventName: "event-a", + firstEventReceivedAt: 140, + firstRuntimeStatusAt: 190, + requestStartedAt: 100, + statusPhase: "routing", + statusTitle: "分析中", + }), + ).toEqual({ + elapsedMs: 90, + eventName: "event-a", + firstEventDeltaMs: 50, + phase: "routing", + sessionId: "session-a", + title: "分析中", + }); + }); + + it("应构造 first text delta 指标上下文", () => { + expect( + buildAgentStreamFirstTextDeltaMetricContext({ + activeSessionId: "session-a", + deltaText: "你好", + eventName: "event-a", + firstEventReceivedAt: 140, + firstRuntimeStatusAt: 190, + firstTextDeltaAt: 260, + requestStartedAt: 100, + }), + ).toEqual({ + deltaChars: 2, + elapsedMs: 160, + eventName: "event-a", + firstEventDeltaMs: 120, + firstRuntimeStatusDeltaMs: 70, + sessionId: "session-a", + }); + }); + + it("未记录前置阶段时 delta 字段应为 null", () => { + expect( + buildAgentStreamFirstTextDeltaMetricContext({ + activeSessionId: "session-a", + deltaText: "好", + eventName: "event-a", + firstEventReceivedAt: null, + firstRuntimeStatusAt: null, + firstTextDeltaAt: 150, + requestStartedAt: 100, + }), + ).toMatchObject({ + firstEventDeltaMs: null, + firstRuntimeStatusDeltaMs: null, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts b/src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts new file mode 100644 index 000000000..5e180e732 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeMetricsController.ts @@ -0,0 +1,63 @@ +export interface AgentStreamFirstRuntimeStatusMetricContextParams { + activeSessionId: string; + eventName: string; + firstEventReceivedAt?: number | null; + firstRuntimeStatusAt: number; + requestStartedAt: number; + statusPhase: string; + statusTitle: string; +} + +export interface AgentStreamFirstTextDeltaMetricContextParams { + activeSessionId: string; + deltaText: string; + eventName: string; + firstEventReceivedAt?: number | null; + firstRuntimeStatusAt?: number | null; + firstTextDeltaAt: number; + requestStartedAt: number; +} + +export function shouldRecordAgentStreamFirstRuntimeStatus(params: { + firstRuntimeStatusAt?: number | null; +}): boolean { + return !params.firstRuntimeStatusAt; +} + +export function shouldRecordAgentStreamFirstTextDelta(params: { + firstTextDeltaAt?: number | null; +}): boolean { + return !params.firstTextDeltaAt; +} + +export function buildAgentStreamFirstRuntimeStatusMetricContext( + params: AgentStreamFirstRuntimeStatusMetricContextParams, +): Record { + return { + elapsedMs: params.firstRuntimeStatusAt - params.requestStartedAt, + eventName: params.eventName, + firstEventDeltaMs: params.firstEventReceivedAt + ? params.firstRuntimeStatusAt - params.firstEventReceivedAt + : null, + phase: params.statusPhase, + sessionId: params.activeSessionId, + title: params.statusTitle, + }; +} + +export function buildAgentStreamFirstTextDeltaMetricContext( + params: AgentStreamFirstTextDeltaMetricContextParams, +): Record { + return { + deltaChars: params.deltaText.length, + elapsedMs: params.firstTextDeltaAt - params.requestStartedAt, + eventName: params.eventName, + firstEventDeltaMs: params.firstEventReceivedAt + ? params.firstTextDeltaAt - params.firstEventReceivedAt + : null, + firstRuntimeStatusDeltaMs: params.firstRuntimeStatusAt + ? params.firstTextDeltaAt - params.firstRuntimeStatusAt + : null, + sessionId: params.activeSessionId, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts new file mode 100644 index 000000000..54c46026f --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeStatusController.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { AgentThreadItem } from "@/lib/api/agentProtocol"; +import { + buildAgentStreamNormalizedRuntimeStatus, + buildAgentStreamRuntimeStatusApplyPlan, + buildAgentStreamRuntimeSummaryItemUpdate, + selectAgentStreamRuntimeSummaryItem, +} from "./agentStreamRuntimeStatusController"; + +function summaryItem(id: string, threadId = "session-a"): AgentThreadItem { + return { + id, + thread_id: threadId, + turn_id: "turn-a", + sequence: 1, + status: "in_progress", + started_at: "2026-05-05T00:00:00.000Z", + updated_at: "2026-05-05T00:00:00.000Z", + type: "turn_summary", + text: "旧状态", + }; +} + +describe("agentStreamRuntimeStatusController", () => { + it("应归一化 runtime status 并构造 summary 文本", () => { + const plan = buildAgentStreamRuntimeStatusApplyPlan({ + status: { + phase: "routing", + title: "正在分析意图", + detail: "准备选择执行策略", + }, + updatedAt: "2026-05-05T10:00:00.000Z", + }); + + expect(plan).toMatchObject({ + normalizedStatus: { + phase: "routing", + title: "正在分析意图", + detail: "准备选择执行策略", + }, + updatedAt: "2026-05-05T10:00:00.000Z", + }); + expect(plan.summaryText).toContain("正在分析意图"); + expect( + buildAgentStreamNormalizedRuntimeStatus({ + phase: "context", + title: "读取上下文", + detail: "读取项目资料", + }), + ).toMatchObject({ title: "读取上下文" }); + }); + + it("应优先选择 pending turn summary item", () => { + const pendingSummary = summaryItem("pending-summary"); + const fallbackSummary = summaryItem("fallback-summary"); + + expect( + selectAgentStreamRuntimeSummaryItem({ + activeSessionId: "session-a", + items: [fallbackSummary, pendingSummary], + pendingItemKey: "pending-summary", + }), + ).toEqual(pendingSummary); + }); + + it("pending item 存在但不是 turn_summary 时应保持原行为不回退", () => { + const pendingAgentMessage: AgentThreadItem = { + id: "pending-item", + thread_id: "session-a", + turn_id: "turn-a", + sequence: 1, + status: "in_progress", + started_at: "2026-05-05T00:00:00.000Z", + updated_at: "2026-05-05T00:00:00.000Z", + type: "agent_message", + text: "正文", + }; + + expect( + selectAgentStreamRuntimeSummaryItem({ + activeSessionId: "session-a", + items: [summaryItem("fallback-summary"), pendingAgentMessage], + pendingItemKey: "pending-item", + }), + ).toBeNull(); + }); + + it("无 pending item 时应选择同 session 最新 in-progress summary", () => { + const older = summaryItem("older"); + const newer = summaryItem("newer"); + const otherSession = summaryItem("other", "session-b"); + + expect( + selectAgentStreamRuntimeSummaryItem({ + activeSessionId: "session-a", + items: [older, otherSession, newer], + pendingItemKey: "missing", + })?.id, + ).toBe("newer"); + }); + + it("应构造 summary item 更新", () => { + expect( + buildAgentStreamRuntimeSummaryItemUpdate({ + activeSessionId: "session-a", + items: [summaryItem("summary-a")], + pendingItemKey: "summary-a", + summaryText: "新状态", + updatedAt: "2026-05-05T10:00:00.000Z", + }), + ).toMatchObject({ + id: "summary-a", + text: "新状态", + updated_at: "2026-05-05T10:00:00.000Z", + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts b/src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts new file mode 100644 index 000000000..3d696daf6 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeStatusController.ts @@ -0,0 +1,76 @@ +import type { + AgentRuntimeStatusPayload, + AgentThreadItem, + AgentThreadTurnSummaryItem, +} from "@/lib/api/agentProtocol"; +import { normalizeLegacyRuntimeStatusTitle } from "@/lib/api/agentTextNormalization"; +import { formatAgentRuntimeStatusSummary } from "../utils/agentRuntimeStatus"; + +export interface AgentStreamRuntimeStatusApplyPlan { + normalizedStatus: AgentRuntimeStatusPayload; + summaryText: string; + updatedAt: string; +} + +export function buildAgentStreamNormalizedRuntimeStatus( + status: AgentRuntimeStatusPayload, +): AgentRuntimeStatusPayload { + return { + ...status, + title: normalizeLegacyRuntimeStatusTitle(status.title), + }; +} + +export function buildAgentStreamRuntimeStatusApplyPlan(params: { + status: AgentRuntimeStatusPayload; + updatedAt: string; +}): AgentStreamRuntimeStatusApplyPlan { + const normalizedStatus = buildAgentStreamNormalizedRuntimeStatus( + params.status, + ); + return { + normalizedStatus, + summaryText: formatAgentRuntimeStatusSummary(normalizedStatus), + updatedAt: params.updatedAt, + }; +} + +export function selectAgentStreamRuntimeSummaryItem(params: { + activeSessionId: string; + items: readonly AgentThreadItem[]; + pendingItemKey: string; +}): AgentThreadTurnSummaryItem | null { + const pendingItem = params.items.find( + (item) => item.id === params.pendingItemKey, + ); + if (pendingItem) { + return pendingItem.type === "turn_summary" ? pendingItem : null; + } + + const fallbackItem = [...params.items].reverse().find( + (item) => + item.thread_id === params.activeSessionId && + item.type === "turn_summary" && + item.status === "in_progress", + ); + return fallbackItem?.type === "turn_summary" ? fallbackItem : null; +} + +export function buildAgentStreamRuntimeSummaryItemUpdate(params: { + activeSessionId: string; + items: readonly AgentThreadItem[]; + pendingItemKey: string; + summaryText: string; + updatedAt: string; +}): AgentThreadTurnSummaryItem | null { + const summaryItem = selectAgentStreamRuntimeSummaryItem(params); + if (!summaryItem) { + return null; + } + + return { + ...summaryItem, + text: params.summaryText, + updated_at: params.updatedAt, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts b/src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts new file mode 100644 index 000000000..82f546a7d --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmissionController.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamSubmitAcceptedContext, + buildAgentStreamSubmitDispatchedContext, + buildAgentStreamSubmitFailedContext, + buildAgentStreamSubmitFailedLogContext, + resolveAgentStreamSubmitErrorMessage, +} from "./agentStreamSubmissionController"; + +describe("agentStreamSubmissionController", () => { + it("应构造 listener bound 后的 submit dispatched 上下文", () => { + expect( + buildAgentStreamSubmitDispatchedContext({ + activeSessionId: "session-a", + effectiveModel: "deepseek-chat", + effectiveProviderType: "deepseek", + eventName: "event-a", + expectingQueue: false, + timing: { + requestStartedAt: 100, + listenerBoundAt: 140, + now: 175, + }, + }), + ).toEqual({ + elapsedMs: 75, + eventName: "event-a", + expectingQueue: false, + listenerBoundDeltaMs: 35, + model: "deepseek-chat", + provider: "deepseek", + sessionId: "session-a", + }); + }); + + it("未记录 listener bound 时 dispatched 上下文应保留 null delta", () => { + expect( + buildAgentStreamSubmitDispatchedContext({ + activeSessionId: "session-a", + effectiveModel: "gpt-5.4", + effectiveProviderType: "openai", + eventName: "event-a", + expectingQueue: true, + timing: { + requestStartedAt: 100, + listenerBoundAt: null, + now: 125, + }, + }), + ).toMatchObject({ + elapsedMs: 25, + listenerBoundDeltaMs: null, + }); + }); + + it("应构造 submit accepted 与 failed 上下文", () => { + expect( + buildAgentStreamSubmitAcceptedContext({ + activeSessionId: "session-a", + eventName: "event-a", + timing: { + requestStartedAt: 100, + submissionDispatchedAt: 160, + now: 210, + }, + }), + ).toEqual({ + elapsedMs: 110, + eventName: "event-a", + sessionId: "session-a", + submitInvokeMs: 50, + }); + + expect( + buildAgentStreamSubmitFailedContext({ + activeSessionId: "session-a", + eventName: "event-a", + error: new Error("provider timeout"), + timing: { + requestStartedAt: 100, + submissionDispatchedAt: 160, + now: 220, + }, + }), + ).toEqual({ + elapsedMs: 120, + error: "provider timeout", + eventName: "event-a", + sessionId: "session-a", + submitInvokeMs: 60, + }); + }); + + it("failed log context 应保留原始 error", () => { + const error = new Error("bridge failed"); + + expect(resolveAgentStreamSubmitErrorMessage("bad gateway")).toBe( + "bad gateway", + ); + expect( + buildAgentStreamSubmitFailedLogContext({ + activeSessionId: "session-a", + eventName: "event-a", + error, + timing: { + requestStartedAt: 100, + submissionDispatchedAt: null, + now: 120, + }, + }), + ).toEqual({ + elapsedMs: 20, + error, + eventName: "event-a", + sessionId: "session-a", + submitInvokeMs: null, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamSubmissionController.ts b/src/components/agent/chat/hooks/agentStreamSubmissionController.ts new file mode 100644 index 000000000..86efe8a7d --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmissionController.ts @@ -0,0 +1,75 @@ +export interface AgentStreamSubmissionTimingState { + listenerBoundAt?: number | null; + now: number; + requestStartedAt: number; + submissionDispatchedAt?: number | null; +} + +export function resolveAgentStreamSubmitErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function buildAgentStreamSubmitDispatchedContext(params: { + activeSessionId: string; + effectiveModel: string; + effectiveProviderType: string; + eventName: string; + expectingQueue: boolean; + timing: AgentStreamSubmissionTimingState; +}): Record { + return { + elapsedMs: params.timing.now - params.timing.requestStartedAt, + eventName: params.eventName, + expectingQueue: params.expectingQueue, + listenerBoundDeltaMs: params.timing.listenerBoundAt + ? params.timing.now - params.timing.listenerBoundAt + : null, + model: params.effectiveModel, + provider: params.effectiveProviderType, + sessionId: params.activeSessionId, + }; +} + +export function buildAgentStreamSubmitAcceptedContext(params: { + activeSessionId: string; + eventName: string; + timing: AgentStreamSubmissionTimingState; +}): Record { + return { + elapsedMs: params.timing.now - params.timing.requestStartedAt, + eventName: params.eventName, + sessionId: params.activeSessionId, + submitInvokeMs: params.timing.submissionDispatchedAt + ? params.timing.now - params.timing.submissionDispatchedAt + : null, + }; +} + +export function buildAgentStreamSubmitFailedContext(params: { + activeSessionId: string; + error: unknown; + eventName: string; + timing: AgentStreamSubmissionTimingState; +}): Record { + return { + elapsedMs: params.timing.now - params.timing.requestStartedAt, + error: resolveAgentStreamSubmitErrorMessage(params.error), + eventName: params.eventName, + sessionId: params.activeSessionId, + submitInvokeMs: params.timing.submissionDispatchedAt + ? params.timing.now - params.timing.submissionDispatchedAt + : null, + }; +} + +export function buildAgentStreamSubmitFailedLogContext(params: { + activeSessionId: string; + error: unknown; + eventName: string; + timing: AgentStreamSubmissionTimingState; +}): Record { + return { + ...buildAgentStreamSubmitFailedContext(params), + error: params.error, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts index c52598e23..ebf438d4e 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts @@ -16,14 +16,11 @@ import type { AgentAccessMode } from "./agentChatStorage"; import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; import type { ActionRequired, Message, MessageImage } from "../types"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import { logAgentDebug } from "@/lib/agentDebug"; -import { buildUserInputSubmitOp } from "../utils/buildUserInputSubmitOp"; +import { runAgentStreamSubmitLifecycle } from "./agentStreamSubmitLifecycleController"; +import { buildAgentStreamSubmitOp } from "./agentStreamSubmitOpController"; import { resolveAgentStreamSubmitContext } from "./agentStreamSubmitContext"; import { registerAgentStreamTurnEventBinding } from "./agentStreamTurnEventBinding"; -import { - extractAgentUiPerformanceTraceMetadata, - recordAgentStreamPerformanceMetric, -} from "./agentStreamPerformanceMetrics"; +import { extractAgentUiPerformanceTraceMetadata } from "./agentStreamPerformanceMetrics"; type MessageParts = NonNullable; @@ -253,109 +250,38 @@ export async function executeAgentStreamSubmit( callbacks.registerListener(unlisten); - requestState.submissionDispatchedAt = Date.now(); - recordAgentStreamPerformanceMetric( - "agentStream.submitDispatched", - performanceTrace, - { - elapsedMs: - requestState.submissionDispatchedAt - requestState.requestStartedAt, - eventName, - expectingQueue, - listenerBoundDeltaMs: requestState.listenerBoundAt - ? requestState.submissionDispatchedAt - requestState.listenerBoundAt - : null, - model: effectiveModel, - provider: effectiveProviderType, - sessionId: activeSessionId, - }, - ); - logAgentDebug("AgentStream", "submitDispatched", { - elapsedMs: - requestState.submissionDispatchedAt - requestState.requestStartedAt, + await runAgentStreamSubmitLifecycle({ + activeSessionId, + effectiveModel, + effectiveProviderType, eventName, expectingQueue, - listenerBoundDeltaMs: requestState.listenerBoundAt - ? requestState.submissionDispatchedAt - requestState.listenerBoundAt - : null, - sessionId: activeSessionId, + requestState, + submit: () => + runtime.submitOp( + buildAgentStreamSubmitOp({ + content, + images, + activeSessionId, + eventName, + submitWorkspaceId, + requestTurnId, + systemPrompt, + skipPreSubmitResume, + requestMetadata, + executionRuntime, + syncedRecentPreferences, + syncedSessionModelPreference, + syncedExecutionStrategy, + effectiveExecutionStrategy, + effectiveAccessMode, + effectiveProviderType, + effectiveModel, + modelOverride, + webSearch, + thinking, + autoContinue, + }), + ), }); - - try { - await runtime.submitOp( - buildUserInputSubmitOp({ - content, - images, - sessionId: activeSessionId, - eventName, - workspaceId: submitWorkspaceId, - turnId: requestTurnId, - systemPrompt, - queueIfBusy: true, - skipPreSubmitResume, - requestMetadata, - executionRuntime, - syncedRecentPreferences, - syncedSessionModelPreference, - syncedExecutionStrategy, - effectiveExecutionStrategy, - effectiveAccessMode, - effectiveProviderType, - effectiveModel, - modelOverride, - webSearch, - thinking, - autoContinue, - }), - ); - recordAgentStreamPerformanceMetric( - "agentStream.submitAccepted", - performanceTrace, - { - elapsedMs: Date.now() - requestState.requestStartedAt, - eventName, - sessionId: activeSessionId, - submitInvokeMs: requestState.submissionDispatchedAt - ? Date.now() - requestState.submissionDispatchedAt - : null, - }, - ); - logAgentDebug("AgentStream", "submitAccepted", { - elapsedMs: Date.now() - requestState.requestStartedAt, - eventName, - sessionId: activeSessionId, - submitInvokeMs: requestState.submissionDispatchedAt - ? Date.now() - requestState.submissionDispatchedAt - : null, - }); - } catch (error) { - recordAgentStreamPerformanceMetric( - "agentStream.submitFailed", - performanceTrace, - { - elapsedMs: Date.now() - requestState.requestStartedAt, - error: error instanceof Error ? error.message : String(error), - eventName, - sessionId: activeSessionId, - submitInvokeMs: requestState.submissionDispatchedAt - ? Date.now() - requestState.submissionDispatchedAt - : null, - }, - ); - logAgentDebug( - "AgentStream", - "submitFailed", - { - elapsedMs: Date.now() - requestState.requestStartedAt, - error, - eventName, - sessionId: activeSessionId, - submitInvokeMs: requestState.submissionDispatchedAt - ? Date.now() - requestState.submissionDispatchedAt - : null, - }, - { level: "error" }, - ); - throw error; - } } diff --git a/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts new file mode 100644 index 000000000..b3ab6e249 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; +import { runAgentStreamSubmitLifecycle } from "./agentStreamSubmitLifecycleController"; + +function createRequestState(): StreamRequestState { + return { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 100, + listenerBoundAt: 140, + requestFinished: false, + queuedTurnId: null, + performanceTrace: { + requestId: "request-a", + sessionId: "session-a", + workspaceId: "workspace-a", + source: "home-input", + submittedAt: 90, + }, + }; +} + +function createNow(values: number[]) { + return () => { + const value = values.shift(); + if (value === undefined) { + throw new Error("now sequence exhausted"); + } + return value; + }; +} + +describe("agentStreamSubmitLifecycleController", () => { + it("应记录 submit dispatched 与 accepted 生命周期", async () => { + const requestState = createRequestState(); + const submit = vi.fn(async () => {}); + const recordMetric = vi.fn(); + const logDebug = vi.fn(); + + await runAgentStreamSubmitLifecycle({ + activeSessionId: "session-a", + effectiveModel: "deepseek-chat", + effectiveProviderType: "deepseek", + eventName: "event-a", + expectingQueue: false, + requestState, + submit, + deps: { + now: createNow([175, 230]), + recordMetric, + logDebug, + }, + }); + + expect(requestState.submissionDispatchedAt).toBe(175); + expect(submit).toHaveBeenCalledTimes(1); + expect(recordMetric).toHaveBeenNthCalledWith( + 1, + "agentStream.submitDispatched", + requestState.performanceTrace, + { + elapsedMs: 75, + eventName: "event-a", + expectingQueue: false, + listenerBoundDeltaMs: 35, + model: "deepseek-chat", + provider: "deepseek", + sessionId: "session-a", + }, + ); + expect(recordMetric).toHaveBeenNthCalledWith( + 2, + "agentStream.submitAccepted", + requestState.performanceTrace, + { + elapsedMs: 130, + eventName: "event-a", + sessionId: "session-a", + submitInvokeMs: 55, + }, + ); + expect(logDebug).toHaveBeenNthCalledWith( + 1, + "AgentStream", + "submitDispatched", + expect.objectContaining({ eventName: "event-a" }), + ); + expect(logDebug).toHaveBeenNthCalledWith( + 2, + "AgentStream", + "submitAccepted", + expect.objectContaining({ submitInvokeMs: 55 }), + ); + }); + + it("submit 失败时应记录 failed 并继续抛出原始错误", async () => { + const requestState = createRequestState(); + const error = new Error("bridge timeout"); + const submit = vi.fn(async () => { + throw error; + }); + const recordMetric = vi.fn(); + const logDebug = vi.fn(); + + await expect( + runAgentStreamSubmitLifecycle({ + activeSessionId: "session-a", + effectiveModel: "gpt-5.4", + effectiveProviderType: "openai", + eventName: "event-a", + expectingQueue: true, + requestState, + submit, + deps: { + now: createNow([180, 260]), + recordMetric, + logDebug, + }, + }), + ).rejects.toBe(error); + + expect(requestState.submissionDispatchedAt).toBe(180); + expect(recordMetric).toHaveBeenNthCalledWith( + 2, + "agentStream.submitFailed", + requestState.performanceTrace, + { + elapsedMs: 160, + error: "bridge timeout", + eventName: "event-a", + sessionId: "session-a", + submitInvokeMs: 80, + }, + ); + expect(logDebug).toHaveBeenNthCalledWith( + 2, + "AgentStream", + "submitFailed", + { + elapsedMs: 160, + error, + eventName: "event-a", + sessionId: "session-a", + submitInvokeMs: 80, + }, + { level: "error" }, + ); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts new file mode 100644 index 000000000..347994a80 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts @@ -0,0 +1,122 @@ +import { logAgentDebug } from "@/lib/agentDebug"; +import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; +import { + recordAgentStreamPerformanceMetric, + type AgentUiPerformanceTraceMetadata, +} from "./agentStreamPerformanceMetrics"; +import { + buildAgentStreamSubmitAcceptedContext, + buildAgentStreamSubmitDispatchedContext, + buildAgentStreamSubmitFailedContext, + buildAgentStreamSubmitFailedLogContext, +} from "./agentStreamSubmissionController"; + +type AgentStreamSubmitMetricRecorder = ( + phase: string, + trace: AgentUiPerformanceTraceMetadata | null | undefined, + context: Record, +) => unknown; + +type AgentStreamSubmitDebugLogger = typeof logAgentDebug; + +export interface AgentStreamSubmitLifecycleDeps { + logDebug?: AgentStreamSubmitDebugLogger; + now?: () => number; + recordMetric?: AgentStreamSubmitMetricRecorder; +} + +export interface RunAgentStreamSubmitLifecycleOptions { + activeSessionId: string; + effectiveModel: string; + effectiveProviderType: string; + eventName: string; + expectingQueue: boolean; + requestState: StreamRequestState; + submit: () => Promise; + deps?: AgentStreamSubmitLifecycleDeps; +} + +export async function runAgentStreamSubmitLifecycle( + options: RunAgentStreamSubmitLifecycleOptions, +): Promise { + const { + activeSessionId, + effectiveModel, + effectiveProviderType, + eventName, + expectingQueue, + requestState, + submit, + deps, + } = options; + const now = deps?.now ?? Date.now; + const recordMetric = deps?.recordMetric ?? recordAgentStreamPerformanceMetric; + const logDebug = deps?.logDebug ?? logAgentDebug; + + requestState.submissionDispatchedAt = now(); + const submitDispatchedContext = buildAgentStreamSubmitDispatchedContext({ + activeSessionId, + effectiveModel, + effectiveProviderType, + eventName, + expectingQueue, + timing: { + listenerBoundAt: requestState.listenerBoundAt, + now: requestState.submissionDispatchedAt, + requestStartedAt: requestState.requestStartedAt, + }, + }); + recordMetric( + "agentStream.submitDispatched", + requestState.performanceTrace, + submitDispatchedContext, + ); + logDebug("AgentStream", "submitDispatched", submitDispatchedContext); + + try { + await submit(); + const submitAcceptedContext = buildAgentStreamSubmitAcceptedContext({ + activeSessionId, + eventName, + timing: { + now: now(), + requestStartedAt: requestState.requestStartedAt, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }, + }); + recordMetric( + "agentStream.submitAccepted", + requestState.performanceTrace, + submitAcceptedContext, + ); + logDebug("AgentStream", "submitAccepted", submitAcceptedContext); + } catch (error) { + const failedTiming = { + now: now(), + requestStartedAt: requestState.requestStartedAt, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }; + recordMetric( + "agentStream.submitFailed", + requestState.performanceTrace, + buildAgentStreamSubmitFailedContext({ + activeSessionId, + error, + eventName, + timing: failedTiming, + }), + ); + logDebug( + "AgentStream", + "submitFailed", + buildAgentStreamSubmitFailedLogContext({ + activeSessionId, + error, + eventName, + timing: failedTiming, + }), + { level: "error" }, + ); + throw error; + } +} diff --git a/src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts new file mode 100644 index 000000000..5b0cff48f --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmitOpController.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { buildUserInputSubmitOp } from "../utils/buildUserInputSubmitOp"; +import { buildAgentStreamSubmitOp } from "./agentStreamSubmitOpController"; + +describe("agentStreamSubmitOpController", () => { + it("应按 stream submit 语义构造 runtime submitOp,并默认允许 busy queue", () => { + const op = buildAgentStreamSubmitOp({ + activeSessionId: "session-fast-1", + content: "只回答一个字:好", + images: [], + eventName: "aster_stream_fast", + submitWorkspaceId: "workspace-1", + requestTurnId: "turn-fast-1", + skipPreSubmitResume: true, + effectiveExecutionStrategy: "react", + effectiveAccessMode: "current", + effectiveProviderType: "deepseek", + effectiveModel: "deepseek-chat", + }); + + expect(op).toEqual({ + type: "user_input", + text: "只回答一个字:好", + sessionId: "session-fast-1", + eventName: "aster_stream_fast", + workspaceId: "workspace-1", + turnId: "turn-fast-1", + images: undefined, + preferences: { + providerPreference: "deepseek", + modelPreference: "deepseek-chat", + thinking: undefined, + approvalPolicy: "on-request", + sandboxPolicy: "workspace-write", + executionStrategy: "react", + webSearch: undefined, + autoContinue: undefined, + }, + systemPrompt: undefined, + metadata: undefined, + queueIfBusy: true, + skipPreSubmitResume: true, + }); + }); + + it("应与底层 user_input builder 保持 payload 等价", () => { + const streamOp = buildAgentStreamSubmitOp({ + activeSessionId: "session-social-1", + content: "继续生成社媒初稿", + images: [ + { + data: "base64-image", + mediaType: "image/png", + }, + ], + eventName: "aster_stream_x", + submitWorkspaceId: undefined, + requestTurnId: "turn-1", + systemPrompt: "system", + requestMetadata: { + harness: { + preferences: { + web_search: false, + thinking: true, + }, + theme: "general", + session_mode: "general_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: "general", + recent_session_mode: "general_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", + effectiveAccessMode: "current", + effectiveProviderType: "openai", + effectiveModel: "gpt-4.1", + webSearch: false, + thinking: true, + autoContinue: { + enabled: true, + fast_mode_enabled: true, + continuation_length: 1, + sensitivity: 0.25, + }, + }); + + const directOp = buildUserInputSubmitOp({ + content: "继续生成社媒初稿", + images: [ + { + data: "base64-image", + mediaType: "image/png", + }, + ], + sessionId: "session-social-1", + eventName: "aster_stream_x", + workspaceId: undefined, + turnId: "turn-1", + systemPrompt: "system", + queueIfBusy: true, + requestMetadata: { + harness: { + preferences: { + web_search: false, + thinking: true, + }, + theme: "general", + session_mode: "general_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: "general", + recent_session_mode: "general_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", + effectiveAccessMode: "current", + effectiveProviderType: "openai", + effectiveModel: "gpt-4.1", + webSearch: false, + thinking: true, + autoContinue: { + enabled: true, + fast_mode_enabled: true, + continuation_length: 1, + sensitivity: 0.25, + }, + }); + + expect(streamOp).toEqual(directOp); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamSubmitOpController.ts b/src/components/agent/chat/hooks/agentStreamSubmitOpController.ts new file mode 100644 index 000000000..0b6a51b4b --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmitOpController.ts @@ -0,0 +1,34 @@ +import type { AgentUserInputOp } from "@/lib/api/agentProtocol"; +import type { BuildUserInputSubmitOpOptions } from "../utils/buildUserInputSubmitOp"; +import { buildUserInputSubmitOp } from "../utils/buildUserInputSubmitOp"; + +type AgentStreamSubmitOpBaseOptions = Omit< + BuildUserInputSubmitOpOptions, + "queueIfBusy" | "sessionId" | "turnId" | "workspaceId" +>; + +export interface BuildAgentStreamSubmitOpOptions + extends AgentStreamSubmitOpBaseOptions { + activeSessionId: string; + requestTurnId: string; + submitWorkspaceId?: string; +} + +export function buildAgentStreamSubmitOp( + options: BuildAgentStreamSubmitOpOptions, +): AgentUserInputOp { + const { + activeSessionId, + requestTurnId, + submitWorkspaceId, + ...submitOpOptions + } = options; + + return buildUserInputSubmitOp({ + ...submitOpOptions, + sessionId: activeSessionId, + workspaceId: submitWorkspaceId, + turnId: requestTurnId, + queueIfBusy: true, + }); +} diff --git a/src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts b/src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts new file mode 100644 index 000000000..5a40bc7d4 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTextDeltaController.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentStreamTextDeltaApplyPlan } from "./agentStreamTextDeltaController"; + +describe("agentStreamTextDeltaController", () => { + it("应构造首个 text delta 的累积内容、buffer 计数与指标上下文", () => { + expect( + buildAgentStreamTextDeltaApplyPlan({ + activeSessionId: "session-a", + accumulatedContent: "", + deltaText: "你好", + eventName: "event-a", + firstEventReceivedAt: 140, + firstRuntimeStatusAt: 180, + firstTextDeltaAt: null, + now: 240, + requestStartedAt: 100, + textDeltaBufferedCount: undefined, + }), + ).toEqual({ + firstTextDeltaAt: 240, + firstTextDeltaContext: { + deltaChars: 2, + elapsedMs: 140, + eventName: "event-a", + firstEventDeltaMs: 100, + firstRuntimeStatusDeltaMs: 60, + sessionId: "session-a", + }, + nextAccumulatedContent: "你好", + nextBufferedCount: 1, + }); + }); + + it("非首个 text delta 不应重复生成首 delta 指标", () => { + expect( + buildAgentStreamTextDeltaApplyPlan({ + activeSessionId: "session-a", + accumulatedContent: "你好", + deltaText: ",世界", + eventName: "event-a", + firstTextDeltaAt: 240, + now: 280, + requestStartedAt: 100, + textDeltaBufferedCount: 2, + }), + ).toMatchObject({ + firstTextDeltaAt: null, + firstTextDeltaContext: null, + nextAccumulatedContent: "你好,世界", + nextBufferedCount: 3, + }); + }); + + it("应使用 overlap detection 避免重复吐字", () => { + expect( + buildAgentStreamTextDeltaApplyPlan({ + activeSessionId: "session-a", + accumulatedContent: "你好,世", + deltaText: "世界", + eventName: "event-a", + firstTextDeltaAt: 240, + now: 300, + requestStartedAt: 100, + textDeltaBufferedCount: 1, + }).nextAccumulatedContent, + ).toBe("你好,世界"); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamTextDeltaController.ts b/src/components/agent/chat/hooks/agentStreamTextDeltaController.ts new file mode 100644 index 000000000..9a853b7b8 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTextDeltaController.ts @@ -0,0 +1,50 @@ +import { appendTextWithOverlapDetection } from "./agentChatHistory"; +import { + buildAgentStreamFirstTextDeltaMetricContext, + shouldRecordAgentStreamFirstTextDelta, +} from "./agentStreamRuntimeMetricsController"; + +export interface AgentStreamTextDeltaApplyPlan { + firstTextDeltaAt: number | null; + firstTextDeltaContext: Record | null; + nextAccumulatedContent: string; + nextBufferedCount: number; +} + +export function buildAgentStreamTextDeltaApplyPlan(params: { + activeSessionId: string; + accumulatedContent: string; + deltaText: string; + eventName: string; + firstEventReceivedAt?: number | null; + firstRuntimeStatusAt?: number | null; + firstTextDeltaAt?: number | null; + now: number; + requestStartedAt: number; + textDeltaBufferedCount?: number | null; +}): AgentStreamTextDeltaApplyPlan { + const shouldRecordFirstTextDelta = shouldRecordAgentStreamFirstTextDelta({ + firstTextDeltaAt: params.firstTextDeltaAt, + }); + const firstTextDeltaAt = shouldRecordFirstTextDelta ? params.now : null; + + return { + firstTextDeltaAt, + firstTextDeltaContext: firstTextDeltaAt + ? buildAgentStreamFirstTextDeltaMetricContext({ + activeSessionId: params.activeSessionId, + deltaText: params.deltaText, + eventName: params.eventName, + firstEventReceivedAt: params.firstEventReceivedAt, + firstRuntimeStatusAt: params.firstRuntimeStatusAt, + firstTextDeltaAt, + requestStartedAt: params.requestStartedAt, + }) + : null, + nextAccumulatedContent: appendTextWithOverlapDetection( + params.accumulatedContent, + params.deltaText, + ), + nextBufferedCount: (params.textDeltaBufferedCount ?? 0) + 1, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts b/src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts new file mode 100644 index 000000000..de605cafc --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTextRenderFlushController.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamFirstTextPaintContext, + buildAgentStreamTextRenderFlushPlan, + resolveAgentStreamPendingRenderedTextDelta, + shouldFlushAgentStreamVisibleFirstText, + shouldScheduleAgentStreamTextRenderTimer, +} from "./agentStreamTextRenderFlushController"; + +describe("agentStreamTextRenderFlushController", () => { + it("应解析本次待渲染 text delta", () => { + expect( + resolveAgentStreamPendingRenderedTextDelta({ + renderedContent: "你好", + accumulatedContent: "你好,世界", + }), + ).toBe(",世界"); + expect( + resolveAgentStreamPendingRenderedTextDelta({ + renderedContent: "旧文本", + accumulatedContent: "新文本", + }), + ).toBe("新文本"); + }); + + it("应判断首个可见文本立即 flush 和 timer 调度", () => { + expect( + shouldFlushAgentStreamVisibleFirstText({ + renderedContent: "", + accumulatedContent: " 好 ", + }), + ).toBe(true); + expect( + shouldFlushAgentStreamVisibleFirstText({ + renderedContent: "好", + accumulatedContent: "好啊", + }), + ).toBe(false); + expect(shouldScheduleAgentStreamTextRenderTimer({ hasPendingTimer: false })) + .toBe(true); + expect(shouldScheduleAgentStreamTextRenderTimer({ hasPendingTimer: true })) + .toBe(false); + }); + + it("应构造首个 render flush 计划和 first paint 调度", () => { + expect( + buildAgentStreamTextRenderFlushPlan({ + activeSessionId: "session-a", + eventName: "event-a", + firstTextDeltaAt: 180, + firstTextPaintAt: null, + firstTextPaintScheduled: false, + firstTextRenderFlushAt: null, + flushStartedAt: 220, + maxTextDeltaBacklogChars: 0, + nextContent: "你好", + renderedContent: "", + requestStartedAt: 100, + textDeltaFlushCount: 0, + }), + ).toEqual({ + backlogChars: 2, + firstTextRenderFlushAt: 220, + firstTextRenderFlushContext: { + elapsedMs: 120, + eventName: "event-a", + firstTextDeltaDeltaMs: 40, + sessionId: "session-a", + }, + flushLogContext: { + accumulatedChars: 2, + backlogChars: 2, + elapsedMs: 120, + eventName: "event-a", + flushCount: 1, + maxBacklogChars: 2, + sessionId: "session-a", + }, + flushLogDedupeKey: "AgentStream:textRenderFlush:event-a:1", + nextLastTextRenderFlushAt: 220, + nextMaxTextDeltaBacklogChars: 2, + nextRenderedContent: "你好", + nextTextDeltaFlushCount: 1, + shouldLogFlush: true, + shouldScheduleFirstTextPaint: true, + textDelta: "你好", + }); + }); + + it("内容未变化时不应构造 flush 计划,非首 flush 不重复 first flush", () => { + expect( + buildAgentStreamTextRenderFlushPlan({ + activeSessionId: "session-a", + eventName: "event-a", + flushStartedAt: 220, + nextContent: "你好", + renderedContent: "你好", + requestStartedAt: 100, + }), + ).toBeNull(); + + expect( + buildAgentStreamTextRenderFlushPlan({ + activeSessionId: "session-a", + eventName: "event-a", + firstTextPaintAt: 260, + firstTextRenderFlushAt: 220, + flushStartedAt: 300, + maxTextDeltaBacklogChars: 2, + nextContent: "你好,世界", + renderedContent: "你好", + requestStartedAt: 100, + textDeltaFlushCount: 1, + }), + ).toMatchObject({ + firstTextRenderFlushAt: null, + firstTextRenderFlushContext: null, + nextTextDeltaFlushCount: 2, + shouldLogFlush: false, + shouldScheduleFirstTextPaint: false, + textDelta: ",世界", + }); + }); + + it("应构造 first text paint 指标上下文", () => { + expect( + buildAgentStreamFirstTextPaintContext({ + activeSessionId: "session-a", + eventName: "event-a", + firstTextDeltaAt: 180, + flushStartedAt: 220, + paintedAt: 260, + requestStartedAt: 100, + }), + ).toEqual({ + elapsedMs: 160, + eventName: "event-a", + firstTextDeltaDeltaMs: 80, + renderFlushDeltaMs: 40, + sessionId: "session-a", + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamTextRenderFlushController.ts b/src/components/agent/chat/hooks/agentStreamTextRenderFlushController.ts new file mode 100644 index 000000000..c96683f60 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTextRenderFlushController.ts @@ -0,0 +1,133 @@ +export interface AgentStreamTextRenderFlushPlan { + backlogChars: number; + firstTextRenderFlushAt: number | null; + firstTextRenderFlushContext: Record | null; + flushLogContext: Record; + flushLogDedupeKey: string; + nextLastTextRenderFlushAt: number; + nextMaxTextDeltaBacklogChars: number; + nextRenderedContent: string; + nextTextDeltaFlushCount: number; + shouldLogFlush: boolean; + shouldScheduleFirstTextPaint: boolean; + textDelta: string; +} + +export function resolveAgentStreamPendingRenderedTextDelta(params: { + accumulatedContent: string; + renderedContent: string; +}): string { + if (!params.renderedContent) { + return params.accumulatedContent; + } + if (params.accumulatedContent.startsWith(params.renderedContent)) { + return params.accumulatedContent.slice(params.renderedContent.length); + } + return params.accumulatedContent; +} + +export function shouldFlushAgentStreamVisibleFirstText(params: { + accumulatedContent: string; + renderedContent: string; +}): boolean { + return ( + !params.renderedContent && params.accumulatedContent.trim().length > 0 + ); +} + +export function shouldScheduleAgentStreamTextRenderTimer(params: { + hasPendingTimer: boolean; +}): boolean { + return !params.hasPendingTimer; +} + +export function buildAgentStreamTextRenderFlushPlan(params: { + activeSessionId: string; + eventName: string; + firstTextDeltaAt?: number | null; + firstTextPaintAt?: number | null; + firstTextPaintScheduled?: boolean | null; + firstTextRenderFlushAt?: number | null; + flushStartedAt: number; + maxTextDeltaBacklogChars?: number | null; + nextContent: string; + renderedContent: string; + requestStartedAt: number; + textDeltaFlushCount?: number | null; +}): AgentStreamTextRenderFlushPlan | null { + if (params.nextContent === params.renderedContent) { + return null; + } + + const nextTextDeltaFlushCount = (params.textDeltaFlushCount ?? 0) + 1; + const backlogChars = Math.max( + 0, + params.nextContent.length - params.renderedContent.length, + ); + const nextMaxTextDeltaBacklogChars = Math.max( + params.maxTextDeltaBacklogChars ?? 0, + backlogChars, + ); + const shouldRecordFirstFlush = !params.firstTextRenderFlushAt; + const shouldScheduleFirstTextPaint = + !params.firstTextPaintAt && + !params.firstTextPaintScheduled && + params.nextContent.trim().length > 0; + const flushLogContext = { + accumulatedChars: params.nextContent.length, + backlogChars, + elapsedMs: params.flushStartedAt - params.requestStartedAt, + eventName: params.eventName, + flushCount: nextTextDeltaFlushCount, + maxBacklogChars: nextMaxTextDeltaBacklogChars, + sessionId: params.activeSessionId, + }; + + return { + backlogChars, + firstTextRenderFlushAt: shouldRecordFirstFlush + ? params.flushStartedAt + : null, + firstTextRenderFlushContext: shouldRecordFirstFlush + ? { + elapsedMs: params.flushStartedAt - params.requestStartedAt, + eventName: params.eventName, + firstTextDeltaDeltaMs: params.firstTextDeltaAt + ? params.flushStartedAt - params.firstTextDeltaAt + : null, + sessionId: params.activeSessionId, + } + : null, + flushLogContext, + flushLogDedupeKey: `AgentStream:textRenderFlush:${params.eventName}:${nextTextDeltaFlushCount}`, + nextLastTextRenderFlushAt: params.flushStartedAt, + nextMaxTextDeltaBacklogChars, + nextRenderedContent: params.nextContent, + nextTextDeltaFlushCount, + shouldLogFlush: backlogChars >= 80 || nextTextDeltaFlushCount === 1, + shouldScheduleFirstTextPaint, + textDelta: resolveAgentStreamPendingRenderedTextDelta({ + accumulatedContent: params.nextContent, + renderedContent: params.renderedContent, + }), + }; +} + +export function buildAgentStreamFirstTextPaintContext(params: { + activeSessionId: string; + eventName: string; + firstTextDeltaAt?: number | null; + flushStartedAt: number; + paintedAt: number; + requestStartedAt: number; +}): Record { + return { + elapsedMs: params.paintedAt - params.requestStartedAt, + eventName: params.eventName, + firstTextDeltaDeltaMs: params.firstTextDeltaAt + ? params.paintedAt - params.firstTextDeltaAt + : null, + renderFlushDeltaMs: params.paintedAt - params.flushStartedAt, + sessionId: params.activeSessionId, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts b/src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts new file mode 100644 index 000000000..53d8b4b1f --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamThinkingDeltaController.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "../types"; +import { + buildAgentStreamThinkingDeltaMessagePatch, + buildAgentStreamThinkingDeltaPreApplyPlan, + type AgentStreamThinkingPartsAppender, +} from "./agentStreamThinkingDeltaController"; + +const appendThinkingToParts: AgentStreamThinkingPartsAppender = ( + parts, + textDelta, +) => [...parts, { type: "thinking", text: textDelta }]; + +describe("agentStreamThinkingDeltaController", () => { + it("应构造 thinking delta 前置计划", () => { + expect( + buildAgentStreamThinkingDeltaPreApplyPlan({ + surfaceThinkingDeltas: true, + }), + ).toEqual({ + shouldActivateStream: true, + shouldApplyThinkingDelta: true, + }); + expect( + buildAgentStreamThinkingDeltaPreApplyPlan({ + surfaceThinkingDeltas: false, + }), + ).toEqual({ + shouldActivateStream: true, + shouldApplyThinkingDelta: false, + }); + }); + + it("应构造 thinking 消息 patch 并做 overlap append", () => { + expect( + buildAgentStreamThinkingDeltaMessagePatch({ + appendThinkingToParts, + contentParts: [{ type: "text", text: "正文" }], + textDelta: "世界", + thinkingContent: "你好,世", + }), + ).toEqual({ + isThinking: true, + thinkingContent: "你好,世界", + contentParts: [ + { type: "text", text: "正文" }, + { type: "thinking", text: "世界" }, + ], + }); + }); + + it("无既有 contentParts 时应从空数组追加 thinking part", () => { + const patch = buildAgentStreamThinkingDeltaMessagePatch({ + appendThinkingToParts, + textDelta: "推理", + }); + + expect(patch).toEqual({ + isThinking: true, + thinkingContent: "推理", + contentParts: [{ type: "thinking", text: "推理" }], + } satisfies Pick< + Message, + "contentParts" | "isThinking" | "thinkingContent" + >); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts b/src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts new file mode 100644 index 000000000..c1d5a1cc4 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamThinkingDeltaController.ts @@ -0,0 +1,42 @@ +import type { Message } from "../types"; +import { appendTextWithOverlapDetection } from "./agentChatHistory"; + +type MessageParts = NonNullable; + +export type AgentStreamThinkingPartsAppender = ( + parts: MessageParts, + textDelta: string, +) => MessageParts; + +export interface AgentStreamThinkingDeltaPreApplyPlan { + shouldActivateStream: boolean; + shouldApplyThinkingDelta: boolean; +} + +export function buildAgentStreamThinkingDeltaPreApplyPlan(params: { + surfaceThinkingDeltas: boolean; +}): AgentStreamThinkingDeltaPreApplyPlan { + return { + shouldActivateStream: true, + shouldApplyThinkingDelta: params.surfaceThinkingDeltas, + }; +} + +export function buildAgentStreamThinkingDeltaMessagePatch(params: { + appendThinkingToParts: AgentStreamThinkingPartsAppender; + contentParts?: Message["contentParts"]; + textDelta: string; + thinkingContent?: string; +}): Pick { + return { + isThinking: true, + thinkingContent: appendTextWithOverlapDetection( + params.thinkingContent || "", + params.textDelta, + ), + contentParts: params.appendThinkingToParts( + params.contentParts || [], + params.textDelta, + ), + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts b/src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts new file mode 100644 index 000000000..8bc1512fa --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamThreadItemController.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { + AgentThreadItem, + AgentThreadTurn, +} from "@/lib/api/agentProtocol"; +import { + buildAgentStreamTurnStartedPendingItemUpdate, + shouldDeferAgentStreamThreadItemUpdate, +} from "./agentStreamThreadItemController"; + +function threadItem( + overrides: Partial = {}, +): AgentThreadItem { + return { + id: "item-a", + thread_id: "thread-old", + turn_id: "turn-old", + sequence: 1, + status: "in_progress", + started_at: "2026-05-05T00:00:00.000Z", + updated_at: "2026-05-05T00:00:01.000Z", + type: "reasoning", + text: "thinking", + ...overrides, + } as AgentThreadItem; +} + +const turn: AgentThreadTurn = { + id: "turn-new", + thread_id: "thread-new", + prompt_text: "hello", + status: "running", + started_at: "2026-05-05T00:00:02.000Z", + created_at: "2026-05-05T00:00:02.000Z", + updated_at: "2026-05-05T00:00:03.000Z", +}; + +describe("agentStreamThreadItemController", () => { + it("应延后 in-progress reasoning 与 agent_message 高频更新", () => { + expect(shouldDeferAgentStreamThreadItemUpdate(threadItem())).toBe(true); + expect( + shouldDeferAgentStreamThreadItemUpdate( + threadItem({ type: "agent_message", text: "hello" }), + ), + ).toBe(true); + }); + + it("非 in-progress 或非文本类 item 不应延后", () => { + expect( + shouldDeferAgentStreamThreadItemUpdate( + threadItem({ status: "completed" }), + ), + ).toBe(false); + expect( + shouldDeferAgentStreamThreadItemUpdate( + threadItem({ + type: "tool_call", + tool_name: "Read", + status: "in_progress", + }), + ), + ).toBe(false); + }); + + it("应把 pending item 绑定到真实 turn,并优先使用 turn.updated_at", () => { + expect( + buildAgentStreamTurnStartedPendingItemUpdate({ + pendingItem: threadItem(), + turn, + }), + ).toMatchObject({ + id: "item-a", + thread_id: "thread-new", + turn_id: "turn-new", + updated_at: "2026-05-05T00:00:03.000Z", + }); + }); + + it("无 pending item 时不应构造更新", () => { + expect( + buildAgentStreamTurnStartedPendingItemUpdate({ + pendingItem: null, + turn, + }), + ).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamThreadItemController.ts b/src/components/agent/chat/hooks/agentStreamThreadItemController.ts new file mode 100644 index 000000000..0c6833ec6 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamThreadItemController.ts @@ -0,0 +1,32 @@ +import type { + AgentThreadItem, + AgentThreadTurn, +} from "@/lib/api/agentProtocol"; + +export function shouldDeferAgentStreamThreadItemUpdate( + item: AgentThreadItem, +): boolean { + return ( + item.status === "in_progress" && + (item.type === "reasoning" || item.type === "agent_message") + ); +} + +export function buildAgentStreamTurnStartedPendingItemUpdate(params: { + pendingItem?: AgentThreadItem | null; + turn: AgentThreadTurn; +}): AgentThreadItem | null { + if (!params.pendingItem) { + return null; + } + + return { + ...params.pendingItem, + thread_id: params.turn.thread_id, + turn_id: params.turn.id, + updated_at: + params.turn.updated_at || + params.turn.started_at || + params.pendingItem.updated_at, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamTimerController.test.ts b/src/components/agent/chat/hooks/agentStreamTimerController.test.ts new file mode 100644 index 000000000..86374aeb8 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTimerController.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_STREAM_QUEUED_DRAFT_CLEANUP_GRACE_MS, + AGENT_STREAM_TEXT_DELTA_RENDER_FLUSH_MS, + buildAgentStreamQueuedDraftCleanupTimerFirePlan, + buildAgentStreamQueuedDraftCleanupTimerSchedulePlan, + buildAgentStreamTextRenderTimerSchedulePlan, + buildAgentStreamTimerClearPlan, +} from "./agentStreamTimerController"; + +describe("agentStreamTimerController", () => { + it("应根据 timer 是否存在构造 clear 计划", () => { + expect(buildAgentStreamTimerClearPlan({ hasTimer: false })).toEqual({ + shouldClearTimer: false, + nextTimerId: null, + }); + expect(buildAgentStreamTimerClearPlan({ hasTimer: true })).toEqual({ + shouldClearTimer: true, + nextTimerId: null, + }); + }); + + it("首个可见文本应立即 flush,不等待 32ms timer", () => { + expect( + buildAgentStreamTextRenderTimerSchedulePlan({ + accumulatedContent: "你好", + renderedContent: "", + hasPendingTimer: false, + }), + ).toEqual({ + action: "flush_now", + delayMs: null, + }); + }); + + it("已有 text render timer 时不应重复调度", () => { + expect( + buildAgentStreamTextRenderTimerSchedulePlan({ + accumulatedContent: "你好,继续", + renderedContent: "你好", + hasPendingTimer: true, + }), + ).toEqual({ + action: "skip", + delayMs: null, + }); + }); + + it("非首个可见文本且无 pending timer 时应调度低频 flush", () => { + expect( + buildAgentStreamTextRenderTimerSchedulePlan({ + accumulatedContent: "你好,继续", + renderedContent: "你好", + hasPendingTimer: false, + }), + ).toEqual({ + action: "schedule_timer", + delayMs: AGENT_STREAM_TEXT_DELTA_RENDER_FLUSH_MS, + }); + }); + + it("queued draft cleanup 调度前应清理旧 timer,并只在当前请求仍需观察且流未激活时调度", () => { + expect( + buildAgentStreamQueuedDraftCleanupTimerSchedulePlan({ + shouldWatchCurrentRequest: false, + streamActivated: false, + }), + ).toEqual({ + shouldClearExistingTimer: true, + shouldScheduleTimer: false, + delayMs: null, + }); + + expect( + buildAgentStreamQueuedDraftCleanupTimerSchedulePlan({ + shouldWatchCurrentRequest: true, + streamActivated: true, + }), + ).toEqual({ + shouldClearExistingTimer: true, + shouldScheduleTimer: false, + delayMs: null, + }); + + expect( + buildAgentStreamQueuedDraftCleanupTimerSchedulePlan({ + shouldWatchCurrentRequest: true, + streamActivated: false, + }), + ).toEqual({ + shouldClearExistingTimer: true, + shouldScheduleTimer: true, + delayMs: AGENT_STREAM_QUEUED_DRAFT_CLEANUP_GRACE_MS, + }); + }); + + it("queued draft cleanup timer 触发时应跳过已完成或已激活的请求", () => { + expect( + buildAgentStreamQueuedDraftCleanupTimerFirePlan({ + requestFinished: true, + streamActivated: false, + }), + ).toEqual({ shouldCleanup: false }); + + expect( + buildAgentStreamQueuedDraftCleanupTimerFirePlan({ + requestFinished: false, + streamActivated: true, + }), + ).toEqual({ shouldCleanup: false }); + + expect( + buildAgentStreamQueuedDraftCleanupTimerFirePlan({ + requestFinished: false, + streamActivated: false, + }), + ).toEqual({ shouldCleanup: true }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamTimerController.ts b/src/components/agent/chat/hooks/agentStreamTimerController.ts new file mode 100644 index 000000000..e4ed8c22c --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamTimerController.ts @@ -0,0 +1,100 @@ +import { + shouldFlushAgentStreamVisibleFirstText, + shouldScheduleAgentStreamTextRenderTimer, +} from "./agentStreamTextRenderFlushController"; + +export const AGENT_STREAM_QUEUED_DRAFT_CLEANUP_GRACE_MS = 1800; +export const AGENT_STREAM_TEXT_DELTA_RENDER_FLUSH_MS = 32; + +export interface AgentStreamTimerClearPlan { + shouldClearTimer: boolean; + nextTimerId: null; +} + +export type AgentStreamTextRenderTimerAction = + | "flush_now" + | "schedule_timer" + | "skip"; + +export interface AgentStreamTextRenderTimerSchedulePlan { + action: AgentStreamTextRenderTimerAction; + delayMs: number | null; +} + +export interface AgentStreamQueuedDraftCleanupTimerSchedulePlan { + shouldClearExistingTimer: boolean; + shouldScheduleTimer: boolean; + delayMs: number | null; +} + +export interface AgentStreamQueuedDraftCleanupTimerFirePlan { + shouldCleanup: boolean; +} + +export function buildAgentStreamTimerClearPlan(params: { + hasTimer: boolean; +}): AgentStreamTimerClearPlan { + return { + shouldClearTimer: params.hasTimer, + nextTimerId: null, + }; +} + +export function buildAgentStreamTextRenderTimerSchedulePlan(params: { + accumulatedContent: string; + hasPendingTimer: boolean; + renderedContent: string; +}): AgentStreamTextRenderTimerSchedulePlan { + if ( + shouldFlushAgentStreamVisibleFirstText({ + accumulatedContent: params.accumulatedContent, + renderedContent: params.renderedContent, + }) + ) { + return { + action: "flush_now", + delayMs: null, + }; + } + + if ( + !shouldScheduleAgentStreamTextRenderTimer({ + hasPendingTimer: params.hasPendingTimer, + }) + ) { + return { + action: "skip", + delayMs: null, + }; + } + + return { + action: "schedule_timer", + delayMs: AGENT_STREAM_TEXT_DELTA_RENDER_FLUSH_MS, + }; +} + +export function buildAgentStreamQueuedDraftCleanupTimerSchedulePlan(params: { + shouldWatchCurrentRequest: boolean; + streamActivated: boolean; +}): AgentStreamQueuedDraftCleanupTimerSchedulePlan { + const shouldScheduleTimer = + params.shouldWatchCurrentRequest && !params.streamActivated; + + return { + shouldClearExistingTimer: true, + shouldScheduleTimer, + delayMs: shouldScheduleTimer + ? AGENT_STREAM_QUEUED_DRAFT_CLEANUP_GRACE_MS + : null, + }; +} + +export function buildAgentStreamQueuedDraftCleanupTimerFirePlan(params: { + requestFinished: boolean; + streamActivated: boolean; +}): AgentStreamQueuedDraftCleanupTimerFirePlan { + return { + shouldCleanup: !params.requestFinished && !params.streamActivated, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts b/src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts new file mode 100644 index 000000000..51a5b8372 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { hasMeaningfulAgentStreamToolCompletionSignal } from "./agentStreamToolCompletionSignalController"; + +describe("agentStreamToolCompletionSignalController", () => { + it("应把站点保存 metadata 视为有意义完成信号", () => { + expect( + hasMeaningfulAgentStreamToolCompletionSignal({ + toolId: "site-tool", + toolName: "lime_site_run", + normalizedResult: { + metadata: { + saved_project_id: "project-a", + }, + }, + }), + ).toBe(true); + }); + + it("应把图片任务 metadata 视为有意义完成信号", () => { + expect( + hasMeaningfulAgentStreamToolCompletionSignal({ + toolId: "image-tool", + toolName: "lime_create_image_generation_task", + normalizedResult: { + metadata: { + task_id: "task-a", + task_type: "image_generate", + status: "running", + }, + }, + }), + ).toBe(true); + }); + + it("普通空结果不应视为有意义完成信号", () => { + expect( + hasMeaningfulAgentStreamToolCompletionSignal({ + toolId: "plain-tool", + toolName: "plain_tool", + normalizedResult: { + metadata: { + status: "ok", + }, + }, + }), + ).toBe(false); + expect( + hasMeaningfulAgentStreamToolCompletionSignal({ + toolId: "plain-tool", + toolName: "plain_tool", + normalizedResult: undefined, + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.ts b/src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.ts new file mode 100644 index 000000000..00288355c --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamToolCompletionSignalController.ts @@ -0,0 +1,42 @@ +import { hasMeaningfulSiteToolResultSignal } from "../utils/siteToolResultSummary"; +import { + buildImageTaskPreviewFromToolResult, + buildTaskPreviewFromToolResult, + buildToolResultArtifactFromToolResult, +} from "../utils/taskPreviewFromToolResult"; + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +export function hasMeaningfulAgentStreamToolCompletionSignal(params: { + toolId: string; + toolName: string; + normalizedResult: + | { + metadata?: unknown; + } + | undefined; +}): boolean { + const resultRecord = asRecord(params.normalizedResult); + if (hasMeaningfulSiteToolResultSignal(resultRecord?.metadata)) { + return true; + } + + const previewParams = { + toolId: params.toolId, + toolName: params.toolName, + toolArguments: undefined, + toolResult: resultRecord, + fallbackPrompt: "", + }; + + return Boolean( + buildImageTaskPreviewFromToolResult(previewParams) || + buildTaskPreviewFromToolResult(previewParams) || + buildToolResultArtifactFromToolResult(previewParams), + ); +} diff --git a/src/components/agent/chat/hooks/agentStreamToolEventController.test.ts b/src/components/agent/chat/hooks/agentStreamToolEventController.test.ts new file mode 100644 index 000000000..453e2df21 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamToolEventController.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { AgentToolExecutionResult } from "@/lib/api/agentProtocol"; +import { + LIME_TOOL_METADATA_BEGIN, + LIME_TOOL_METADATA_END, +} from "./agentChatCoreUtils"; +import { buildAgentStreamToolEndPreApplyPlan } from "./agentStreamToolEventController"; + +function toolResult( + overrides: Partial = {}, +): AgentToolExecutionResult { + return { + success: true, + output: "", + ...overrides, + }; +} + +describe("agentStreamToolEventController", () => { + it("应查找 tool name 并归一化 tool result", () => { + const toolNameByToolId = new Map([["tool-a", "lime_tool"]]); + const plan = buildAgentStreamToolEndPreApplyPlan({ + toolId: "tool-a", + toolNameByToolId, + result: toolResult({ + output: [ + "正文", + LIME_TOOL_METADATA_BEGIN, + "{\"task_id\":\"task-a\",\"task_type\":\"image_generate\"}", + LIME_TOOL_METADATA_END, + ].join("\n"), + }), + }); + + expect(plan.toolName).toBe("lime_tool"); + expect(plan.normalizedResult?.output).toBe("正文"); + expect(plan.normalizedResult?.metadata).toMatchObject({ + task_id: "task-a", + task_type: "image_generate", + }); + }); + + it("应在结果可展示为任务时标记 meaningful completion", () => { + const plan = buildAgentStreamToolEndPreApplyPlan({ + toolId: "tool-a", + toolNameByToolId: new Map([ + ["tool-a", "lime_create_image_generation_task"], + ]), + result: toolResult({ + metadata: { + task_id: "task-a", + task_type: "image_generate", + status: "running", + }, + }), + }); + + expect(plan.hasMeaningfulCompletionSignal).toBe(true); + }); + + it("普通 tool result 不应标记 meaningful completion", () => { + const plan = buildAgentStreamToolEndPreApplyPlan({ + toolId: "tool-a", + toolNameByToolId: new Map(), + result: toolResult({ metadata: { status: "ok" } }), + }); + + expect(plan.toolName).toBe(""); + expect(plan.hasMeaningfulCompletionSignal).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamToolEventController.ts b/src/components/agent/chat/hooks/agentStreamToolEventController.ts new file mode 100644 index 000000000..ce3813d41 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamToolEventController.ts @@ -0,0 +1,29 @@ +import type { AgentToolExecutionResult } from "@/lib/api/agentProtocol"; +import { normalizeIncomingToolResult } from "./agentChatToolResult"; +import { hasMeaningfulAgentStreamToolCompletionSignal } from "./agentStreamToolCompletionSignalController"; + +export interface AgentStreamToolEndPreApplyPlan { + hasMeaningfulCompletionSignal: boolean; + normalizedResult: AgentToolExecutionResult | undefined; + toolName: string; +} + +export function buildAgentStreamToolEndPreApplyPlan(params: { + result: AgentToolExecutionResult | null | undefined; + toolId: string; + toolNameByToolId: Map; +}): AgentStreamToolEndPreApplyPlan { + const normalizedResult = normalizeIncomingToolResult(params.result); + const toolName = params.toolNameByToolId.get(params.toolId) || ""; + + return { + hasMeaningfulCompletionSignal: + hasMeaningfulAgentStreamToolCompletionSignal({ + toolId: params.toolId, + toolName, + normalizedResult, + }), + normalizedResult, + toolName, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts index f191121b4..f5b94d478 100644 --- a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts +++ b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts @@ -12,21 +12,38 @@ import type { QueuedTurnSnapshot, } from "@/lib/api/agentRuntime"; import { logAgentDebug } from "@/lib/agentDebug"; -import { activityLogger } from "@/lib/workspace/workbenchRuntime"; import type { ActionRequired, Message } from "../types"; -import { mapProviderName } from "./agentChatCoreUtils"; import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler"; +import { + AGENT_STREAM_FIRST_EVENT_TIMEOUT_MESSAGE, + AGENT_STREAM_INACTIVITY_TIMEOUT_MESSAGE, + buildAgentStreamFirstEventDeferredWarning, + buildAgentStreamFirstEventSilentRecoveryWarning, + buildAgentStreamInactivitySilentRecoveryWarning, + resolveAgentStreamFirstEventTimeoutAction, + resolveAgentStreamInactivityTimeoutAction, +} from "./agentStreamInactivityController"; +import { + buildAgentStreamFirstEventContext, + buildAgentStreamFirstEventDeferredContext, + buildAgentStreamListenerBoundContext, + extractAgentStreamRuntimeEventType, + shouldDeferAgentStreamFirstEventTimeout, + shouldIgnoreAgentStreamInactivityResult, + shouldScheduleAgentStreamInactivityWatchdog, +} from "./agentStreamListenerReadinessController"; +import { startAgentStreamRequest } from "./agentStreamRequestStartController"; +import { + rememberAgentStreamUnknownEventWarning, + resolveAgentStreamUnknownEventPlan, +} from "./agentStreamUnknownEventController"; import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; import type { StreamRequestState } from "./agentStreamSubmissionLifecycle"; import { recordAgentStreamPerformanceMetric } from "./agentStreamPerformanceMetrics"; type MessageParts = NonNullable; const STREAM_FIRST_EVENT_TIMEOUT_MS = 12_000; -const STREAM_FIRST_EVENT_TIMEOUT_MESSAGE = - "执行已中断:运行时未返回任何进度事件,请重试。"; const STREAM_INACTIVITY_TIMEOUT_MS = 120_000; // 2 分钟,兼容推理模型长时间思考 -const STREAM_INACTIVITY_TIMEOUT_MESSAGE = - "执行已中断:运行时长时间没有返回新进度,请重试。"; interface StreamObserver { onTextDelta?: (delta: string, accumulated: string) => void; @@ -103,15 +120,6 @@ interface RegisterAgentStreamTurnEventBindingOptions { setIsSending: Dispatch>; } -function extractRuntimeEventType(payload: unknown): string | null { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { - return null; - } - - const type = (payload as { type?: unknown }).type; - return typeof type === "string" && type.trim() ? type : null; -} - export async function registerAgentStreamTurnEventBinding( options: RegisterAgentStreamTurnEventBindingOptions, ) { @@ -155,41 +163,19 @@ export async function registerAgentStreamTurnEventBinding( setIsSending, } = options; - requestState.requestStartedAt = Date.now(); - recordAgentStreamPerformanceMetric( - "agentStream.request.start", - requestState.performanceTrace, - { - contentLength: content.trim().length, - eventName, - expectingQueue, - model: effectiveModel, - provider: effectiveProviderType, - sessionId: activeSessionId, - skipUserMessage, - systemPromptLength: systemPrompt?.length ?? 0, - systemPromptPreview: systemPrompt?.slice(0, 48) ?? null, - }, - ); - requestState.requestLogId = activityLogger.log({ - eventType: "chat_request_start", - status: "pending", - title: skipUserMessage ? "系统引导请求" : "发送请求", - description: `模型: ${effectiveModel} · 策略: ${effectiveExecutionStrategy}`, - workspaceId: resolvedWorkspaceId, - sessionId: activeSessionId, - source: "aster-chat", - metadata: { - provider: mapProviderName(effectiveProviderType), - model: effectiveModel, - executionStrategy: effectiveExecutionStrategy, - contentLength: content.trim().length, - skipUserMessage, - systemPromptLength: systemPrompt?.length ?? 0, - autoContinueEnabled: autoContinue?.enabled ?? false, - autoContinue: autoContinue?.enabled ? autoContinue : undefined, - queuedSubmission: expectingQueue, - }, + startAgentStreamRequest({ + activeSessionId, + autoContinue, + content, + effectiveExecutionStrategy, + effectiveModel, + effectiveProviderType, + eventName, + expectingQueue, + requestState, + resolvedWorkspaceId, + skipUserMessage, + systemPrompt, }); let firstEventReceived = false; @@ -206,30 +192,21 @@ export async function registerAgentStreamTurnEventBinding( firstEventReceived = true; requestState.firstEventReceivedAt = params.eventReceivedAt; + const firstEventContext = buildAgentStreamFirstEventContext({ + activeSessionId, + eventName, + eventReceivedAt: params.eventReceivedAt, + eventType: params.eventType, + recognized: params.recognized, + requestStartedAt: requestState.requestStartedAt, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }); recordAgentStreamPerformanceMetric( "agentStream.firstEvent", requestState.performanceTrace, - { - elapsedMs: params.eventReceivedAt - requestState.requestStartedAt, - eventName, - eventType: params.eventType, - recognized: params.recognized, - sessionId: activeSessionId, - submissionDispatchedDeltaMs: requestState.submissionDispatchedAt - ? params.eventReceivedAt - requestState.submissionDispatchedAt - : null, - }, + firstEventContext, ); - logAgentDebug("AgentStream", "firstEvent", { - elapsedMs: params.eventReceivedAt - requestState.requestStartedAt, - eventName, - eventType: params.eventType, - recognized: params.recognized, - sessionId: activeSessionId, - submissionDispatchedDeltaMs: requestState.submissionDispatchedAt - ? params.eventReceivedAt - requestState.submissionDispatchedAt - : null, - }); + logAgentDebug("AgentStream", "firstEvent", firstEventContext); clearFirstEventWatchdog(); }; let inactivityWatchdogId: ReturnType | null = null; @@ -241,26 +218,28 @@ export async function registerAgentStreamTurnEventBinding( }; function deferFirstEventTimeoutAfterSubmission() { if ( - firstEventReceived || - requestState.requestFinished || - !requestState.submissionDispatchedAt + !shouldDeferAgentStreamFirstEventTimeout({ + firstEventReceived, + requestFinished: requestState.requestFinished, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }) ) { return false; } firstEventReceived = true; lastEventReceivedAt = Date.now(); + const deferredContext = buildAgentStreamFirstEventDeferredContext({ + activeSessionId, + deferredAt: lastEventReceivedAt, + eventName, + requestStartedAt: requestState.requestStartedAt, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }); recordAgentStreamPerformanceMetric( "agentStream.firstEventDeferred", requestState.performanceTrace, - { - elapsedMs: lastEventReceivedAt - requestState.requestStartedAt, - eventName, - sessionId: activeSessionId, - submissionDispatchedDeltaMs: requestState.submissionDispatchedAt - ? lastEventReceivedAt - requestState.submissionDispatchedAt - : null, - }, + deferredContext, ); callbacks.activateStream(activeSessionId, effectiveWaitingRuntimeStatus); scheduleInactivityWatchdog(); @@ -275,24 +254,37 @@ export async function registerAgentStreamTurnEventBinding( } void (async () => { const recovered = await tryRecoverSilentTurn(); - if (firstEventReceived || requestState.requestFinished) { - return; + const timeoutAction = resolveAgentStreamFirstEventTimeoutAction({ + canDeferAfterSubmission: shouldDeferAgentStreamFirstEventTimeout({ + firstEventReceived, + requestFinished: requestState.requestFinished, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }), + firstEventReceived, + recovered, + requestFinished: requestState.requestFinished, + }); + switch (timeoutAction) { + case "ignore": + return; + case "recover": + console.warn( + buildAgentStreamFirstEventSilentRecoveryWarning({ eventName }), + ); + finalizeSilentTurnRecovery(); + return; + case "defer": + if (deferFirstEventTimeoutAfterSubmission()) { + console.warn( + buildAgentStreamFirstEventDeferredWarning({ eventName }), + ); + } + return; + case "fail": + firstEventReceived = true; + dispatchSyntheticError(AGENT_STREAM_FIRST_EVENT_TIMEOUT_MESSAGE); + return; } - if (recovered) { - console.warn( - `[AsterChat] 首个运行时事件静默,已降级切换为会话快照同步: ${eventName}`, - ); - finalizeSilentTurnRecovery(); - return; - } - if (deferFirstEventTimeoutAfterSubmission()) { - console.warn( - `[AsterChat] 首个运行时事件暂未到达,已基于提交派发继续等待后续进度: ${eventName}`, - ); - return; - } - firstEventReceived = true; - dispatchSyntheticError(STREAM_FIRST_EVENT_TIMEOUT_MESSAGE); })(); }, STREAM_FIRST_EVENT_TIMEOUT_MS); @@ -378,9 +370,11 @@ export async function registerAgentStreamTurnEventBinding( const scheduleInactivityWatchdog = () => { clearInactivityWatchdog(); if ( - !firstEventReceived || - requestState.requestFinished || - !callbacks.isStreamActivated() + !shouldScheduleAgentStreamInactivityWatchdog({ + firstEventReceived, + requestFinished: requestState.requestFinished, + streamActivated: callbacks.isStreamActivated(), + }) ) { return; } @@ -393,21 +387,28 @@ export async function registerAgentStreamTurnEventBinding( const timeoutStartedAt = Date.now(); void (async () => { const recovered = await tryRecoverSilentTurn(); - if ( - requestState.requestFinished || - !callbacks.isStreamActivated() || - lastEventReceivedAt > timeoutStartedAt - ) { - return; + const timeoutAction = resolveAgentStreamInactivityTimeoutAction({ + recovered, + shouldIgnore: shouldIgnoreAgentStreamInactivityResult({ + lastEventReceivedAt, + requestFinished: requestState.requestFinished, + streamActivated: callbacks.isStreamActivated(), + timeoutStartedAt, + }), + }); + switch (timeoutAction) { + case "ignore": + return; + case "recover": + console.warn( + buildAgentStreamInactivitySilentRecoveryWarning({ eventName }), + ); + finalizeSilentTurnRecovery(); + return; + case "fail": + dispatchSyntheticError(AGENT_STREAM_INACTIVITY_TIMEOUT_MESSAGE); + return; } - if (recovered) { - console.warn( - `[AsterChat] 运行时事件静默,已降级切换为会话快照同步: ${eventName}`, - ); - finalizeSilentTurnRecovery(); - return; - } - dispatchSyntheticError(STREAM_INACTIVITY_TIMEOUT_MESSAGE); })(); }, STREAM_INACTIVITY_TIMEOUT_MS); }; @@ -417,15 +418,20 @@ export async function registerAgentStreamTurnEventBinding( (event: { payload: unknown }) => { const eventReceivedAt = Date.now(); const data = parseAgentEvent(event.payload); - const eventType = extractRuntimeEventType(event.payload); + const eventType = extractAgentStreamRuntimeEventType(event.payload); if (!data) { - if (!eventType) { + const unknownEventPlan = resolveAgentStreamUnknownEventPlan({ + eventName, + eventType, + warnedEventTypes: warnedUnknownEventTypes, + }); + if (!unknownEventPlan) { return; } if (!firstEventReceived) { markFirstEventReceived({ eventReceivedAt, - eventType, + eventType: unknownEventPlan.eventType, recognized: false, }); } @@ -434,11 +440,15 @@ export async function registerAgentStreamTurnEventBinding( activeSessionId, effectiveWaitingRuntimeStatus, ); - if (!warnedUnknownEventTypes.has(eventType)) { - warnedUnknownEventTypes.add(eventType); - console.warn( - `[AsterChat] 收到未识别的运行时事件,已保留流活跃态: ${eventName} · ${eventType}`, - ); + if ( + unknownEventPlan.shouldWarn && + unknownEventPlan.warningMessage && + rememberAgentStreamUnknownEventWarning({ + eventType: unknownEventPlan.eventType, + warnedEventTypes: warnedUnknownEventTypes, + }) + ) { + console.warn(unknownEventPlan.warningMessage); } scheduleInactivityWatchdog(); return; @@ -504,22 +514,19 @@ export async function registerAgentStreamTurnEventBinding( ); requestState.listenerBoundAt = Date.now(); + const listenerBoundContext = buildAgentStreamListenerBoundContext({ + activeSessionId, + eventName, + expectingQueue, + listenerBoundAt: requestState.listenerBoundAt, + requestStartedAt: requestState.requestStartedAt, + }); recordAgentStreamPerformanceMetric( "agentStream.listenerBound", requestState.performanceTrace, - { - elapsedMs: requestState.listenerBoundAt - requestState.requestStartedAt, - eventName, - expectingQueue, - sessionId: activeSessionId, - }, + listenerBoundContext, ); - logAgentDebug("AgentStream", "listenerBound", { - elapsedMs: requestState.listenerBoundAt - requestState.requestStartedAt, - eventName, - expectingQueue, - sessionId: activeSessionId, - }); + logAgentDebug("AgentStream", "listenerBound", listenerBoundContext); return () => { clearFirstEventWatchdog(); diff --git a/src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts b/src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts new file mode 100644 index 000000000..d407d3ea9 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamUnknownEventController.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentStreamUnknownEventWarningMessage, + rememberAgentStreamUnknownEventWarning, + resolveAgentStreamUnknownEventPlan, +} from "./agentStreamUnknownEventController"; + +describe("agentStreamUnknownEventController", () => { + it("应构造未知 runtime event 告警文案", () => { + expect( + buildAgentStreamUnknownEventWarningMessage({ + eventName: "event-a", + eventType: "runtime_projection_bootstrap", + }), + ).toBe( + "[AsterChat] 收到未识别的运行时事件,已保留流活跃态: event-a · runtime_projection_bootstrap", + ); + }); + + it("无 event type 时不应生成处理计划", () => { + expect( + resolveAgentStreamUnknownEventPlan({ + eventName: "event-a", + eventType: null, + warnedEventTypes: new Set(), + }), + ).toBeNull(); + }); + + it("首次未知 event 应生成告警计划,重复 event 应去重", () => { + expect( + resolveAgentStreamUnknownEventPlan({ + eventName: "event-a", + eventType: "runtime_projection_bootstrap", + warnedEventTypes: new Set(), + }), + ).toEqual({ + eventType: "runtime_projection_bootstrap", + shouldWarn: true, + warningMessage: + "[AsterChat] 收到未识别的运行时事件,已保留流活跃态: event-a · runtime_projection_bootstrap", + }); + + expect( + resolveAgentStreamUnknownEventPlan({ + eventName: "event-a", + eventType: "runtime_projection_bootstrap", + warnedEventTypes: new Set(["runtime_projection_bootstrap"]), + }), + ).toEqual({ + eventType: "runtime_projection_bootstrap", + shouldWarn: false, + warningMessage: null, + }); + }); + + it("应记录已告警未知 event type 并返回是否首次记录", () => { + const warnedEventTypes = new Set(); + + expect( + rememberAgentStreamUnknownEventWarning({ + eventType: "runtime_projection_bootstrap", + warnedEventTypes, + }), + ).toBe(true); + expect(warnedEventTypes.has("runtime_projection_bootstrap")).toBe(true); + + expect( + rememberAgentStreamUnknownEventWarning({ + eventType: "runtime_projection_bootstrap", + warnedEventTypes, + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamUnknownEventController.ts b/src/components/agent/chat/hooks/agentStreamUnknownEventController.ts new file mode 100644 index 000000000..3a6df086e --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamUnknownEventController.ts @@ -0,0 +1,46 @@ +export interface AgentStreamUnknownEventPlan { + eventType: string; + shouldWarn: boolean; + warningMessage: string | null; +} + +export function buildAgentStreamUnknownEventWarningMessage(params: { + eventName: string; + eventType: string; +}): string { + return `[AsterChat] 收到未识别的运行时事件,已保留流活跃态: ${params.eventName} · ${params.eventType}`; +} + +export function resolveAgentStreamUnknownEventPlan(params: { + eventName: string; + eventType: string | null; + warnedEventTypes: ReadonlySet; +}): AgentStreamUnknownEventPlan | null { + if (!params.eventType) { + return null; + } + + const shouldWarn = !params.warnedEventTypes.has(params.eventType); + return { + eventType: params.eventType, + shouldWarn, + warningMessage: shouldWarn + ? buildAgentStreamUnknownEventWarningMessage({ + eventName: params.eventName, + eventType: params.eventType, + }) + : null, + }; +} + +export function rememberAgentStreamUnknownEventWarning(params: { + eventType: string; + warnedEventTypes: Set; +}): boolean { + if (params.warnedEventTypes.has(params.eventType)) { + return false; + } + + params.warnedEventTypes.add(params.eventType); + return true; +} diff --git a/src/components/agent/chat/hooks/agentStreamWarningController.test.ts b/src/components/agent/chat/hooks/agentStreamWarningController.test.ts new file mode 100644 index 000000000..15f936194 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamWarningController.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import { WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE } from "./agentChatCoreUtils"; +import { + applyAgentStreamWarningToastAction, + buildAgentStreamWarningPlan, + buildAgentStreamWarningToastAction, +} from "./agentStreamWarningController"; +import { ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE } from "./runtimeWarningPresentation"; + +describe("agentStreamWarningController", () => { + it("应忽略 workspace 自动创建 warning", () => { + expect( + buildAgentStreamWarningPlan({ + activeSessionId: "session-a", + alreadyWarned: false, + code: WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE, + message: "已自动创建", + }), + ).toEqual({ + shouldMarkWarned: false, + toast: null, + warningKey: null, + }); + }); + + it("已提示过的 warning 不应重复 toast", () => { + expect( + buildAgentStreamWarningPlan({ + activeSessionId: "session-a", + alreadyWarned: true, + code: "code-a", + message: "提醒", + }), + ).toEqual({ + shouldMarkWarned: false, + toast: null, + warningKey: "session-a:code-a", + }); + }); + + it("应为普通 warning 构造 toast plan 并标记 warned", () => { + expect( + buildAgentStreamWarningPlan({ + activeSessionId: "session-a", + alreadyWarned: false, + message: "普通提醒", + }), + ).toEqual({ + shouldMarkWarned: true, + toast: { + level: "warning", + message: "普通提醒", + }, + warningKey: "session-a:普通提醒", + }); + }); + + it("不需要 toast 的 warning 仍应标记 warned", () => { + expect( + buildAgentStreamWarningPlan({ + activeSessionId: "session-a", + alreadyWarned: false, + code: ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE, + }), + ).toEqual({ + shouldMarkWarned: true, + toast: null, + warningKey: `session-a:${ARTIFACT_DOCUMENT_REPAIRED_WARNING_CODE}`, + }); + }); + + it("应从 warning plan 构造 toast action", () => { + expect( + buildAgentStreamWarningToastAction({ + level: "info", + message: "已恢复", + }), + ).toEqual({ + level: "info", + message: "已恢复", + }); + expect(buildAgentStreamWarningToastAction(null)).toBeNull(); + }); + + it("应按 toast action level 调用对应 dispatcher", () => { + const dispatcher = { + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }; + + applyAgentStreamWarningToastAction( + { level: "info", message: "提示" }, + dispatcher, + ); + applyAgentStreamWarningToastAction( + { level: "error", message: "错误" }, + dispatcher, + ); + applyAgentStreamWarningToastAction( + { level: "warning", message: "警告" }, + dispatcher, + ); + applyAgentStreamWarningToastAction(null, dispatcher); + + expect(dispatcher.info).toHaveBeenCalledWith("提示"); + expect(dispatcher.error).toHaveBeenCalledWith("错误"); + expect(dispatcher.warning).toHaveBeenCalledWith("警告"); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamWarningController.ts b/src/components/agent/chat/hooks/agentStreamWarningController.ts new file mode 100644 index 000000000..57f37aa8c --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamWarningController.ts @@ -0,0 +1,102 @@ +import { WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE } from "./agentChatCoreUtils"; +import { + resolveRuntimeWarningToastPresentation, + type RuntimeWarningToastLevel, +} from "./runtimeWarningPresentation"; + +export interface AgentStreamWarningPlan { + shouldMarkWarned: boolean; + toast: { + level: RuntimeWarningToastLevel; + message: string; + } | null; + warningKey: string | null; +} + +export interface AgentStreamWarningToastAction { + level: RuntimeWarningToastLevel; + message: string; +} + +export interface AgentStreamWarningToastDispatcher { + error: (message: string) => void; + info: (message: string) => void; + warning: (message: string) => void; +} + +export function buildAgentStreamWarningPlan(params: { + activeSessionId: string; + alreadyWarned: boolean; + code?: string | null; + message?: string | null; +}): AgentStreamWarningPlan { + if (params.code === WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE) { + return { + shouldMarkWarned: false, + toast: null, + warningKey: null, + }; + } + + const warningKey = `${params.activeSessionId}:${ + params.code || params.message || "" + }`; + if (params.alreadyWarned) { + return { + shouldMarkWarned: false, + toast: null, + warningKey, + }; + } + + const presentation = resolveRuntimeWarningToastPresentation({ + code: params.code, + message: params.message, + }); + + return { + shouldMarkWarned: true, + toast: presentation.shouldToast + ? { + level: presentation.level, + message: presentation.message, + } + : null, + warningKey, + }; +} + +export function buildAgentStreamWarningToastAction( + toastPlan: AgentStreamWarningPlan["toast"], +): AgentStreamWarningToastAction | null { + if (!toastPlan) { + return null; + } + + return { + level: toastPlan.level, + message: toastPlan.message, + }; +} + +export function applyAgentStreamWarningToastAction( + action: AgentStreamWarningToastAction | null, + dispatcher: AgentStreamWarningToastDispatcher, +): void { + if (!action) { + return; + } + + switch (action.level) { + case "info": + dispatcher.info(action.message); + break; + case "error": + dispatcher.error(action.message); + break; + case "warning": + default: + dispatcher.warning(action.message); + break; + } +} diff --git a/src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts b/src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts new file mode 100644 index 000000000..c6775be6f --- /dev/null +++ b/src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { AsterSessionDetail } from "@/lib/api/agentRuntime"; +import type { AgentThreadItem, AgentThreadTurn, Message } from "../types"; +import { buildSessionHistoryMergePlan } from "./sessionHistoryMergeController"; + +const baseDate = new Date("2026-05-05T00:00:00.000Z"); + +function localMessage(id: string, content: string): Message { + return { + id, + role: "assistant", + content, + timestamp: baseDate, + }; +} + +function turn(id: string, offset: number): AgentThreadTurn { + return { + id, + thread_id: "thread-a", + prompt_text: id, + status: "completed", + started_at: `2026-05-05T00:00:0${offset}.000Z`, + completed_at: `2026-05-05T00:00:0${offset}.500Z`, + created_at: `2026-05-05T00:00:0${offset}.000Z`, + updated_at: `2026-05-05T00:00:0${offset}.500Z`, + }; +} + +function agentItem( + id: string, + turnId: string, + sequence: number, + text: string, +): AgentThreadItem { + return { + id, + thread_id: "thread-a", + turn_id: turnId, + sequence, + status: "completed", + started_at: `2026-05-05T00:00:0${sequence}.000Z`, + updated_at: `2026-05-05T00:00:0${sequence}.500Z`, + type: "agent_message", + text, + }; +} + +function detail(overrides: Partial = {}): AsterSessionDetail { + return { + id: "topic-a", + created_at: 1, + updated_at: 2, + messages: [ + { + role: "user", + timestamp: 1, + content: [{ type: "text", text: "更早的问题" }], + }, + { + role: "assistant", + timestamp: 2, + content: [{ type: "text", text: "更早的回复" }], + }, + ], + turns: [turn("turn-b", 2)], + items: [agentItem("item-b", "turn-b", 2, "更早工具过程")], + ...overrides, + }; +} + +describe("sessionHistoryMergeController", () => { + it("应合并分页 detail 的消息、turns 与 thread items", () => { + const plan = buildSessionHistoryMergePlan({ + currentMessages: [localMessage("local-a", "最近回复")], + currentThreadTurns: [turn("turn-a", 1)], + currentThreadItems: [agentItem("item-a", "turn-a", 1, "最近工具过程")], + currentTurnId: "turn-a", + detail: detail(), + sessionId: "topic-a", + }); + + expect(plan.incomingMessages).toHaveLength(2); + expect(plan.mergedMessages.map((message) => message.content)).toEqual([ + "更早的问题", + "更早的回复", + "最近回复", + ]); + expect(plan.mergedThreadTurns.map((item) => item.id)).toEqual([ + "turn-a", + "turn-b", + ]); + expect(plan.mergedThreadItems.map((item) => item.id)).toEqual([ + "item-a", + "item-b", + ]); + expect(plan.currentTurnId).toBe("turn-b"); + }); + + it("无 incoming turns 时应保留当前 turnId", () => { + const plan = buildSessionHistoryMergePlan({ + currentMessages: [localMessage("local-a", "最近回复")], + currentThreadTurns: [], + currentThreadItems: [], + currentTurnId: "turn-current", + detail: detail({ messages: [], turns: [], items: [] }), + sessionId: "topic-a", + }); + + expect(plan.currentTurnId).toBe("turn-current"); + expect(plan.mergedMessages).toHaveLength(1); + }); +}); diff --git a/src/components/agent/chat/hooks/sessionHistoryMergeController.ts b/src/components/agent/chat/hooks/sessionHistoryMergeController.ts new file mode 100644 index 000000000..9d2c26744 --- /dev/null +++ b/src/components/agent/chat/hooks/sessionHistoryMergeController.ts @@ -0,0 +1,65 @@ +import type { AsterSessionDetail } from "@/lib/api/agentRuntime"; +import { normalizeLegacyThreadItems } from "@/lib/api/agentTextNormalization"; +import type { AgentThreadItem, AgentThreadTurn, Message } from "../types"; +import { + filterConversationThreadItems, + mergeThreadItems, + mergeThreadTurns, +} from "../utils/threadTimelineView"; +import { + hydrateSessionDetailMessages, + mergeHydratedMessagesWithLocalState, + shouldCompactCompletedSessionHistory, +} from "./agentChatHistory"; + +export interface SessionHistoryMergePlan { + currentTurnId: string | null; + incomingMessages: Message[]; + mergedMessages: Message[]; + mergedThreadItems: AgentThreadItem[]; + mergedThreadTurns: AgentThreadTurn[]; +} + +export function buildSessionHistoryMergePlan(params: { + currentMessages: Message[]; + currentThreadItems: AgentThreadItem[]; + currentThreadTurns: AgentThreadTurn[]; + currentTurnId: string | null; + detail: AsterSessionDetail; + sessionId: string; +}): SessionHistoryMergePlan { + const incomingMessages = hydrateSessionDetailMessages( + params.detail, + params.sessionId, + { + compactCompletedHistory: shouldCompactCompletedSessionHistory( + params.detail, + ), + }, + ); + const mergedMessages = mergeHydratedMessagesWithLocalState( + params.currentMessages, + incomingMessages, + ); + const mergedThreadTurns = mergeThreadTurns( + params.currentThreadTurns, + params.detail.turns || [], + ); + const mergedThreadItems = filterConversationThreadItems( + mergeThreadItems( + params.currentThreadItems, + normalizeLegacyThreadItems(params.detail.items || []), + ), + ); + + return { + currentTurnId: + mergedThreadTurns.length > 0 + ? mergedThreadTurns[mergedThreadTurns.length - 1]?.id || null + : params.currentTurnId, + incomingMessages, + mergedMessages, + mergedThreadItems, + mergedThreadTurns, + }; +} diff --git a/src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts b/src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts new file mode 100644 index 000000000..d49e6926b --- /dev/null +++ b/src/components/agent/chat/hooks/sessionHistoryPaginationController.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + buildSessionHistoryPageRequestPlan, + buildSessionHistoryPageResultPlan, + normalizeNonNegativeInteger, + normalizePositiveInteger, + resolveDetailHistoryLoadedMessages, + resolveSessionHistoryWindowFromDetail, +} from "./sessionHistoryPaginationController"; + +function messages(count: number) { + return Array.from({ length: count }, (_, index) => ({ id: `m-${index}` })); +} + +describe("sessionHistoryPaginationController", () => { + it("应归一化整数边界", () => { + expect(normalizePositiveInteger(1.8)).toBe(1); + expect(normalizePositiveInteger(0)).toBeNull(); + expect(normalizeNonNegativeInteger(0)).toBe(0); + expect(normalizeNonNegativeInteger(-1)).toBeNull(); + }); + + it("应从 detail cursor / limit offset 推导已加载消息数", () => { + expect( + resolveDetailHistoryLoadedMessages({ + messages: messages(40), + messages_count: 320, + history_cursor: { start_index: 280 }, + }), + ).toBe(40); + + expect( + resolveDetailHistoryLoadedMessages({ + messages: messages(50), + messages_count: 320, + history_limit: 50, + history_offset: 40, + }), + ).toBe(90); + }); + + it("未截断或已全量加载时不应保留 history window", () => { + expect( + resolveSessionHistoryWindowFromDetail({ + messages: messages(40), + history_truncated: false, + }), + ).toBeNull(); + + expect( + resolveSessionHistoryWindowFromDetail({ + messages: messages(40), + messages_count: 40, + history_truncated: true, + }), + ).toBeNull(); + }); + + it("应构造首个完整历史分页请求计划", () => { + expect( + buildSessionHistoryPageRequestPlan({ + currentHistoryWindow: { + loadedMessages: 40, + totalMessages: 320, + historyBeforeMessageId: 281, + historyStartIndex: 280, + isLoadingFull: false, + error: "old", + }, + currentMessagesCount: 40, + pageSize: 50, + }), + ).toEqual({ + historyBeforeMessageId: 281, + loadedMessagesCount: 40, + loadingWindow: { + loadedMessages: 40, + totalMessages: 320, + historyBeforeMessageId: 281, + historyStartIndex: 280, + isLoadingFull: true, + error: null, + }, + nextHistoryLimit: 50, + nextHistoryOffset: 40, + requestOptions: { + historyLimit: 50, + historyOffset: 40, + historyBeforeMessageId: 281, + }, + totalMessagesCount: 320, + }); + }); + + it("已在加载时不应重复构造分页请求", () => { + expect( + buildSessionHistoryPageRequestPlan({ + currentHistoryWindow: { + loadedMessages: 40, + totalMessages: 320, + isLoadingFull: true, + error: null, + }, + currentMessagesCount: 40, + pageSize: 50, + }), + ).toBeNull(); + }); + + it("应根据分页 detail 构造下一轮 history window", () => { + expect( + buildSessionHistoryPageResultPlan({ + detail: { + messages: messages(50), + messages_count: 320, + history_limit: 50, + history_offset: 40, + history_cursor: { + oldest_message_id: 231, + start_index: 230, + }, + }, + historyBeforeMessageId: 281, + nextHistoryLimit: 50, + nextHistoryOffset: 40, + totalMessagesCount: 320, + }), + ).toMatchObject({ + detailLoadedMessages: 90, + nextHistoryBeforeMessageId: 231, + nextHistoryStartIndex: 230, + nextLoadedMessages: 90, + resolvedTotalMessages: 320, + nextHistoryWindow: { + loadedMessages: 90, + totalMessages: 320, + historyBeforeMessageId: 231, + historyStartIndex: 230, + isLoadingFull: false, + error: null, + }, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/sessionHistoryPaginationController.ts b/src/components/agent/chat/hooks/sessionHistoryPaginationController.ts new file mode 100644 index 000000000..4ce7fe19a --- /dev/null +++ b/src/components/agent/chat/hooks/sessionHistoryPaginationController.ts @@ -0,0 +1,215 @@ +export interface SessionHistoryWindowState { + loadedMessages: number; + totalMessages: number; + historyBeforeMessageId?: number | null; + historyStartIndex?: number | null; + isLoadingFull: boolean; + error: string | null; +} + +export interface SessionHistoryDetailLike { + history_cursor?: { + oldest_message_id?: number | null; + start_index?: number | null; + } | null; + history_limit?: number | null; + history_offset?: number | null; + history_truncated?: boolean | null; + messages: readonly unknown[]; + messages_count?: number | null; +} + +export interface SessionHistoryPageRequestPlan { + historyBeforeMessageId: number | null; + loadedMessagesCount: number; + loadingWindow: SessionHistoryWindowState; + nextHistoryLimit: number; + nextHistoryOffset: number; + requestOptions: { + historyBeforeMessageId?: number; + historyLimit: number; + historyOffset: number; + }; + totalMessagesCount: number; +} + +export interface SessionHistoryPageResultPlan { + detailLoadedMessages: number; + nextHistoryBeforeMessageId: number | null; + nextHistoryStartIndex: number | null; + nextHistoryWindow: SessionHistoryWindowState | null; + nextLoadedMessages: number; + resolvedTotalMessages: number; +} + +export function normalizePositiveInteger(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.trunc(value) + : null; +} + +export function normalizeNonNegativeInteger(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.trunc(value) + : null; +} + +export function resolveDetailHistoryLoadedMessages( + detail: SessionHistoryDetailLike, +): number { + const totalMessages = + normalizeNonNegativeInteger(detail.messages_count) ?? + detail.messages.length; + const cursorStartIndex = normalizeNonNegativeInteger( + detail.history_cursor?.start_index, + ); + if (cursorStartIndex !== null) { + return Math.min( + totalMessages, + Math.max(detail.messages.length, totalMessages - cursorStartIndex), + ); + } + const historyLimit = + normalizeNonNegativeInteger(detail.history_limit) ?? null; + const historyOffset = normalizeNonNegativeInteger(detail.history_offset) ?? 0; + + if (historyLimit === null) { + return detail.messages.length; + } + + return Math.min(totalMessages, historyOffset + historyLimit); +} + +export function resolveSessionHistoryWindowFromDetail( + detail: SessionHistoryDetailLike, +): SessionHistoryWindowState | null { + if (detail.history_truncated !== true) { + return null; + } + + const loadedMessages = resolveDetailHistoryLoadedMessages(detail); + const totalMessages = Math.max( + loadedMessages, + normalizeNonNegativeInteger(detail.messages_count) ?? loadedMessages, + ); + + if (totalMessages <= loadedMessages) { + return null; + } + + return { + loadedMessages, + totalMessages, + historyBeforeMessageId: normalizePositiveInteger( + detail.history_cursor?.oldest_message_id, + ), + historyStartIndex: normalizeNonNegativeInteger( + detail.history_cursor?.start_index, + ), + isLoadingFull: false, + error: null, + }; +} + +export function buildSessionHistoryPageRequestPlan(params: { + currentHistoryWindow?: SessionHistoryWindowState | null; + currentMessagesCount: number; + pageSize: number; +}): SessionHistoryPageRequestPlan | null { + const currentHistoryWindow = params.currentHistoryWindow; + if (currentHistoryWindow?.isLoadingFull) { + return null; + } + + const loadedMessagesCount = + currentHistoryWindow?.loadedMessages ?? params.currentMessagesCount; + const totalMessagesCount = + currentHistoryWindow?.totalMessages ?? loadedMessagesCount; + const nextHistoryOffset = loadedMessagesCount; + const historyBeforeMessageId = + normalizePositiveInteger(currentHistoryWindow?.historyBeforeMessageId) ?? + null; + const nextHistoryLimit = + totalMessagesCount > loadedMessagesCount + ? Math.min(params.pageSize, totalMessagesCount - loadedMessagesCount) + : params.pageSize; + + if (nextHistoryLimit <= 0) { + return null; + } + + return { + historyBeforeMessageId, + loadedMessagesCount, + loadingWindow: currentHistoryWindow + ? { ...currentHistoryWindow, isLoadingFull: true, error: null } + : { + loadedMessages: loadedMessagesCount, + totalMessages: totalMessagesCount, + historyBeforeMessageId, + historyStartIndex: null, + isLoadingFull: true, + error: null, + }, + nextHistoryLimit, + nextHistoryOffset, + requestOptions: { + historyLimit: nextHistoryLimit, + historyOffset: nextHistoryOffset, + ...(historyBeforeMessageId !== null ? { historyBeforeMessageId } : {}), + }, + totalMessagesCount, + }; +} + +export function buildSessionHistoryPageResultPlan(params: { + detail: SessionHistoryDetailLike; + historyBeforeMessageId: number | null; + nextHistoryLimit: number; + nextHistoryOffset: number; + totalMessagesCount: number; +}): SessionHistoryPageResultPlan { + const detailLoadedMessages = resolveDetailHistoryLoadedMessages( + params.detail, + ); + const detailTotalMessages = + normalizeNonNegativeInteger(params.detail.messages_count) ?? + params.totalMessagesCount; + const nextLoadedMessages = Math.min( + detailTotalMessages, + Math.max( + detailLoadedMessages, + params.nextHistoryOffset + params.nextHistoryLimit, + ), + ); + const resolvedTotalMessages = Math.max( + nextLoadedMessages, + detailTotalMessages, + ); + const nextHistoryBeforeMessageId = normalizePositiveInteger( + params.detail.history_cursor?.oldest_message_id, + ); + const nextHistoryStartIndex = normalizeNonNegativeInteger( + params.detail.history_cursor?.start_index, + ); + + return { + detailLoadedMessages, + nextHistoryBeforeMessageId, + nextHistoryStartIndex, + nextLoadedMessages, + resolvedTotalMessages, + nextHistoryWindow: + resolvedTotalMessages > nextLoadedMessages + ? { + loadedMessages: nextLoadedMessages, + totalMessages: resolvedTotalMessages, + historyBeforeMessageId: + nextHistoryBeforeMessageId ?? params.historyBeforeMessageId, + historyStartIndex: nextHistoryStartIndex, + isLoadingFull: false, + error: null, + } + : null, + }; +} diff --git a/src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts b/src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts new file mode 100644 index 000000000..6c0e62bef --- /dev/null +++ b/src/components/agent/chat/hooks/sessionSwitchErrorController.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + buildSessionSwitchErrorToastMessage, + resolveSessionSwitchErrorAction, +} from "./sessionSwitchErrorController"; + +describe("sessionSwitchErrorController", () => { + it("session not found 应清空当前快照并刷新 topics,但不弹 toast", () => { + const error = new Error("session not found"); + + expect( + resolveSessionSwitchErrorAction({ + error, + preserveCurrentSnapshot: true, + topicId: "topic-a", + workspaceId: "workspace-a", + }), + ).toEqual({ + clearCurrentSnapshot: true, + kind: "session_not_found", + logContext: { + error, + topicId: "topic-a", + workspaceId: "workspace-a", + }, + reloadTopics: true, + showToast: false, + toastMessage: null, + }); + }); + + it("普通错误默认应清空快照并弹出 toast", () => { + const error = new Error("permission denied"); + + expect( + resolveSessionSwitchErrorAction({ + error, + topicId: "topic-a", + }), + ).toMatchObject({ + clearCurrentSnapshot: true, + kind: "clear_and_toast", + reloadTopics: false, + showToast: true, + toastMessage: "加载对话历史失败: permission denied", + }); + }); + + it("preserveCurrentSnapshot 时普通错误只弹 toast,不清空当前快照", () => { + expect( + resolveSessionSwitchErrorAction({ + error: "fetch failed", + preserveCurrentSnapshot: true, + topicId: "topic-a", + }), + ).toMatchObject({ + clearCurrentSnapshot: false, + kind: "toast_only", + reloadTopics: false, + showToast: true, + toastMessage: "加载对话历史失败: fetch failed", + }); + }); + + it("toast 文案应兼容非 Error 错误", () => { + expect(buildSessionSwitchErrorToastMessage("bad gateway")).toBe( + "加载对话历史失败: bad gateway", + ); + }); +}); diff --git a/src/components/agent/chat/hooks/sessionSwitchErrorController.ts b/src/components/agent/chat/hooks/sessionSwitchErrorController.ts new file mode 100644 index 000000000..2b0c22324 --- /dev/null +++ b/src/components/agent/chat/hooks/sessionSwitchErrorController.ts @@ -0,0 +1,61 @@ +import { isAsterSessionNotFoundError } from "@/lib/asterSessionRecovery"; + +export type SessionSwitchErrorKind = + | "session_not_found" + | "toast_only" + | "clear_and_toast"; + +export interface SessionSwitchErrorAction { + clearCurrentSnapshot: boolean; + kind: SessionSwitchErrorKind; + logContext: Record; + reloadTopics: boolean; + showToast: boolean; + toastMessage: string | null; +} + +export function buildSessionSwitchErrorLogContext(params: { + error: unknown; + topicId: string; + workspaceId?: string | null; +}): Record { + return { + error: params.error, + topicId: params.topicId, + workspaceId: params.workspaceId, + }; +} + +export function buildSessionSwitchErrorToastMessage(error: unknown): string { + return `加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`; +} + +export function resolveSessionSwitchErrorAction(params: { + error: unknown; + preserveCurrentSnapshot?: boolean; + topicId: string; + workspaceId?: string | null; +}): SessionSwitchErrorAction { + const logContext = buildSessionSwitchErrorLogContext(params); + + if (isAsterSessionNotFoundError(params.error)) { + return { + clearCurrentSnapshot: true, + kind: "session_not_found", + logContext, + reloadTopics: true, + showToast: false, + toastMessage: null, + }; + } + + const clearCurrentSnapshot = !params.preserveCurrentSnapshot; + return { + clearCurrentSnapshot, + kind: clearCurrentSnapshot ? "clear_and_toast" : "toast_only", + logContext, + reloadTopics: false, + showToast: true, + toastMessage: buildSessionSwitchErrorToastMessage(params.error), + }; +} diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index 6524a1b14..8f71f3f85 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -36,7 +36,6 @@ import { } from "./agentProjectStorage"; import { hydrateSessionDetailMessages, - mergeHydratedMessagesWithLocalState, normalizeHistoryMessages, shouldCompactCompletedSessionHistory, } from "./agentChatHistory"; @@ -60,8 +59,6 @@ import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; import { isAuxiliaryAgentSessionId } from "@/lib/api/agentRuntime/sessionIdentity"; import { filterConversationThreadItems, - mergeThreadItems, - mergeThreadTurns, } from "../utils/threadTimelineView"; import { shouldResumeTaskSession } from "../utils/taskCenterTabs"; import { @@ -116,12 +113,20 @@ import { buildSessionDetailPrefetchSignature, isCurrentSessionHydrationRequest, } from "./sessionHydrationController"; +import { + buildSessionHistoryPageRequestPlan, + buildSessionHistoryPageResultPlan, + resolveSessionHistoryWindowFromDetail, + type SessionHistoryWindowState, +} from "./sessionHistoryPaginationController"; +import { buildSessionHistoryMergePlan } from "./sessionHistoryMergeController"; import { createSessionDetailPrefetchRegistry, loadSessionDetailWithPrefetch, type SessionDetailFetchEvent, } from "./sessionDetailFetchController"; import { resolveDeferredSessionHydrationErrorAction } from "./sessionHydrationRetryController"; +import { resolveSessionSwitchErrorAction } from "./sessionSwitchErrorController"; const INITIAL_TOPICS_IDLE_TIMEOUT_MS = 1_500; const INITIAL_TOPICS_SESSION_REQUEST_LIMIT = 21; @@ -198,52 +203,7 @@ function upsertTopicFromSessionDetail( }); } -export interface AgentSessionHistoryWindow { - loadedMessages: number; - totalMessages: number; - historyBeforeMessageId?: number | null; - historyStartIndex?: number | null; - isLoadingFull: boolean; - error: string | null; -} - -function normalizePositiveInteger(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.trunc(value) - : null; -} - -function normalizeNonNegativeInteger(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) && value >= 0 - ? Math.trunc(value) - : null; -} - -function resolveDetailHistoryLoadedMessages( - detail: Awaited>, -): number { - const totalMessages = - normalizeNonNegativeInteger(detail.messages_count) ?? - detail.messages.length; - const cursorStartIndex = normalizeNonNegativeInteger( - detail.history_cursor?.start_index, - ); - if (cursorStartIndex !== null) { - return Math.min( - totalMessages, - Math.max(detail.messages.length, totalMessages - cursorStartIndex), - ); - } - const historyLimit = - normalizeNonNegativeInteger(detail.history_limit) ?? null; - const historyOffset = normalizeNonNegativeInteger(detail.history_offset) ?? 0; - - if (historyLimit === null) { - return detail.messages.length; - } - - return Math.min(totalMessages, historyOffset + historyLimit); -} +export type AgentSessionHistoryWindow = SessionHistoryWindowState; function takeTail(items: T[], limit: number): T[] { if (items.length <= limit) { @@ -671,35 +631,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { ( detail: Awaited>, ): AgentSessionHistoryWindow | null => { - if (detail.history_truncated !== true) { - return null; - } - - const loadedMessages = resolveDetailHistoryLoadedMessages(detail); - const totalMessages = Math.max( - loadedMessages, - typeof detail.messages_count === "number" && - Number.isFinite(detail.messages_count) - ? Math.trunc(detail.messages_count) - : loadedMessages, - ); - - if (totalMessages <= loadedMessages) { - return null; - } - - return { - loadedMessages, - totalMessages, - historyBeforeMessageId: normalizePositiveInteger( - detail.history_cursor?.oldest_message_id, - ), - historyStartIndex: normalizeNonNegativeInteger( - detail.history_cursor?.start_index, - ), - isLoadingFull: false, - error: null, - }; + return resolveSessionHistoryWindowFromDetail(detail); }, [], ); @@ -1996,42 +1928,38 @@ export function useAgentSession(options: UseAgentSessionOptions) { topicId: string, options?: { preserveCurrentSnapshot?: boolean }, ) => { + const errorAction = resolveSessionSwitchErrorAction({ + error, + preserveCurrentSnapshot: options?.preserveCurrentSnapshot, + topicId, + workspaceId, + }); + console.error("[AsterChat] 切换话题失败:", error); console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2)); logAgentDebug( "useAgentSession", "switchTopic.error", - { - error, - topicId, - workspaceId, - }, + errorAction.logContext, { level: "error" }, ); - if (isAsterSessionNotFoundError(error)) { + if (errorAction.clearCurrentSnapshot) { applySessionSnapshot(createEmptyAgentSessionSnapshot()); setSessionHistoryWindow(null); persistSessionRestoreCandidate(null); hydratedSessionRef.current = null; - void loadTopics(); - setIsAutoRestoringSession(false); - setIsSessionHydrating(false); - return; } - if (!options?.preserveCurrentSnapshot) { - applySessionSnapshot(createEmptyAgentSessionSnapshot()); - setSessionHistoryWindow(null); - persistSessionRestoreCandidate(null); - hydratedSessionRef.current = null; + if (errorAction.reloadTopics) { + void loadTopics(); } setIsAutoRestoringSession(false); setIsSessionHydrating(false); - toast.error( - `加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`, - ); + if (errorAction.showToast && errorAction.toastMessage) { + toast.error(errorAction.toastMessage); + } }, [ applySessionSnapshot, @@ -2379,59 +2307,33 @@ export function useAgentSession(options: UseAgentSessionOptions) { } const currentHistoryWindow = sessionHistoryWindowRef.current; - if (currentHistoryWindow?.isLoadingFull) { - return false; - } - const loadedMessagesCount = - currentHistoryWindow?.loadedMessages ?? messagesRef.current.length; - const totalMessagesCount = - currentHistoryWindow?.totalMessages ?? loadedMessagesCount; - const nextHistoryOffset = loadedMessagesCount; - const historyBeforeMessageId = - normalizePositiveInteger(currentHistoryWindow?.historyBeforeMessageId) ?? - null; - const nextHistoryLimit = - totalMessagesCount > loadedMessagesCount - ? Math.min( - SESSION_HISTORY_LOAD_PAGE_SIZE, - totalMessagesCount - loadedMessagesCount, - ) - : SESSION_HISTORY_LOAD_PAGE_SIZE; - - if (nextHistoryLimit <= 0) { + const requestPlan = buildSessionHistoryPageRequestPlan({ + currentHistoryWindow, + currentMessagesCount: messagesRef.current.length, + pageSize: SESSION_HISTORY_LOAD_PAGE_SIZE, + }); + if (!requestPlan) { return false; } const switchRequestVersion = sessionSwitchRequestVersionRef.current; const startedAt = Date.now(); - setSessionHistoryWindow((current) => - current - ? { ...current, isLoadingFull: true, error: null } - : { - loadedMessages: loadedMessagesCount, - totalMessages: totalMessagesCount, - historyBeforeMessageId, - historyStartIndex: currentHistoryWindow?.historyStartIndex ?? null, - isLoadingFull: true, - error: null, - }, - ); + setSessionHistoryWindow(requestPlan.loadingWindow); logAgentDebug("useAgentSession", "loadFullHistory.start", { - historyBeforeMessageId, - loadedMessagesCount, - nextHistoryLimit, - nextHistoryOffset, + historyBeforeMessageId: requestPlan.historyBeforeMessageId, + loadedMessagesCount: requestPlan.loadedMessagesCount, + nextHistoryLimit: requestPlan.nextHistoryLimit, + nextHistoryOffset: requestPlan.nextHistoryOffset, sessionId: targetSessionId, - totalMessagesCount, + totalMessagesCount: requestPlan.totalMessagesCount, workspaceId, }); try { - const detail = await runtime.getSession(targetSessionId, { - historyLimit: nextHistoryLimit, - historyOffset: nextHistoryOffset, - ...(historyBeforeMessageId !== null ? { historyBeforeMessageId } : {}), - }); + const detail = await runtime.getSession( + targetSessionId, + requestPlan.requestOptions, + ); if ( !isCurrentSessionHydrationRequest({ currentRequestVersion: sessionSwitchRequestVersionRef.current, @@ -2443,70 +2345,29 @@ export function useAgentSession(options: UseAgentSessionOptions) { return false; } - const incomingMessages = hydrateSessionDetailMessages( + const mergePlan = buildSessionHistoryMergePlan({ + currentMessages: messagesRef.current, + currentThreadItems: threadItemsRef.current, + currentThreadTurns: threadTurnsRef.current, + currentTurnId, detail, - targetSessionId, - { - compactCompletedHistory: shouldCompactCompletedSessionHistory(detail), - }, - ); - const mergedMessages = mergeHydratedMessagesWithLocalState( - messagesRef.current, - incomingMessages, - ); - const mergedThreadTurns = mergeThreadTurns( - threadTurnsRef.current, - detail.turns || [], - ); - const mergedThreadItems = filterConversationThreadItems( - mergeThreadItems( - threadItemsRef.current, - normalizeLegacyThreadItems(detail.items || []), - ), - ); - const detailLoadedMessages = resolveDetailHistoryLoadedMessages(detail); - const detailTotalMessages = - typeof detail.messages_count === "number" && - Number.isFinite(detail.messages_count) - ? Math.trunc(detail.messages_count) - : totalMessagesCount; - const nextLoadedMessages = Math.min( - detailTotalMessages, - Math.max(detailLoadedMessages, nextHistoryOffset + nextHistoryLimit), - ); - const resolvedTotalMessages = Math.max( - nextLoadedMessages, - detailTotalMessages, - ); - const nextHistoryBeforeMessageId = normalizePositiveInteger( - detail.history_cursor?.oldest_message_id, - ); - const nextHistoryStartIndex = normalizeNonNegativeInteger( - detail.history_cursor?.start_index, - ); - const nextHistoryWindow = - resolvedTotalMessages > nextLoadedMessages - ? { - loadedMessages: nextLoadedMessages, - totalMessages: resolvedTotalMessages, - historyBeforeMessageId: - nextHistoryBeforeMessageId ?? historyBeforeMessageId, - historyStartIndex: nextHistoryStartIndex, - isLoadingFull: false, - error: null, - } - : null; + sessionId: targetSessionId, + }); + const resultPlan = buildSessionHistoryPageResultPlan({ + detail, + historyBeforeMessageId: requestPlan.historyBeforeMessageId, + nextHistoryLimit: requestPlan.nextHistoryLimit, + nextHistoryOffset: requestPlan.nextHistoryOffset, + totalMessagesCount: requestPlan.totalMessagesCount, + }); startTransition(() => { applySessionSnapshot({ sessionId: targetSessionId, - messages: mergedMessages, - threadTurns: mergedThreadTurns, - threadItems: mergedThreadItems, - currentTurnId: - mergedThreadTurns.length > 0 - ? mergedThreadTurns[mergedThreadTurns.length - 1]?.id || null - : currentTurnId, + messages: mergePlan.mergedMessages, + threadTurns: mergePlan.mergedThreadTurns, + threadItems: mergePlan.mergedThreadItems, + currentTurnId: mergePlan.currentTurnId, queuedTurns, threadRead, executionRuntime: executionRuntimeRef.current, @@ -2515,7 +2376,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { subagentParentContext, }); }); - setSessionHistoryWindow(nextHistoryWindow); + setSessionHistoryWindow(resultPlan.nextHistoryWindow); setTopics((prev) => upsertTopicFromSessionDetail( prev, @@ -2529,16 +2390,16 @@ export function useAgentSession(options: UseAgentSessionOptions) { ); logAgentDebug("useAgentSession", "loadFullHistory.success", { durationMs: Date.now() - startedAt, - historyBeforeMessageId, + historyBeforeMessageId: requestPlan.historyBeforeMessageId, historyTruncated: detail.history_truncated === true, - historyOffset: detail.history_offset ?? nextHistoryOffset, - incomingMessagesCount: incomingMessages.length, - loadedMessagesCount: nextLoadedMessages, - messagesCount: mergedMessages.length, - nextHistoryLimit, - nextHistoryOffset, + historyOffset: detail.history_offset ?? requestPlan.nextHistoryOffset, + incomingMessagesCount: mergePlan.incomingMessages.length, + loadedMessagesCount: resultPlan.nextLoadedMessages, + messagesCount: mergePlan.mergedMessages.length, + nextHistoryLimit: requestPlan.nextHistoryLimit, + nextHistoryOffset: requestPlan.nextHistoryOffset, sessionId: targetSessionId, - totalMessagesCount: resolvedTotalMessages, + totalMessagesCount: resultPlan.resolvedTotalMessages, workspaceId, }); return true; @@ -2562,9 +2423,9 @@ export function useAgentSession(options: UseAgentSessionOptions) { { durationMs: Date.now() - startedAt, error, - historyBeforeMessageId, - nextHistoryLimit, - nextHistoryOffset, + historyBeforeMessageId: requestPlan.historyBeforeMessageId, + nextHistoryLimit: requestPlan.nextHistoryLimit, + nextHistoryOffset: requestPlan.nextHistoryOffset, sessionId: targetSessionId, workspaceId, }, diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 65961c69b..028f48c34 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -35,6 +35,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { initialPendingServiceSkillLaunch, initialProjectFileOpenTarget, initialSiteSkillLaunch, + initialKnowledgePackSelection, initialUserImages, initialUserPrompt, openBrowserAssistOnMount = false, @@ -56,6 +57,7 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { Boolean(initialUserImages?.length) || Boolean(initialSiteSkillLaunch) || Boolean(initialPendingServiceSkillLaunch?.skillId?.trim()) || + Boolean(initialKnowledgePackSelection?.packName?.trim()) || Boolean(initialInputCapability?.capabilityRoute) || Boolean(initialProjectFileOpenTarget?.relativePath?.trim()) || openBrowserAssistOnMount; diff --git a/src/components/agent/chat/skill-selection/inputCapabilitySections.ts b/src/components/agent/chat/skill-selection/inputCapabilitySections.ts index 77bacf944..c9d619afd 100644 --- a/src/components/agent/chat/skill-selection/inputCapabilitySections.ts +++ b/src/components/agent/chat/skill-selection/inputCapabilitySections.ts @@ -242,6 +242,8 @@ const INPUT_COMMAND_GROUP_BY_KEY: Record< site_search: "search_read", read_pdf: "search_read", file_read_runtime: "search_read", + knowledge_pack: "search_read", + knowledge_settle: "search_read", summary: "search_read", translation: "search_read", analysis: "search_read", diff --git a/src/components/agent/chat/styles/index.ts b/src/components/agent/chat/styles/index.ts index 2633524f4..ad280c880 100644 --- a/src/components/agent/chat/styles/index.ts +++ b/src/components/agent/chat/styles/index.ts @@ -96,7 +96,7 @@ export const MessageWrapper = styled.div<{ &:hover .message-actions, &:focus-within .message-actions { opacity: 1; - max-height: 40px; + max-height: 48px; margin-top: 8px; transform: translateY(0); pointer-events: auto; @@ -150,12 +150,14 @@ export const MessageActions = styled.div` display: flex; gap: 4px; align-self: flex-end; - max-height: 0; - overflow: hidden; - opacity: 0; - pointer-events: none; - margin-top: 0; - transform: translateY(-4px); + position: relative; + z-index: 5; + max-height: 48px; + overflow: visible; + opacity: 1; + pointer-events: auto; + margin-top: 8px; + transform: translateY(0); transition: opacity 0.18s ease, max-height 0.18s ease, diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index 45d144f3d..8c276b270 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -247,6 +247,9 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { onAddPathReferences?: ComponentProps< typeof EmptyState >["onAddPathReferences"]; + onImportPathReferenceAsKnowledge?: ComponentProps< + typeof EmptyState + >["onImportPathReferenceAsKnowledge"]; onRemovePathReference?: ComponentProps< typeof EmptyState >["onRemovePathReference"]; @@ -378,6 +381,22 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { runtimeToolAvailability?: ComponentProps< typeof EmptyState >["runtimeToolAvailability"]; + knowledgePackSelection?: ComponentProps< + typeof EmptyState + >["knowledgePackSelection"]; + knowledgePackOptions?: ComponentProps["knowledgePackOptions"]; + onToggleKnowledgePack?: ComponentProps< + typeof EmptyState + >["onToggleKnowledgePack"]; + onSelectKnowledgePack?: ComponentProps< + typeof EmptyState + >["onSelectKnowledgePack"]; + onStartKnowledgeOrganize?: ComponentProps< + typeof EmptyState + >["onStartKnowledgeOrganize"]; + onManageKnowledgePacks?: ComponentProps< + typeof EmptyState + >["onManageKnowledgePacks"]; runtimeTaskCard?: ComponentProps["runtimeTaskCard"]; onOpenMemoryWorkbench?: ComponentProps< typeof EmptyState @@ -437,6 +456,7 @@ export function WorkspaceConversationScene({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, @@ -518,6 +538,12 @@ export function WorkspaceConversationScene({ onDismissWorkspaceHint, onOpenSettings, runtimeToolAvailability, + knowledgePackSelection, + knowledgePackOptions, + onToggleKnowledgePack, + onSelectKnowledgePack, + onStartKnowledgeOrganize, + onManageKnowledgePacks, runtimeTaskCard, onOpenMemoryWorkbench, onOpenChannels, @@ -616,6 +642,12 @@ export function WorkspaceConversationScene({ projectId, sessionId, runtimeToolAvailability, + knowledgePackSelection, + knowledgePackOptions, + onToggleKnowledgePack, + onSelectKnowledgePack, + onStartKnowledgeOrganize, + onManageKnowledgePacks, runtimeTaskCard, onOpenMemoryWorkbench, onOpenChannels, @@ -626,6 +658,7 @@ export function WorkspaceConversationScene({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts index 418c10a59..956e3b36f 100644 --- a/src/components/agent/chat/workspace/chatSurfaceProps.ts +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -173,6 +173,22 @@ interface BuildWorkspaceEmptyStatePropsParams { runtimeToolAvailability?: ComponentProps< typeof EmptyState >["runtimeToolAvailability"]; + knowledgePackSelection?: ComponentProps< + typeof EmptyState + >["knowledgePackSelection"]; + knowledgePackOptions?: ComponentProps["knowledgePackOptions"]; + onToggleKnowledgePack?: ComponentProps< + typeof EmptyState + >["onToggleKnowledgePack"]; + onSelectKnowledgePack?: ComponentProps< + typeof EmptyState + >["onSelectKnowledgePack"]; + onStartKnowledgeOrganize?: ComponentProps< + typeof EmptyState + >["onStartKnowledgeOrganize"]; + onManageKnowledgePacks?: ComponentProps< + typeof EmptyState + >["onManageKnowledgePacks"]; runtimeTaskCard?: ComponentProps["runtimeTaskCard"]; onOpenMemoryWorkbench?: ComponentProps< typeof EmptyState @@ -191,6 +207,9 @@ interface BuildWorkspaceEmptyStatePropsParams { onAddPathReferences?: ComponentProps< typeof EmptyState >["onAddPathReferences"]; + onImportPathReferenceAsKnowledge?: ComponentProps< + typeof EmptyState + >["onImportPathReferenceAsKnowledge"]; onRemovePathReference?: ComponentProps< typeof EmptyState >["onRemovePathReference"]; @@ -256,6 +275,12 @@ export function buildWorkspaceEmptyStateProps({ projectId, sessionId, runtimeToolAvailability, + knowledgePackSelection, + knowledgePackOptions, + onToggleKnowledgePack, + onSelectKnowledgePack, + onStartKnowledgeOrganize, + onManageKnowledgePacks, runtimeTaskCard, onOpenMemoryWorkbench, onOpenChannels, @@ -266,6 +291,7 @@ export function buildWorkspaceEmptyStateProps({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, @@ -334,6 +360,12 @@ export function buildWorkspaceEmptyStateProps({ projectId, sessionId, runtimeToolAvailability, + knowledgePackSelection, + knowledgePackOptions, + onToggleKnowledgePack, + onSelectKnowledgePack, + onStartKnowledgeOrganize, + onManageKnowledgePacks, runtimeTaskCard, onOpenMemoryWorkbench, onOpenChannels, @@ -344,6 +376,7 @@ export function buildWorkspaceEmptyStateProps({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, diff --git a/src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts b/src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts index 36b385daa..7c6ba313d 100644 --- a/src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts +++ b/src/components/agent/chat/workspace/generalWorkbenchHelpers.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; +import { createInitialDesignCanvasState } from "@/lib/workspace/workbenchCanvas"; import type { Message } from "../types"; import { applyBackendGeneralWorkbenchDocumentState, buildGeneralWorkbenchWorkflowSteps, + isCanvasStateEmpty, readPersistedGeneralWorkbenchDocument, + serializeCanvasStateForSync, } from "./generalWorkbenchHelpers"; describe("generalWorkbenchHelpers", () => { @@ -236,4 +239,21 @@ describe("generalWorkbenchHelpers", () => { "artifact-document:auto-report:v2": "pending", }); }); + + it("design canvas 同步应序列化 LayeredDesignDocument JSON", () => { + const state = createInitialDesignCanvasState({ + id: "design-sync", + title: "工作区图层设计", + canvas: { width: 1080, height: 1440 }, + layers: [], + assets: [], + createdAt: "2026-05-05T00:00:00.000Z", + }); + + expect(isCanvasStateEmpty(state)).toBe(true); + const serialized = serializeCanvasStateForSync(state); + expect(serialized).toContain('"id": "design-sync"'); + expect(serialized).toContain('"title": "工作区图层设计"'); + expect(serialized).toContain('"schemaVersion"'); + }); }); diff --git a/src/components/agent/chat/workspace/generalWorkbenchHelpers.ts b/src/components/agent/chat/workspace/generalWorkbenchHelpers.ts index 6aeafb4e9..bd5f21947 100644 --- a/src/components/agent/chat/workspace/generalWorkbenchHelpers.ts +++ b/src/components/agent/chat/workspace/generalWorkbenchHelpers.ts @@ -1058,6 +1058,8 @@ export function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean { return !state.content || state.content.trim() === ""; case "video": return !state.prompt || state.prompt.trim() === ""; + case "design": + return state.document.layers.length === 0; default: return true; } @@ -1069,6 +1071,8 @@ export function serializeCanvasStateForSync(state: CanvasStateUnion): string { return state.content || ""; case "video": return state.prompt || ""; + case "design": + return JSON.stringify(state.document, null, 2); default: return JSON.stringify(state); } diff --git a/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.test.ts b/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.test.ts new file mode 100644 index 000000000..f4ec90ead --- /dev/null +++ b/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { + KnowledgePackStatus, + KnowledgePackSummary, +} from "@/lib/api/knowledge"; +import { chooseDefaultKnowledgePack } from "./useWorkspaceKnowledgeRuntime"; + +function buildPack({ + name, + status, + defaultForWorkspace = false, +}: { + name: string; + status: KnowledgePackStatus; + defaultForWorkspace?: boolean; +}): KnowledgePackSummary { + return { + metadata: { + name, + description: name, + type: "custom", + status, + maintainers: [], + }, + rootPath: "/tmp/project", + knowledgePath: `/tmp/project/.lime/knowledge/packs/${name}`, + defaultForWorkspace, + updatedAt: 1, + sourceCount: status === "ready" ? 1 : 0, + wikiCount: 0, + compiledCount: status === "ready" ? 1 : 0, + runCount: 0, + preview: null, + }; +} + +describe("chooseDefaultKnowledgePack", () => { + it("默认资料未确认时应优先选择已确认资料", () => { + const selected = chooseDefaultKnowledgePack([ + buildPack({ + name: "draft-without-source", + status: "needs-review", + defaultForWorkspace: true, + }), + buildPack({ + name: "ready-guide", + status: "ready", + }), + ]); + + expect(selected?.metadata.name).toBe("ready-guide"); + }); + + it("没有已确认资料时才回退到默认草稿,方便用户进入管理确认", () => { + const selected = chooseDefaultKnowledgePack([ + buildPack({ + name: "draft-default", + status: "needs-review", + defaultForWorkspace: true, + }), + buildPack({ + name: "draft-later", + status: "draft", + }), + ]); + + expect(selected?.metadata.name).toBe("draft-default"); + }); +}); diff --git a/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts b/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts index 8ef688180..96deb0328 100644 --- a/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts +++ b/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts @@ -1,17 +1,27 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; -import { listKnowledgePacks, type KnowledgePackSummary } from "@/lib/api/knowledge"; +import { + listKnowledgePacks, + type KnowledgePackSummary, +} from "@/lib/api/knowledge"; +import { + importKnowledgePathSource, + importKnowledgeTextSource, +} from "@/features/knowledge/import/knowledgeSourceImport"; +import { getKnowledgeUnsupportedSourceMessage } from "@/features/knowledge/import/knowledgeSourceSupport"; import { buildKnowledgeOrganizePrompt, normalizeKnowledgeDraftName, } from "@/features/knowledge/agent/knowledgePromptBuilder"; import { buildKnowledgeBuilderMetadata } from "@/features/knowledge/agent/knowledgeMetadata"; +import type { AgentInitialKnowledgePackSelectionParams } from "@/types/page"; import type { InputbarKnowledgePackOption, InputbarKnowledgePackSelection, } from "../../components/Inputbar/types"; import type { HandleSendOptions } from "../../hooks/handleSendTypes"; -import type { MessageImage } from "../../types"; +import type { MessageImage, MessagePathReference } from "../../types"; type KnowledgeExecutionStrategy = "react" | "code_orchestrated" | "auto"; @@ -25,6 +35,22 @@ type WorkspaceKnowledgeHandleSend = ( sendOptions?: HandleSendOptions, ) => void | Promise | boolean; +function isReadyKnowledgePack(pack: KnowledgePackSummary): boolean { + return pack.metadata.status === "ready"; +} + +export function chooseDefaultKnowledgePack( + packs: KnowledgePackSummary[], +): KnowledgePackSummary | null { + return ( + packs.find((pack) => pack.defaultForWorkspace && isReadyKnowledgePack(pack)) ?? + packs.find(isReadyKnowledgePack) ?? + packs.find((pack) => pack.defaultForWorkspace) ?? + packs[0] ?? + null + ); +} + interface UseWorkspaceKnowledgeRuntimeParams { projectRootPath?: string | null; currentSessionTitle?: string | null; @@ -33,6 +59,7 @@ interface UseWorkspaceKnowledgeRuntimeParams { executionStrategy?: KnowledgeExecutionStrategy; handleSend: WorkspaceKnowledgeHandleSend; onOpenKnowledgeManagement?: (workingDir?: string | null) => void; + initialKnowledgePackSelection?: AgentInitialKnowledgePackSelectionParams | null; } interface UseWorkspaceKnowledgeRuntimeResult { @@ -42,6 +69,13 @@ interface UseWorkspaceKnowledgeRuntimeResult { onSelectKnowledgePack: (packName: string) => void; onStartKnowledgeOrganize: () => void; onManageKnowledgePacks?: () => void; + onImportPathReferenceAsKnowledge: (reference: MessagePathReference) => void; + onImportTextAsKnowledge: (source: { + sourceName: string; + sourceText: string; + description?: string | null; + packType?: string | null; + }) => void; } export function useWorkspaceKnowledgeRuntime({ @@ -52,6 +86,7 @@ export function useWorkspaceKnowledgeRuntime({ executionStrategy, handleSend, onOpenKnowledgeManagement, + initialKnowledgePackSelection, }: UseWorkspaceKnowledgeRuntimeParams): UseWorkspaceKnowledgeRuntimeResult { const [knowledgePacks, setKnowledgePacks] = useState( [], @@ -60,9 +95,58 @@ export function useWorkspaceKnowledgeRuntime({ string | null >(null); const [knowledgePackEnabled, setKnowledgePackEnabled] = useState(false); + const initialSelectionPackName = + initialKnowledgePackSelection?.packName.trim() ?? ""; + const initialSelectionWorkingDir = + initialKnowledgePackSelection?.workingDir.trim() ?? ""; + const effectiveProjectRootPath = + projectRootPath?.trim() || initialSelectionWorkingDir; + const initialSelectionMatchesWorkingDir = Boolean( + initialSelectionWorkingDir && + initialSelectionWorkingDir === effectiveProjectRootPath, + ); + const shouldEnableInitialSelection = Boolean( + initialKnowledgePackSelection?.enabled && + initialSelectionPackName && + initialSelectionMatchesWorkingDir, + ); + + const refreshKnowledgePacks = useCallback( + async (workingDir: string, preferredPackName?: string | null) => { + const response = await listKnowledgePacks({ + workingDir, + includeArchived: false, + }); + const nextDefaultPack = chooseDefaultKnowledgePack(response.packs); + + setKnowledgePacks(response.packs); + setSelectedKnowledgePackName((current) => { + const normalizedPreferred = preferredPackName?.trim(); + if ( + normalizedPreferred && + response.packs.some( + (pack) => pack.metadata.name === normalizedPreferred, + ) + ) { + return normalizedPreferred; + } + + if ( + current && + response.packs.some((pack) => pack.metadata.name === current) + ) { + return current; + } + + return nextDefaultPack?.metadata.name ?? null; + }); + return response.packs; + }, + [], + ); useEffect(() => { - const workingDir = projectRootPath?.trim(); + const workingDir = effectiveProjectRootPath; if (!workingDir) { setKnowledgePacks([]); setSelectedKnowledgePackName(null); @@ -71,27 +155,13 @@ export function useWorkspaceKnowledgeRuntime({ } let cancelled = false; - listKnowledgePacks({ workingDir, includeArchived: false }) - .then((response) => { + refreshKnowledgePacks(workingDir, initialSelectionPackName || null) + .then((responsePacks) => { if (cancelled) { return; } - const nextDefaultPack = - response.packs.find((pack) => pack.defaultForWorkspace) ?? - response.packs[0] ?? - null; - setKnowledgePacks(response.packs); - setSelectedKnowledgePackName((current) => { - if ( - current && - response.packs.some((pack) => pack.metadata.name === current) - ) { - return current; - } - - return nextDefaultPack?.metadata.name ?? null; - }); - setKnowledgePackEnabled(false); + void responsePacks; + setKnowledgePackEnabled(shouldEnableInitialSelection); }) .catch((error) => { if (cancelled) { @@ -106,7 +176,12 @@ export function useWorkspaceKnowledgeRuntime({ return () => { cancelled = true; }; - }, [projectRootPath]); + }, [ + effectiveProjectRootPath, + initialSelectionPackName, + refreshKnowledgePacks, + shouldEnableInitialSelection, + ]); const selectedKnowledgePack = useMemo(() => { if (knowledgePacks.length === 0) { @@ -117,47 +192,89 @@ export function useWorkspaceKnowledgeRuntime({ knowledgePacks.find( (pack) => pack.metadata.name === selectedKnowledgePackName, ) ?? - knowledgePacks.find((pack) => pack.defaultForWorkspace) ?? - knowledgePacks[0] ?? - null + chooseDefaultKnowledgePack(knowledgePacks) ); }, [knowledgePacks, selectedKnowledgePackName]); - const knowledgePackOptions = useMemo( - () => - knowledgePacks.map((pack) => ({ + const knowledgePackOptions = useMemo(() => { + const options: InputbarKnowledgePackOption[] = knowledgePacks.map( + (pack) => ({ packName: pack.metadata.name, label: pack.metadata.description || pack.metadata.name, status: pack.metadata.status, defaultForWorkspace: pack.defaultForWorkspace, - })), - [knowledgePacks], - ); + }), + ); - const knowledgePackSelection = useMemo( - () => - selectedKnowledgePack && projectRootPath - ? { - enabled: knowledgePackEnabled, - packName: selectedKnowledgePack.metadata.name, - workingDir: projectRootPath, - label: - selectedKnowledgePack.metadata.description || - selectedKnowledgePack.metadata.name, - status: selectedKnowledgePack.metadata.status, - } - : null, - [selectedKnowledgePack, knowledgePackEnabled, projectRootPath], - ); + if ( + initialSelectionMatchesWorkingDir && + initialSelectionPackName && + !options.some((option) => option.packName === initialSelectionPackName) + ) { + options.push({ + packName: initialSelectionPackName, + label: initialKnowledgePackSelection?.label || initialSelectionPackName, + status: initialKnowledgePackSelection?.status, + defaultForWorkspace: false, + }); + } + + return options; + }, [ + initialKnowledgePackSelection, + initialSelectionMatchesWorkingDir, + initialSelectionPackName, + knowledgePacks, + ]); + + const knowledgePackSelection = useMemo(() => { + if (selectedKnowledgePack && effectiveProjectRootPath) { + return { + enabled: knowledgePackEnabled, + packName: selectedKnowledgePack.metadata.name, + workingDir: effectiveProjectRootPath, + label: + selectedKnowledgePack.metadata.description || + selectedKnowledgePack.metadata.name, + status: selectedKnowledgePack.metadata.status, + }; + } + + if ( + initialSelectionMatchesWorkingDir && + initialSelectionPackName && + effectiveProjectRootPath + ) { + return { + enabled: knowledgePackEnabled || shouldEnableInitialSelection, + packName: initialSelectionPackName, + workingDir: effectiveProjectRootPath, + label: initialKnowledgePackSelection?.label || initialSelectionPackName, + status: initialKnowledgePackSelection?.status, + }; + } + + return null; + }, [ + effectiveProjectRootPath, + initialKnowledgePackSelection, + initialSelectionMatchesWorkingDir, + initialSelectionPackName, + knowledgePackEnabled, + selectedKnowledgePack, + shouldEnableInitialSelection, + ]); const handleSelectKnowledgePack = useCallback((packName: string) => { setSelectedKnowledgePackName(packName); }, []); const handleStartKnowledgeOrganize = useCallback(() => { - const workingDir = projectRootPath?.trim(); + const workingDir = effectiveProjectRootPath; if (!workingDir) { - setInput("请先选择一个项目,然后我会把资料整理成当前项目可复用的项目资料。"); + setInput( + "请先选择一个项目,然后我会把资料整理成当前项目可复用的项目资料。", + ); return; } @@ -194,10 +311,10 @@ export function useWorkspaceKnowledgeRuntime({ ); }, [ currentSessionTitle, + effectiveProjectRootPath, executionStrategy, handleSend, input, - projectRootPath, selectedKnowledgePack, setInput, ]); @@ -205,9 +322,107 @@ export function useWorkspaceKnowledgeRuntime({ const handleManageKnowledgePacks = useMemo( () => onOpenKnowledgeManagement - ? () => onOpenKnowledgeManagement(projectRootPath) + ? () => onOpenKnowledgeManagement(effectiveProjectRootPath) : undefined, - [onOpenKnowledgeManagement, projectRootPath], + [effectiveProjectRootPath, onOpenKnowledgeManagement], + ); + + const handleKnowledgeImportSuccess = useCallback( + async ( + workingDir: string, + packName: string, + description?: string | null, + ) => { + await refreshKnowledgePacks(workingDir, packName); + setKnowledgePackEnabled(false); + toast.success("项目资料已整理,确认后可用于生成", { + description: description || undefined, + }); + }, + [refreshKnowledgePacks], + ); + + const handleImportPathReferenceAsKnowledge = useCallback( + (reference: MessagePathReference) => { + const workingDir = effectiveProjectRootPath; + if (!workingDir) { + toast.error("请先选择一个项目,再添加项目资料。"); + return; + } + if (reference.isDir) { + toast.info( + "文件夹暂时只能添加到对话,请选择 Markdown 或文本文件作为项目资料。", + ); + return; + } + const unsupportedMessage = getKnowledgeUnsupportedSourceMessage(reference); + if (unsupportedMessage) { + toast.info(unsupportedMessage); + return; + } + + toast.info(`正在整理项目资料:${reference.name}`); + void importKnowledgePathSource({ + workingDir, + source: reference, + }) + .then(async (result) => { + await handleKnowledgeImportSuccess( + workingDir, + result.pack.metadata.name, + result.pack.metadata.description, + ); + }) + .catch((error) => { + console.error("导入项目资料失败:", error); + toast.error( + error instanceof Error && error.message.trim() + ? error.message + : "导入项目资料失败,请稍后重试。", + ); + }); + }, + [effectiveProjectRootPath, handleKnowledgeImportSuccess], + ); + + const handleImportTextAsKnowledge = useCallback( + (source: { + sourceName: string; + sourceText: string; + description?: string | null; + packType?: string | null; + }) => { + const workingDir = effectiveProjectRootPath; + if (!workingDir) { + toast.error("请先选择一个项目,再添加项目资料。"); + return; + } + + toast.info(`正在整理项目资料:${source.sourceName}`); + void importKnowledgeTextSource({ + workingDir, + sourceName: source.sourceName, + sourceText: source.sourceText, + description: source.description, + packType: source.packType, + }) + .then(async (result) => { + await handleKnowledgeImportSuccess( + workingDir, + result.pack.metadata.name, + result.pack.metadata.description, + ); + }) + .catch((error) => { + console.error("沉淀项目资料失败:", error); + toast.error( + error instanceof Error && error.message.trim() + ? error.message + : "沉淀项目资料失败,请稍后重试。", + ); + }); + }, + [effectiveProjectRootPath, handleKnowledgeImportSuccess], ); return { @@ -217,5 +432,7 @@ export function useWorkspaceKnowledgeRuntime({ onSelectKnowledgePack: handleSelectKnowledgePack, onStartKnowledgeOrganize: handleStartKnowledgeOrganize, onManageKnowledgePacks: handleManageKnowledgePacks, + onImportPathReferenceAsKnowledge: handleImportPathReferenceAsKnowledge, + onImportTextAsKnowledge: handleImportTextAsKnowledge, }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx index 996bef1ec..e0e042034 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx @@ -691,6 +691,7 @@ function useWorkspaceCanvasPreviewRuntime({ onSelectionTextChange: canvasFactory.onSelectionTextChange, projectId: canvasFactory.projectId, contentId: canvasFactory.contentId, + projectRootPath: defaultPreview.workspaceRoot, autoImageTopic: canvasFactory.autoImageTopic, autoContinueProviderType: canvasFactory.autoContinueProviderType, onAutoContinueProviderTypeChange: @@ -735,6 +736,7 @@ function useWorkspaceCanvasPreviewRuntime({ canvasFactory.preferContentReviewInRightRail, canvasFactory.projectId, canvasFactory.resolvedCanvasState, + defaultPreview.workspaceRoot, ], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index d251cb857..4f6cdc072 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -45,6 +45,12 @@ type InputbarScene = Pick< | "generalWorkbenchDialog" | "teamWorkbenchSurfaceProps" | "runtimeToolAvailability" + | "knowledgePackSelection" + | "knowledgePackOptions" + | "onToggleKnowledgePack" + | "onSelectKnowledgePack" + | "onStartKnowledgeOrganize" + | "onManageKnowledgePacks" >; type CanvasScene = Pick< ReturnType, @@ -303,6 +309,7 @@ interface UseWorkspaceConversationSceneRuntimeParams { defaultCuratedTaskReferenceEntries?: ConversationScenePresentationParams["scene"]["defaultCuratedTaskReferenceEntries"]; pathReferences?: ConversationScenePresentationParams["scene"]["pathReferences"]; onAddPathReferences?: ConversationScenePresentationParams["scene"]["onAddPathReferences"]; + onImportPathReferenceAsKnowledge?: ConversationScenePresentationParams["scene"]["onImportPathReferenceAsKnowledge"]; onRemovePathReference?: ConversationScenePresentationParams["scene"]["onRemovePathReference"]; onClearPathReferences?: ConversationScenePresentationParams["scene"]["onClearPathReferences"]; fileManagerOpen?: ConversationScenePresentationParams["scene"]["fileManagerOpen"]; @@ -426,6 +433,7 @@ interface UseWorkspaceConversationSceneRuntimeParams { handleOpenMessagePreview?: ConversationScenePresentationParams["messageList"]["onOpenMessagePreview"]; handleSaveMessageAsSkill?: ConversationScenePresentationParams["messageList"]["onSaveMessageAsSkill"]; handleSaveMessageAsInspiration?: ConversationScenePresentationParams["messageList"]["onSaveMessageAsInspiration"]; + handleSaveMessageAsKnowledge?: ConversationScenePresentationParams["messageList"]["onSaveMessageAsKnowledge"]; handleOpenSubagentSession: ConversationScenePresentationParams["messageList"]["onOpenSubagentSession"]; handlePermissionResponse: ConversationScenePresentationParams["messageList"]["onPermissionResponse"]; pendingPromotedA2UIActionRequest: unknown; @@ -464,6 +472,7 @@ export function useWorkspaceConversationSceneRuntime({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, @@ -581,6 +590,7 @@ export function useWorkspaceConversationSceneRuntime({ handleOpenMessagePreview, handleSaveMessageAsSkill, handleSaveMessageAsInspiration, + handleSaveMessageAsKnowledge, handleOpenSubagentSession, handlePermissionResponse, pendingPromotedA2UIActionRequest, @@ -1014,6 +1024,7 @@ export function useWorkspaceConversationSceneRuntime({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, @@ -1110,6 +1121,12 @@ export function useWorkspaceConversationSceneRuntime({ ? navigationActions.handleOpenAppearanceSettings : undefined, runtimeToolAvailability: inputbarScene.runtimeToolAvailability, + knowledgePackSelection: inputbarScene.knowledgePackSelection, + knowledgePackOptions: inputbarScene.knowledgePackOptions, + onToggleKnowledgePack: inputbarScene.onToggleKnowledgePack, + onSelectKnowledgePack: inputbarScene.onSelectKnowledgePack, + onStartKnowledgeOrganize: inputbarScene.onStartKnowledgeOrganize, + onManageKnowledgePacks: inputbarScene.onManageKnowledgePacks, runtimeTaskCard, taskCenterTabsNode, onOpenMemoryWorkbench: () => @@ -1216,6 +1233,7 @@ export function useWorkspaceConversationSceneRuntime({ onOpenMessagePreview: handleOpenMessagePreview, onSaveMessageAsSkill: handleSaveMessageAsSkill, onSaveMessageAsInspiration: handleSaveMessageAsInspiration, + onSaveMessageAsKnowledge: handleSaveMessageAsKnowledge, onOpenSubagentSession: handleOpenSubagentSession, onPermissionResponse: handlePermissionResponse, promoteActionRequestsToA2UI: Boolean(pendingPromotedA2UIActionRequest), diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx index fe9339aa8..9ef8c1c5d 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -9,7 +9,10 @@ import { import { Info } from "lucide-react"; import styled from "styled-components"; import type { Character } from "@/lib/api/memory"; -import type { AgentInitialInputCapabilityParams } from "@/types/page"; +import type { + AgentInitialInputCapabilityParams, + AgentInitialKnowledgePackSelectionParams, +} from "@/types/page"; import { Inputbar } from "../components/Inputbar"; import { TeamWorkspaceDock } from "../components/TeamWorkspaceDock"; import { useWorkspaceNavigationActions } from "./useWorkspaceNavigationActions"; @@ -505,6 +508,7 @@ interface UseWorkspaceInputbarSceneRuntimeParams { skillsLoading: InputbarParams["isSkillsLoading"]; onSelectServiceSkill: InputbarParams["onSelectServiceSkill"]; initialInputCapability?: AgentInitialInputCapabilityParams; + initialKnowledgePackSelection?: AgentInitialKnowledgePackSelectionParams; setChatToolPreferences: Dispatch>; handleNavigateToSkillSettings: InputbarParams["onNavigateToSettings"]; handleRefreshSkills: InputbarParams["onRefreshSkills"]; @@ -623,6 +627,7 @@ export function useWorkspaceInputbarSceneRuntime({ skillsLoading, onSelectServiceSkill, initialInputCapability, + initialKnowledgePackSelection, setChatToolPreferences, handleNavigateToSkillSettings, handleRefreshSkills, @@ -679,6 +684,7 @@ export function useWorkspaceInputbarSceneRuntime({ executionStrategy, handleSend, onOpenKnowledgeManagement: navigationActions.handleOpenKnowledgeManagement, + initialKnowledgePackSelection, }); const resolvedChatToolPreferences = chatToolPreferences ?? DEFAULT_CHAT_TOOL_PREFERENCES; @@ -709,7 +715,7 @@ export function useWorkspaceInputbarSceneRuntime({ resolvedTurns[resolvedTurns.length - 1]?.prompt_text?.trim() || ""; - return useWorkspaceInputbarScenePresentationRuntime({ + const presentationRuntime = useWorkspaceInputbarScenePresentationRuntime({ setMentionedCharacters, taskFiles, taskFilesExpanded, @@ -805,6 +811,8 @@ export function useWorkspaceInputbarSceneRuntime({ defaultCuratedTaskReferenceEntries, pathReferences, onAddPathReferences, + onImportPathReferenceAsKnowledge: + knowledgeRuntime.onImportPathReferenceAsKnowledge, onRemovePathReference, onClearPathReferences, fileManagerOpen, @@ -888,4 +896,17 @@ export function useWorkspaceInputbarSceneRuntime({ }, }, }); + + return { + ...presentationRuntime, + knowledgePackSelection: knowledgeRuntime.knowledgePackSelection, + knowledgePackOptions: knowledgeRuntime.knowledgePackOptions, + onToggleKnowledgePack: knowledgeRuntime.onToggleKnowledgePack, + onSelectKnowledgePack: knowledgeRuntime.onSelectKnowledgePack, + onStartKnowledgeOrganize: knowledgeRuntime.onStartKnowledgeOrganize, + onManageKnowledgePacks: knowledgeRuntime.onManageKnowledgePacks, + onImportPathReferenceAsKnowledge: + knowledgeRuntime.onImportPathReferenceAsKnowledge, + onImportTextAsKnowledge: knowledgeRuntime.onImportTextAsKnowledge, + }; } diff --git a/src/components/artifact/ArtifactRenderer.test.ts b/src/components/artifact/ArtifactRenderer.test.ts index 8ff759cc5..0d1f46a47 100644 --- a/src/components/artifact/ArtifactRenderer.test.ts +++ b/src/components/artifact/ArtifactRenderer.test.ts @@ -314,7 +314,7 @@ describe("Property 7: Canvas 类型委托正确性", () => { expect(delegated.canvasType).toBe(expectedCanvasType); // 验证子类型是有效的 Canvas 子类型 - const validCanvasSubtypes = ["document", "video"]; + const validCanvasSubtypes = ["document", "video", "design"]; expect(validCanvasSubtypes).toContain(delegated.canvasType); }), { numRuns: 100 }, diff --git a/src/components/artifact/ArtifactRenderer.tsx b/src/components/artifact/ArtifactRenderer.tsx index cd8918f31..e8a95eaa7 100644 --- a/src/components/artifact/ArtifactRenderer.tsx +++ b/src/components/artifact/ArtifactRenderer.tsx @@ -700,18 +700,6 @@ export const ArtifactRenderer: React.FC = memo( ); } - // 未注册的类型,使用回退渲染器 - if (!entry) { - return ( -
- - {(isStreaming || isCompleting) && ( - - )} -
- ); - } - // Canvas 类型委托给 Canvas 系统 // @requirements 12.1 if (artifactRegistry.isCanvasType(artifact.type)) { @@ -729,6 +717,18 @@ export const ArtifactRenderer: React.FC = memo( ); } + // 未注册的类型,使用回退渲染器 + if (!entry) { + return ( +
+ + {(isStreaming || isCompleting) && ( + + )} +
+ ); + } + // 获取渲染器组件 const RendererComponent = entry.component; diff --git a/src/components/artifact/ArtifactRenderer.ui.test.tsx b/src/components/artifact/ArtifactRenderer.ui.test.tsx index 9fdb8195b..f0acc2c52 100644 --- a/src/components/artifact/ArtifactRenderer.ui.test.tsx +++ b/src/components/artifact/ArtifactRenderer.ui.test.tsx @@ -115,6 +115,51 @@ describe("ArtifactRenderer 空内容态", () => { expect(container.textContent).toContain("workspace/index.ts"); }); + it("canvas:design 应直接委托到图层设计画布,不依赖轻量渲染器注册", async () => { + const container = renderArtifact( + createArtifact({ + type: "canvas:design", + title: "design.json", + status: "complete", + content: JSON.stringify({ + id: "design-artifact-ui", + title: "Artifact 图层海报", + canvas: { width: 1080, height: 1440 }, + layers: [ + { + id: "headline", + name: "标题层", + type: "text", + text: "可编辑标题", + x: 120, + y: 120, + width: 840, + height: 120, + zIndex: 4, + }, + ], + assets: [], + editHistory: [], + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + }), + meta: { + filePath: "workspace/design.json", + filename: "design.json", + }, + }), + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("图层设计 Canvas"); + expect(container.textContent).toContain("LayeredDesignDocument"); + expect(container.textContent).toContain("Artifact 图层海报"); + expect(container.textContent).toContain("标题层"); + }); + it("失败且没有内容时应展示错误解释态", () => { const container = renderArtifact( createArtifact({ diff --git a/src/components/artifact/ArtifactToolbar.test.ts b/src/components/artifact/ArtifactToolbar.test.ts index ea9bad706..da42dfdfb 100644 --- a/src/components/artifact/ArtifactToolbar.test.ts +++ b/src/components/artifact/ArtifactToolbar.test.ts @@ -302,6 +302,7 @@ const VALID_EXTENSIONS: Record = { browser_assist: ["txt"], "canvas:document": ["md"], "canvas:video": ["txt"], + "canvas:design": ["json"], }; /** 语言到扩展名的映射 */ diff --git a/src/components/artifact/ArtifactToolbar.tsx b/src/components/artifact/ArtifactToolbar.tsx index b5e483b97..33db26ed1 100644 --- a/src/components/artifact/ArtifactToolbar.tsx +++ b/src/components/artifact/ArtifactToolbar.tsx @@ -667,6 +667,7 @@ function getMimeType(type: Artifact["type"]): string { react: "text/javascript", "canvas:document": "text/markdown", "canvas:video": "text/plain", + "canvas:design": "application/json", }; return mimeTypes[type] || "text/plain"; diff --git a/src/components/artifact/CanvasAdapter.tsx b/src/components/artifact/CanvasAdapter.tsx index 33bf9b1d9..d79104b66 100644 --- a/src/components/artifact/CanvasAdapter.tsx +++ b/src/components/artifact/CanvasAdapter.tsx @@ -90,7 +90,7 @@ CanvasUnsupportedMessage.displayName = "CanvasUnsupportedMessage"; * Canvas 适配器组件 * * 功能特性: - * - 检测 Canvas 类型 (canvas:document, canvas:video;旧类型仅在边界归一) (Requirement 12.1) + * - 检测 Canvas 类型 (canvas:document, canvas:video, canvas:design) (Requirement 12.1) * - 将 Artifact 内容作为初始状态传递给 Canvas (Requirement 12.2) * - 同步 Canvas 状态变更回 Artifact (Requirement 12.3) * - 支持在完整 Canvas 编辑器模式中打开 (Requirement 12.4) diff --git a/src/components/artifact/README.md b/src/components/artifact/README.md index 629a00c00..dc17af574 100644 --- a/src/components/artifact/README.md +++ b/src/components/artifact/README.md @@ -123,7 +123,7 @@ import { ArtifactRenderer } from '@/components/artifact/ArtifactRenderer'; Canvas 适配器组件,将 Canvas 类型的 Artifact 适配到现有 Canvas 系统。 **功能特性:** -- 检测 Canvas 类型(canvas:document, canvas:video) +- 检测 Canvas 类型(canvas:document, canvas:video, canvas:design) - 将 Artifact 内容作为初始状态传递给 Canvas - 同步 Canvas 状态变更回 Artifact - 支持在完整 Canvas 编辑器模式中打开 @@ -134,8 +134,9 @@ Canvas 适配器组件,将 Canvas 类型的 Artifact 适配到现有 Canvas |--------------|-------------|------| | canvas:document | document | 文档画布 | | canvas:video | video | 视频画布 | +| canvas:design | design | AI 图层化设计画布 | -旧 `canvas:poster / canvas:music / canvas:novel / canvas:script` 只在解析与适配边界做归一,不再作为主链类型暴露。 +旧 `canvas:poster / canvas:music / canvas:novel / canvas:script` 已不再归一到现役 Canvas 类型;需要图层化图片设计时必须使用 `canvas:design` 和 `LayeredDesignDocument`。 **使用示例:** ```tsx diff --git a/src/components/artifact/canvasAdapterUtils.test.ts b/src/components/artifact/canvasAdapterUtils.test.ts new file mode 100644 index 000000000..dac0a8110 --- /dev/null +++ b/src/components/artifact/canvasAdapterUtils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { Artifact } from "@/lib/artifact/types"; +import { + createCanvasStateFromArtifact, + extractCanvasMetadata, + extractContentFromCanvasState, + getCanvasTypeFromArtifact, +} from "./canvasAdapterUtils"; + +const CREATED_AT = 1_775_520_000_000; + +function createArtifact( + type: Artifact["type"] | string, + content: string, +): Artifact { + return { + id: "artifact-design", + type: type as Artifact["type"], + title: "图层设计", + content, + status: "complete", + meta: {}, + position: { start: 0, end: content.length }, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }; +} + +describe("canvasAdapterUtils", () => { + it("canvas:design 应创建 design canvas state,并保留 LayeredDesignDocument", () => { + const content = JSON.stringify({ + id: "design-from-artifact", + title: "Artifact 图层设计", + canvas: { width: 1080, height: 1440 }, + layers: [], + assets: [], + editHistory: [], + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + }); + + const state = createCanvasStateFromArtifact( + createArtifact("canvas:design", content), + ); + + expect(state?.type).toBe("design"); + if (state?.type !== "design") { + throw new Error("expected design canvas state"); + } + expect(state.document.id).toBe("design-from-artifact"); + expect(state.document.title).toBe("Artifact 图层设计"); + expect(extractContentFromCanvasState(state)).toContain( + "design-from-artifact", + ); + expect(extractCanvasMetadata(state)).toMatchObject({ + platform: "layered-design", + designId: "design-from-artifact", + }); + }); + + it("旧 canvas:poster 不应再归一到 document canvas", () => { + expect(getCanvasTypeFromArtifact("canvas:poster")).toBeNull(); + expect( + createCanvasStateFromArtifact(createArtifact("canvas:poster", "")), + ).toBeNull(); + }); +}); diff --git a/src/components/artifact/canvasAdapterUtils.ts b/src/components/artifact/canvasAdapterUtils.ts index 75dfea84c..4b5d09194 100644 --- a/src/components/artifact/canvasAdapterUtils.ts +++ b/src/components/artifact/canvasAdapterUtils.ts @@ -19,6 +19,7 @@ import type { } from "@/lib/workspace/workbenchCanvas"; import { createInitialDocumentState, + createDesignCanvasStateFromContent, createInitialVideoState, } from "@/lib/workspace/workbenchCanvas"; @@ -46,14 +47,15 @@ export interface CanvasMetadata { /** * Artifact Canvas 类型到 Canvas 系统类型的映射 - * 旧类型仅在这里做一次归一化,不再作为主链合法类型继续传播。 + * 旧专用 Canvas 类型不再归一到现役类型,避免继续扩展旧主题面。 */ export const ARTIFACT_TO_CANVAS_TYPE: Record< - Extract, + Extract, CanvasType > = { "canvas:document": "document", "canvas:video": "video", + "canvas:design": "design", }; /** @@ -62,6 +64,7 @@ export const ARTIFACT_TO_CANVAS_TYPE: Record< export const CANVAS_TYPE_LABELS: Record = { document: "文档", video: "视频", + design: "图层设计", }; /** @@ -70,6 +73,7 @@ export const CANVAS_TYPE_LABELS: Record = { export const CANVAS_TYPE_ICONS: Record = { document: "📄", video: "🎞️", + design: "🧩", }; // ============================================================================ @@ -91,7 +95,8 @@ export function getCanvasTypeFromArtifact( : artifactType; if ( normalizedType !== "canvas:document" && - normalizedType !== "canvas:video" + normalizedType !== "canvas:video" && + normalizedType !== "canvas:design" ) { return null; } @@ -143,6 +148,8 @@ export function createCanvasStateFromArtifact( } case "video": return createInitialVideoState(content); + case "design": + return createDesignCanvasStateFromContent(content); default: return null; } @@ -160,6 +167,8 @@ export function extractContentFromCanvasState(state: CanvasStateUnion): string { return (state as DocumentCanvasState).content; case "video": return state.prompt; + case "design": + return JSON.stringify(state.document, null, 2); default: return ""; } @@ -180,6 +189,11 @@ export function extractCanvasMetadata(state: CanvasStateUnion): CanvasMetadata { case "document": metadata.platform = (state as DocumentCanvasState).platform; break; + case "design": + metadata.platform = "layered-design"; + metadata.schemaVersion = state.document.schemaVersion; + metadata.designId = state.document.id; + break; } return metadata; diff --git a/src/components/skills/SkillsWorkspacePage.test.tsx b/src/components/skills/SkillsWorkspacePage.test.tsx index dc3bc8f7c..d914f8477 100644 --- a/src/components/skills/SkillsWorkspacePage.test.tsx +++ b/src/components/skills/SkillsWorkspacePage.test.tsx @@ -26,6 +26,9 @@ const mockRefreshLocalSkills = vi.fn(); const mockAdvancedSkillsPage = vi.fn(); const mockToastSuccess = vi.fn(); const mockToastError = vi.fn(); +const mockGetProject = vi.fn(); +const mockListCapabilityDrafts = vi.fn(); +const mockListRegisteredSkills = vi.fn(); function createDefaultLocalSkills(): Skill[] { return [ @@ -222,6 +225,20 @@ vi.mock("@/lib/api/unifiedMemory", () => ({ listUnifiedMemories: mockListUnifiedMemories, })); +vi.mock("@/lib/api/project", () => ({ + getProject: (...args: unknown[]) => mockGetProject(...args), +})); + +vi.mock("@/lib/api/capabilityDrafts", () => ({ + capabilityDraftsApi: { + list: (...args: unknown[]) => mockListCapabilityDrafts(...args), + verify: vi.fn(), + register: vi.fn(), + listRegisteredSkills: (...args: unknown[]) => + mockListRegisteredSkills(...args), + }, +})); + vi.mock("./SkillsPage", () => ({ SkillsPage: (props: Record) => { mockAdvancedSkillsPage(props); @@ -343,6 +360,12 @@ describe("SkillsWorkspacePage", () => { mockAdvancedSkillsPage.mockReset(); mockToastSuccess.mockReset(); mockToastError.mockReset(); + mockGetProject.mockReset(); + mockGetProject.mockReturnValue(new Promise(() => {})); + mockListCapabilityDrafts.mockReset(); + mockListCapabilityDrafts.mockResolvedValue([]); + mockListRegisteredSkills.mockReset(); + mockListRegisteredSkills.mockResolvedValue([]); mockListUnifiedMemories.mockResolvedValue([]); window.localStorage.clear(); }); @@ -481,6 +504,143 @@ describe("SkillsWorkspacePage", () => { expect(banner?.textContent).toContain("查看全部做法"); }); + it("应在我的方法工作台保留未验证能力草案隔离区", async () => { + mockGetProject.mockResolvedValueOnce({ + id: "project-review", + name: "复盘项目", + workspaceType: "general", + rootPath: "/tmp/lime/project-review", + isDefault: false, + createdAt: Date.now(), + updatedAt: Date.now(), + isFavorite: false, + isArchived: false, + tags: [], + }); + mockListCapabilityDrafts.mockResolvedValueOnce([ + { + draftId: "capdraft-1", + name: "竞品监控草案", + description: "每天汇总竞品价格和上新变化。", + userGoal: "持续监控竞品爆款并产出待复核清单。", + sourceKind: "manual", + sourceRefs: ["docs/research/creaoai"], + permissionSummary: ["Level 0 只读发现"], + generatedFiles: [ + { relativePath: "SKILL.md", byteLength: 32, sha256: "abc" }, + ], + verificationStatus: "unverified", + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + draftRoot: + "/tmp/lime/project-review/.lime/capability-drafts/capdraft-1", + manifestPath: + "/tmp/lime/project-review/.lime/capability-drafts/capdraft-1/manifest.json", + }, + ]); + + const { container } = renderPage({ + creationProjectId: "project-review", + highlightCapabilityDraftId: "capdraft-1", + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockGetProject).toHaveBeenCalledWith("project-review"); + expect(mockListCapabilityDrafts).toHaveBeenCalledWith({ + workspaceRoot: "/tmp/lime/project-review", + }); + expect(container.textContent).toContain("能力草案"); + expect(container.textContent).toContain("竞品监控草案"); + expect(container.textContent).toContain("未验证"); + expect(container.textContent).toContain("当前没有运行、注册或自动化入口"); + const forbiddenActionButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("注册成方法")); + expect(forbiddenActionButton).toBeUndefined(); + expect(container.textContent).not.toContain("立即运行"); + }); + + it("应在我的方法工作台展示 Workspace 已注册能力,但不接默认运行入口", async () => { + mockGetProject.mockResolvedValueOnce({ + id: "project-review", + name: "复盘项目", + workspaceType: "general", + rootPath: "/tmp/lime/project-review", + isDefault: false, + createdAt: Date.now(), + updatedAt: Date.now(), + isFavorite: false, + isArchived: false, + tags: [], + }); + mockListCapabilityDrafts.mockResolvedValueOnce([]); + mockListRegisteredSkills.mockResolvedValueOnce([ + { + key: "workspace:capability-report", + name: "只读 CLI 报告", + description: "把本地只读 CLI 输出整理成 Markdown 报告。", + directory: "capability-report", + registeredSkillDirectory: + "/tmp/lime/project-review/.agents/skills/capability-report", + registration: { + registrationId: "capreg-1", + registeredAt: "2026-05-05T01:10:00.000Z", + skillDirectory: "capability-report", + registeredSkillDirectory: + "/tmp/lime/project-review/.agents/skills/capability-report", + sourceDraftId: "capdraft-1", + sourceVerificationReportId: "capver-1", + generatedFileCount: 4, + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + }, + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + metadata: {}, + allowedTools: [], + resourceSummary: { + hasScripts: true, + hasReferences: false, + hasAssets: false, + }, + standardCompliance: { + isStandard: true, + validationErrors: [], + deprecatedFields: [], + }, + launchEnabled: false, + runtimeGate: + "已注册为 Workspace 本地 Skill 包;进入运行前还需要 P3B runtime binding 与 tool_runtime 授权。", + }, + ]); + + const { container } = renderPage({ + creationProjectId: "project-review", + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockListRegisteredSkills).toHaveBeenCalledWith({ + workspaceRoot: "/tmp/lime/project-review", + }); + expect(container.textContent).toContain("Workspace 已注册能力"); + expect(container.textContent).toContain("只读 CLI 报告"); + expect(container.textContent).toContain("待 runtime gate"); + expect(container.textContent).toContain("capdraft-1 / capver-1"); + expect(container.textContent).toContain("tool_runtime 授权"); + const registeredPanel = container.querySelector( + '[data-testid="workspace-registered-skills-panel"]', + ); + expect(registeredPanel?.textContent).not.toContain("立即运行"); + expect(registeredPanel?.textContent).not.toContain("创建自动化"); + expect(registeredPanel?.textContent).not.toContain("继续这套方法"); + }); + it("最近保存到灵感库的成果信号应影响技能页的结果模板推荐", () => { recordCuratedTaskRecommendationSignalFromMemory( { diff --git a/src/components/skills/SkillsWorkspacePage.tsx b/src/components/skills/SkillsWorkspacePage.tsx index 7cf90de73..f60c1942b 100644 --- a/src/components/skills/SkillsWorkspacePage.tsx +++ b/src/components/skills/SkillsWorkspacePage.tsx @@ -89,6 +89,11 @@ import { } from "@/components/agent/chat/skill-selection/slashEntryUsage"; import { buildServiceSkillLaunchPrefillSummary } from "@/components/agent/chat/service-skills/serviceSkillLaunchPrefill"; import { resolveSceneAppsPageEntryParams } from "@/lib/sceneapp"; +import { getProject } from "@/lib/api/project"; +import { + CapabilityDraftPanel, + WorkspaceRegisteredSkillsPanel, +} from "@/features/capability-drafts"; interface SkillsWorkspacePageProps { onNavigate: (page: Page, params?: PageParams) => void; @@ -267,6 +272,14 @@ export function SkillsWorkspacePage({ const [consumedScaffoldRequestKey, setConsumedScaffoldRequestKey] = useState< number | null >(null); + const [capabilityDraftWorkspaceRoot, setCapabilityDraftWorkspaceRoot] = + useState(null); + const [capabilityDraftProjectLoading, setCapabilityDraftProjectLoading] = + useState(false); + const [capabilityDraftProjectError, setCapabilityDraftProjectError] = + useState(null); + const [registeredSkillsRefreshSignal, setRegisteredSkillsRefreshSignal] = + useState(0); const lastHandledScaffoldRequestKeyRef = useRef(null); const installedLocalSkills = useMemo(() => { @@ -311,6 +324,8 @@ export function SkillsWorkspacePage({ [selectedGroupKey, skillGroups], ); const creationProjectId = pageParams?.creationProjectId?.trim() || undefined; + const highlightedCapabilityDraftId = + pageParams?.highlightCapabilityDraftId?.trim() || undefined; const scaffoldCreationReplay = useMemo(() => { if (!pageParams?.initialScaffoldDraft) { return undefined; @@ -362,6 +377,49 @@ export function SkillsWorkspacePage({ setAdvancedManagerOpen(true); }, [pageParams?.initialScaffoldDraft, pageParams?.initialScaffoldRequestKey]); + useEffect(() => { + let cancelled = false; + + if (!creationProjectId) { + setCapabilityDraftWorkspaceRoot(null); + setCapabilityDraftProjectError(null); + setCapabilityDraftProjectLoading(false); + return () => { + cancelled = true; + }; + } + + setCapabilityDraftProjectLoading(true); + setCapabilityDraftProjectError(null); + void getProject(creationProjectId) + .then((project) => { + if (cancelled) { + return; + } + const rootPath = project?.rootPath?.trim() || null; + setCapabilityDraftWorkspaceRoot(rootPath); + setCapabilityDraftProjectError( + rootPath ? null : "当前项目没有可用的本地目录", + ); + }) + .catch((error) => { + if (cancelled) { + return; + } + setCapabilityDraftWorkspaceRoot(null); + setCapabilityDraftProjectError(String(error)); + }) + .finally(() => { + if (!cancelled) { + setCapabilityDraftProjectLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [creationProjectId]); + const handleBringScaffoldToCreation = useCallback( (draft: SkillScaffoldDraft) => { const seed = buildSkillScaffoldCreationSeed(draft); @@ -1499,6 +1557,23 @@ export function SkillsWorkspacePage({ )} + + setRegisteredSkillsRefreshSignal((previous) => previous + 1) + } + /> + + +
= memo( onSelectionTextChange, projectId, contentId, + projectRootPath, autoImageTopic, autoContinueProviderType, onAutoContinueProviderTypeChange, @@ -159,6 +164,20 @@ export const CanvasFactory: React.FC = memo( ); } + if (canvasType === "design" && state.type === "design") { + return ( + void} + onBackHome={resolvedBackHome} + onClose={onClose} + projectRootPath={projectRootPath} + projectId={projectId} + contentId={contentId} + /> + ); + } + // 不支持的主题或状态类型不匹配 return null; }, diff --git a/src/components/workspace/canvas/canvasUtils.test.ts b/src/components/workspace/canvas/canvasUtils.test.ts index 5bc1fa8d2..79cc59798 100644 --- a/src/components/workspace/canvas/canvasUtils.test.ts +++ b/src/components/workspace/canvas/canvasUtils.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { + createInitialDesignCanvasState, createInitialCanvasState, getCanvasTypeForTheme, isCanvasSupported, @@ -36,3 +37,20 @@ describe("createInitialCanvasState", () => { expect(state?.type).toBe("document"); }); }); + +describe("createInitialDesignCanvasState", () => { + it("应创建 AI 图层化设计画布状态", () => { + const state = createInitialDesignCanvasState({ + id: "design-canvas-state", + title: "图层设计", + canvas: { width: 1080, height: 1440 }, + layers: [], + assets: [], + createdAt: "2026-05-05T00:00:00.000Z", + }); + + expect(state.type).toBe("design"); + expect(state.document.id).toBe("design-canvas-state"); + expect(state.document.title).toBe("图层设计"); + }); +}); diff --git a/src/components/workspace/canvas/canvasUtils.ts b/src/components/workspace/canvas/canvasUtils.ts index bed27919d..035e1c85e 100644 --- a/src/components/workspace/canvas/canvasUtils.ts +++ b/src/components/workspace/canvas/canvasUtils.ts @@ -11,16 +11,21 @@ import { } from "@/components/workspace/document/types"; import { createInitialVideoState } from "@/components/workspace/video/types"; import type { VideoCanvasState } from "@/components/workspace/video/types"; +import type { DesignCanvasState } from "@/components/workspace/design/types"; +import { createInitialDesignCanvasState } from "@/components/workspace/design/types"; /** * 画布状态联合类型 */ -export type CanvasStateUnion = DocumentCanvasState | VideoCanvasState; +export type CanvasStateUnion = + | DocumentCanvasState + | VideoCanvasState + | DesignCanvasState; /** * 画布类型 */ -export type CanvasType = "document" | "video"; +export type CanvasType = "document" | "video" | "design"; /** * 主题到画布类型的映射 @@ -62,7 +67,12 @@ export function createInitialCanvasState( return createInitialDocumentState(content || ""); case "video": return createInitialVideoState(content); + case "design": + return createInitialDesignCanvasState(); default: return null; } } + +export { createInitialDesignCanvasState }; +export type { DesignCanvasState }; diff --git a/src/components/workspace/design/DesignCanvas.test.tsx b/src/components/workspace/design/DesignCanvas.test.tsx new file mode 100644 index 000000000..33a6d101d --- /dev/null +++ b/src/components/workspace/design/DesignCanvas.test.tsx @@ -0,0 +1,451 @@ +import React, { useState } 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 { MediaTaskArtifactOutput } from "@/lib/api/mediaTasks"; +import { + createImageLayer, + createSingleLayerAssetGenerationRequest, + createTextLayer, + recordLayeredDesignImageTaskSubmissions, +} from "@/lib/layered-design"; +import type { + GeneratedDesignAsset, + LayeredDesignDocument, +} from "@/lib/layered-design"; +import { createLayeredDesignDocument } from "@/lib/layered-design"; +import { DesignCanvas } from "./DesignCanvas"; +import type { DesignCanvasProps, DesignCanvasState } from "./types"; + +interface MountedCanvas { + container: HTMLDivElement; + root: Root; + readState: () => DesignCanvasState; +} + +const mountedCanvases: MountedCanvas[] = []; +const CREATED_AT = "2026-05-05T00:00:00.000Z"; + +type DesignCanvasTestProps = Omit< + Partial, + "state" | "onStateChange" +>; + +function createAsset(id: string): GeneratedDesignAsset { + return { + id, + kind: "subject", + src: "", + width: 512, + height: 512, + hasAlpha: true, + provider: "test-provider", + modelId: "test-model", + createdAt: CREATED_AT, + }; +} + +function createDocument(): LayeredDesignDocument { + return createLayeredDesignDocument({ + id: "design-test", + title: "图层化海报", + canvas: { width: 1080, height: 1440, backgroundColor: "#f8fafc" }, + layers: [ + createImageLayer({ + id: "subject", + name: "角色层", + type: "image", + assetId: "asset-subject", + x: 120, + y: 240, + width: 640, + height: 840, + zIndex: 2, + source: "generated", + }), + createTextLayer({ + id: "headline", + name: "标题层", + type: "text", + text: "冥界女巫", + x: 160, + y: 120, + width: 760, + height: 140, + zIndex: 8, + source: "planned", + }), + ], + assets: [createAsset("asset-subject")], + preview: { + assetId: "asset-preview", + src: "/preview.png", + width: 1080, + height: 1440, + updatedAt: CREATED_AT, + stale: false, + }, + createdAt: CREATED_AT, + }); +} + +function createImageTaskOutput( + taskId: string, + result?: MediaTaskArtifactOutput["record"]["result"], +): MediaTaskArtifactOutput { + return { + success: true, + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: result ? "succeeded" : "pending_submit", + normalized_status: result ? "succeeded" : "pending", + path: `.lime/tasks/image_generate/${taskId}.json`, + absolute_path: `/workspace/.lime/tasks/image_generate/${taskId}.json`, + artifact_path: `.lime/tasks/image_generate/${taskId}.json`, + absolute_artifact_path: `/workspace/.lime/tasks/image_generate/${taskId}.json`, + reused_existing: false, + record: { + task_id: taskId, + task_type: "image_generate", + task_family: "image", + payload: { + prompt: "生成角色层", + provider_id: "openai", + model: "gpt-image-2", + }, + status: result ? "succeeded" : "pending_submit", + normalized_status: result ? "succeeded" : "pending", + created_at: "2026-05-05T01:00:00.000Z", + updated_at: "2026-05-05T01:00:00.000Z", + result, + }, + }; +} + +function StatefulCanvas({ + initialState, + canvasProps = {}, +}: { + initialState: DesignCanvasState; + canvasProps?: DesignCanvasTestProps; +}) { + const [state, setState] = useState(initialState); + (globalThis as typeof globalThis & { __designCanvasState?: DesignCanvasState }) + .__designCanvasState = state; + + return ( + + ); +} + +function renderDesignCanvas( + initialState: DesignCanvasState = { + type: "design", + document: createDocument(), + selectedLayerId: "headline", + zoom: 0.72, + }, + canvasProps: DesignCanvasTestProps = {}, +): MountedCanvas { + const container = document.createElement("div"); + Object.defineProperty(container, "clientWidth", { + configurable: true, + value: 1200, + }); + Object.defineProperty(container, "clientHeight", { + configurable: true, + value: 800, + }); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const mounted: MountedCanvas = { + container, + root, + readState: () => + (globalThis as typeof globalThis & { + __designCanvasState: DesignCanvasState; + }).__designCanvasState, + }; + mountedCanvases.push(mounted); + return mounted; +} + +function clickButton(label: string) { + const button = Array.from(document.querySelectorAll("button")).find( + (item) => item.textContent?.includes(label), + ); + expect(button).toBeDefined(); + act(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +async function clickButtonAsync(label: string) { + const button = Array.from(document.querySelectorAll("button")).find( + (item) => item.textContent?.includes(label), + ); + expect(button).toBeDefined(); + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function createDocumentWithPendingImageTask(): LayeredDesignDocument { + const document = createDocument(); + const generationRequest = createSingleLayerAssetGenerationRequest( + document, + "subject", + ); + + return recordLayeredDesignImageTaskSubmissions( + document, + [ + { + generationRequest, + taskRequest: { + projectRootPath: "/workspace", + prompt: generationRequest.prompt, + title: "图层化海报 · 角色层", + mode: "generate", + size: "512x512", + aspectRatio: "1:1", + count: 1, + entrySource: "layered_design_canvas", + slotId: "subject", + targetOutputId: "asset-subject", + targetOutputRefId: generationRequest.id, + }, + output: createImageTaskOutput("task-subject"), + }, + ], + { recordedAt: "2026-05-05T02:00:00.000Z" }, + ); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + __designCanvasState?: DesignCanvasState; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedCanvases.length > 0) { + const mounted = mountedCanvases.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + delete (globalThis as { __designCanvasState?: DesignCanvasState }) + .__designCanvasState; +}); + +describe("DesignCanvas", () => { + it("应渲染图层栏、画布和属性栏,并展示 LayeredDesignDocument 信息", () => { + renderDesignCanvas(); + + expect(document.body.textContent).toContain("LayeredDesignDocument"); + expect(document.body.textContent).toContain("图层化海报"); + expect(document.body.textContent).toContain("角色层"); + expect(document.body.textContent).toContain("标题层"); + expect(document.body.textContent).toContain("1080 x 1440"); + }); + + it("应暴露 design.json、assets manifest 与 PNG 的设计工程导出入口", () => { + renderDesignCanvas(); + + expect(document.body.textContent).toContain("导出设计工程"); + expect(document.body.textContent).not.toContain("PNG 导出待接入"); + }); + + it("移动图层应回写 LayeredDesignDocument,并把 preview 标记为 stale", () => { + const mounted = renderDesignCanvas(); + + clickButton("右移"); + + const updatedLayer = mounted + .readState() + .document.layers.find((layer) => layer.id === "headline"); + expect(updatedLayer?.x).toBe(170); + expect(mounted.readState().document.preview?.stale).toBe(true); + expect(mounted.readState().document.editHistory.at(-1)?.type).toBe( + "transform_updated", + ); + }); + + it("隐藏图层应回写文档 visible 状态,而不是只隐藏前端 DOM", () => { + const mounted = renderDesignCanvas(); + + clickButton("隐藏"); + + const updatedLayer = mounted + .readState() + .document.layers.find((layer) => layer.id === "headline"); + expect(updatedLayer?.visible).toBe(false); + expect(mounted.readState().document.editHistory.at(-1)).toMatchObject({ + type: "visibility_updated", + layerId: "headline", + previousVisible: true, + nextVisible: false, + }); + }); + + it("生成全部图片层应提交现有 image task,并把请求记录回文档", async () => { + const createImageTaskArtifact = vi + .fn() + .mockResolvedValue(createImageTaskOutput("task-subject")); + const mounted = renderDesignCanvas(undefined, { + projectRootPath: "/workspace", + projectId: "project-1", + contentId: "content-1", + createImageTaskArtifact, + }); + + await clickButtonAsync("生成全部图片层"); + + expect(createImageTaskArtifact).toHaveBeenCalledWith( + expect.objectContaining({ + projectRootPath: "/workspace", + entrySource: "layered_design_canvas", + modalityContractKey: "image_generation", + routingSlot: "image_generation_model", + slotId: "subject", + targetOutputId: "asset-subject", + targetOutputRefId: "design-test:subject:asset-subject", + projectId: "project-1", + contentId: "content-1", + }), + ); + expect(JSON.stringify(createImageTaskArtifact.mock.calls[0][0])).not.toMatch( + /poster_generate|canvas:poster/, + ); + expect(mounted.readState().document.editHistory.at(-1)).toMatchObject({ + type: "asset_generation_requested", + layerId: "subject", + nextAssetId: "asset-subject", + }); + expect(document.body.textContent).toContain("已提交 1 个图片任务"); + }); + + it("重生成当前图片层应写回生成资产,并保持文字层可编辑", async () => { + const createImageTaskArtifact = vi.fn().mockResolvedValue( + createImageTaskOutput("task-subject", { + images: [ + { + url: "data:image/png;base64,ZmFrZS1zdWJqZWN0", + revised_prompt: "重生成角色层", + }, + ], + }), + ); + const mounted = renderDesignCanvas( + { + type: "design", + document: createDocument(), + selectedLayerId: "subject", + zoom: 0.72, + }, + { + projectRootPath: "/workspace", + createImageTaskArtifact, + }, + ); + + await clickButtonAsync("重生成当前层"); + + const updatedState = mounted.readState(); + expect( + updatedState.document.layers.find((layer) => layer.id === "subject"), + ).toMatchObject({ + type: "image", + assetId: "asset-subject-generated-task-subject", + source: "generated", + x: 120, + y: 240, + width: 640, + height: 840, + }); + expect( + updatedState.document.layers.find((layer) => layer.id === "headline"), + ).toMatchObject({ + type: "text", + text: "冥界女巫", + }); + expect(updatedState.document.assets.at(-1)).toMatchObject({ + id: "asset-subject-generated-task-subject", + src: "data:image/png;base64,ZmFrZS1zdWJqZWN0", + provider: "openai", + modelId: "gpt-image-2", + }); + expect(document.body.textContent).toContain("写回 1 个已完成结果"); + }); + + it("刷新生成结果应恢复已提交任务,并把成功输出写回目标图片层", async () => { + const getImageTaskArtifact = vi.fn().mockResolvedValue( + createImageTaskOutput("task-subject", { + images: [ + { + url: "data:image/png;base64,cmVmcmVzaGVk", + revised_prompt: "刷新回来的角色层", + }, + ], + }), + ); + const mounted = renderDesignCanvas( + { + type: "design", + document: createDocumentWithPendingImageTask(), + selectedLayerId: "subject", + zoom: 0.72, + }, + { + projectRootPath: "/workspace", + getImageTaskArtifact, + }, + ); + + await clickButtonAsync("刷新生成结果"); + + expect(getImageTaskArtifact).toHaveBeenCalledWith({ + projectRootPath: "/workspace", + taskRef: ".lime/tasks/image_generate/task-subject.json", + }); + expect( + mounted + .readState() + .document.layers.find((layer) => layer.id === "subject"), + ).toMatchObject({ + type: "image", + assetId: "asset-subject-generated-task-subject", + source: "generated", + }); + expect( + mounted + .readState() + .document.layers.find((layer) => layer.id === "headline"), + ).toMatchObject({ + type: "text", + text: "冥界女巫", + }); + expect(document.body.textContent).toContain( + "已刷新 1 个图片任务,并写回 1 个图层结果", + ); + expect(JSON.stringify(mounted.readState().document)).not.toMatch( + /poster_generate|canvas:poster/, + ); + }); +}); diff --git a/src/components/workspace/design/DesignCanvas.tsx b/src/components/workspace/design/DesignCanvas.tsx new file mode 100644 index 000000000..84c4db945 --- /dev/null +++ b/src/components/workspace/design/DesignCanvas.tsx @@ -0,0 +1,1135 @@ +import React, { memo, useMemo, useState } from "react"; +import styled from "styled-components"; +import { + applyLayeredDesignImageTaskOutput, + createLayeredDesignAssetGenerationPlan, + createLayeredDesignExportBundle, + createLayeredDesignImageTaskArtifacts, + createLayeredDesignPreviewSvgDataUrl, + createSingleLayerAssetGenerationRequest, + isImageDesignLayer, + listPendingLayeredDesignImageTasks, + recordLayeredDesignImageTaskSubmissions, + refreshLayeredDesignImageTaskResults, + sortDesignLayers, + updateLayerLock, + updateLayerTransform, + updateLayerVisibility, +} from "@/lib/layered-design"; +import type { + DesignLayer, + GeneratedDesignAsset, + GroupLayer, + ImageLayer, + LayeredDesignExportFile, + ShapeLayer, + TextLayer, +} from "@/lib/layered-design"; +import type { DesignCanvasProps, DesignCanvasState } from "./types"; + +const Shell = styled.div` + display: grid; + grid-template-columns: minmax(190px, 240px) minmax(0, 1fr) minmax(230px, 280px); + height: 100%; + min-height: 0; + width: 100%; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + + @media (max-width: 1080px) { + grid-template-columns: minmax(160px, 200px) minmax(0, 1fr); + } + + @media (max-width: 760px) { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(360px, 1fr) auto; + } +`; + +const Rail = styled.aside` + display: flex; + min-height: 0; + flex-direction: column; + border-right: 1px solid hsl(var(--border)); + background: hsl(var(--card)); +`; + +const Inspector = styled.aside` + display: flex; + min-height: 0; + flex-direction: column; + border-left: 1px solid hsl(var(--border)); + background: hsl(var(--card)); + + @media (max-width: 1080px) { + grid-column: 1 / -1; + border-left: none; + border-top: 1px solid hsl(var(--border)); + } +`; + +const PanelHeader = styled.div` + padding: 14px 16px 12px; + border-bottom: 1px solid hsl(var(--border)); +`; + +const Eyebrow = styled.div` + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.2; +`; + +const Title = styled.h2` + margin: 4px 0 0; + font-size: 16px; + font-weight: 650; +`; + +const LayerList = styled.div` + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 8px; + overflow: auto; + padding: 12px; +`; + +const LayerButton = styled.button<{ $selected: boolean }>` + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + width: 100%; + border: 1px solid + ${({ $selected }) => + $selected ? "hsl(var(--foreground))" : "hsl(var(--border))"}; + border-radius: 14px; + background: ${({ $selected }) => + $selected ? "hsl(var(--accent))" : "hsl(var(--background))"}; + color: hsl(var(--foreground)); + cursor: pointer; + padding: 9px 10px; + text-align: left; +`; + +const LayerIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 9px; + background: hsl(var(--muted)); + font-size: 13px; +`; + +const LayerName = styled.span` + overflow: hidden; + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const LayerMeta = styled.span` + color: hsl(var(--muted-foreground)); + font-size: 11px; +`; + +const StageColumn = styled.main` + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: + radial-gradient(circle at 18% 12%, rgba(14, 165, 233, 0.1), transparent 28%), + linear-gradient(180deg, hsl(var(--muted)) 0%, hsl(var(--background)) 62%); +`; + +const Toolbar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 58px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + padding: 10px 14px; +`; + +const ToolbarTitle = styled.div` + min-width: 0; +`; + +const ToolbarHeading = styled.div` + overflow: hidden; + font-size: 15px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ToolbarMeta = styled.div` + margin-top: 2px; + color: hsl(var(--muted-foreground)); + font-size: 12px; +`; + +const ToolbarActions = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +`; + +const Button = styled.button<{ $primary?: boolean }>` + border: 1px solid + ${({ $primary }) => + $primary ? "hsl(var(--foreground))" : "hsl(var(--border))"}; + border-radius: 999px; + background: ${({ $primary }) => + $primary ? "hsl(var(--foreground))" : "hsl(var(--background))"}; + color: ${({ $primary }) => + $primary ? "hsl(var(--background))" : "hsl(var(--foreground))"}; + cursor: pointer; + font-size: 12px; + font-weight: 600; + padding: 7px 11px; + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } +`; + +const StageViewport = styled.div` + display: flex; + min-height: 0; + flex: 1; + align-items: center; + justify-content: center; + overflow: auto; + padding: 28px; +`; + +const StageFrame = styled.div<{ + $aspectRatio: string; + $backgroundColor: string; + $zoom: number; +}>` + position: relative; + width: min(100%, ${({ $zoom }) => Math.round(760 * $zoom)}px); + aspect-ratio: ${({ $aspectRatio }) => $aspectRatio}; + border: 1px solid hsl(var(--border)); + border-radius: 22px; + background: ${({ $backgroundColor }) => $backgroundColor}; + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.16); + overflow: hidden; +`; + +const CanvasLayer = styled.button<{ $selected: boolean; $locked: boolean }>` + position: absolute; + display: block; + border: 1px solid + ${({ $selected }) => + $selected ? "rgba(15, 23, 42, 0.95)" : "rgba(148, 163, 184, 0.2)"}; + border-radius: 10px; + background: transparent; + cursor: ${({ $locked }) => ($locked ? "not-allowed" : "pointer")}; + outline: none; + overflow: hidden; + padding: 0; + box-shadow: ${({ $selected }) => + $selected ? "0 0 0 3px rgba(14, 165, 233, 0.18)" : "none"}; +`; + +const EmptyAsset = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: + linear-gradient(135deg, rgba(15, 23, 42, 0.08) 25%, transparent 25%) 0 0 / + 18px 18px, + linear-gradient(135deg, transparent 75%, rgba(15, 23, 42, 0.08) 75%) 0 0 / + 18px 18px, + #f8fafc; + color: #64748b; + font-size: 12px; +`; + +const Image = styled.img` + display: block; + width: 100%; + height: 100%; + object-fit: cover; +`; + +const TextLayerContent = styled.div<{ + $align: TextLayer["align"]; + $color: string; + $fontSize: number; +}>` + display: flex; + align-items: center; + justify-content: ${({ $align }) => + $align === "center" + ? "center" + : $align === "right" + ? "flex-end" + : "flex-start"}; + width: 100%; + height: 100%; + color: ${({ $color }) => $color}; + font-size: ${({ $fontSize }) => Math.max(10, Math.min(52, $fontSize))}px; + font-weight: 700; + line-height: 1.1; + padding: 6px; + text-align: ${({ $align }) => $align}; +`; + +const ShapeLayerContent = styled.div<{ + $shape: ShapeLayer["shape"]; + $fill?: string; + $stroke?: string; + $strokeWidth?: number; +}>` + width: 100%; + height: 100%; + border: ${({ $stroke, $strokeWidth }) => + $stroke ? `${$strokeWidth ?? 1}px solid ${$stroke}` : "none"}; + border-radius: ${({ $shape }) => + $shape === "ellipse" ? "999px" : $shape === "round_rect" ? "18px" : "0"}; + background: ${({ $fill }) => $fill ?? "rgba(15, 23, 42, 0.08)"}; +`; + +const InspectorBody = styled.div` + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 14px; + overflow: auto; + padding: 14px; +`; + +const PropertyCard = styled.div` + border: 1px solid hsl(var(--border)); + border-radius: 16px; + background: hsl(var(--background)); + padding: 12px; +`; + +const PropertyTitle = styled.div` + margin-bottom: 10px; + font-size: 13px; + font-weight: 650; +`; + +const PropertyGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +`; + +const PropertyItem = styled.div` + border-radius: 12px; + background: hsl(var(--muted)); + padding: 8px; +`; + +const PropertyLabel = styled.div` + color: hsl(var(--muted-foreground)); + font-size: 11px; +`; + +const PropertyValue = styled.div` + margin-top: 2px; + font-size: 13px; + font-weight: 650; +`; + +const ActionGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +`; + +const Hint = styled.p` + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.55; +`; + +const GenerationNotice = styled.div<{ + $tone: "info" | "success" | "warning" | "error"; +}>` + border-bottom: 1px solid hsl(var(--border)); + background: ${({ $tone }) => + $tone === "success" + ? "rgba(16, 185, 129, 0.1)" + : $tone === "warning" + ? "rgba(245, 158, 11, 0.12)" + : $tone === "error" + ? "rgba(244, 63, 94, 0.1)" + : "rgba(14, 165, 233, 0.1)"}; + color: hsl(var(--foreground)); + font-size: 12px; + line-height: 1.5; + padding: 8px 14px; +`; + +const layerTypeLabels: Record = { + image: "图片", + effect: "特效", + text: "文字", + shape: "形状", + group: "分组", +}; + +function findLayerAsset( + layer: DesignLayer, + assets: GeneratedDesignAsset[], +): GeneratedDesignAsset | null { + if (!isImageDesignLayer(layer)) { + return null; + } + + return assets.find((asset) => asset.id === layer.assetId) ?? null; +} + +function resolveLayerIcon(layer: DesignLayer): string { + switch (layer.type) { + case "image": + return "图"; + case "effect": + return "效"; + case "text": + return "字"; + case "shape": + return "形"; + case "group": + return "组"; + } +} + +function renderLayerContent( + layer: DesignLayer, + assets: GeneratedDesignAsset[], +) { + if (isImageDesignLayer(layer)) { + return renderImageLayer(layer, assets); + } + + switch (layer.type) { + case "text": + return renderTextLayer(layer); + case "shape": + return renderShapeLayer(layer); + case "group": + return renderGroupLayer(layer); + } +} + +function renderImageLayer( + layer: ImageLayer, + assets: GeneratedDesignAsset[], +) { + const asset = findLayerAsset(layer, assets); + if (!asset?.src) { + return {layer.name}; + } + + return {layer.name}; +} + +function renderTextLayer(layer: TextLayer) { + return ( + + {layer.text} + + ); +} + +function renderShapeLayer(layer: ShapeLayer) { + return ( + + ); +} + +function renderGroupLayer(layer: GroupLayer) { + return {layer.children.length} 个子图层; +} + +function buildLayerStyle( + layer: DesignLayer, + canvasWidth: number, + canvasHeight: number, +): React.CSSProperties { + const safeWidth = Math.max(1, canvasWidth); + const safeHeight = Math.max(1, canvasHeight); + + return { + left: `${(layer.x / safeWidth) * 100}%`, + top: `${(layer.y / safeHeight) * 100}%`, + width: `${(layer.width / safeWidth) * 100}%`, + height: `${(layer.height / safeHeight) * 100}%`, + opacity: layer.opacity, + transform: `rotate(${layer.rotation}deg)`, + zIndex: layer.zIndex, + display: layer.visible ? "block" : "none", + }; +} + +function clickDownloadAnchor(anchor: HTMLAnchorElement) { + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); +} + +function downloadTextFile(file: LayeredDesignExportFile) { + const blob = new Blob([file.content], { type: file.mimeType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = file.downloadName; + clickDownloadAnchor(anchor); + URL.revokeObjectURL(url); +} + +function downloadDataUrlFile(filename: string, dataUrl: string) { + const anchor = document.createElement("a"); + + anchor.href = dataUrl; + anchor.download = filename; + clickDownloadAnchor(anchor); +} + +async function renderSvgDataUrlToPngDataUrl( + svgDataUrl: string, + width: number, + height: number, +): Promise { + const image = new window.Image(); + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("SVG 预览无法转换为 PNG")); + image.src = svgDataUrl; + }); + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("当前环境不支持 Canvas PNG 导出"); + } + + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL("image/png"); +} + +export const DesignCanvas: React.FC = memo( + ({ + state, + onStateChange, + onBackHome, + onClose, + projectRootPath, + projectId, + contentId, + createImageTaskArtifact, + getImageTaskArtifact, + }) => { + const document = state.document; + const [generationBusyTarget, setGenerationBusyTarget] = useState< + "all" | "selected" | "refresh" | null + >(null); + const [generationStatus, setGenerationStatus] = useState<{ + tone: "info" | "success" | "warning" | "error"; + message: string; + } | null>(null); + const [exportBusy, setExportBusy] = useState(false); + const visibleLayers = useMemo( + () => sortDesignLayers(document.layers).filter((layer) => layer.visible), + [document.layers], + ); + const panelLayers = useMemo( + () => sortDesignLayers(document.layers).slice().reverse(), + [document.layers], + ); + const selectedLayer = + document.layers.find((layer) => layer.id === state.selectedLayerId) ?? + panelLayers[0] ?? + null; + const selectedAsset = selectedLayer + ? findLayerAsset(selectedLayer, document.assets) + : null; + const selectedImageLayer = + selectedLayer && isImageDesignLayer(selectedLayer) ? selectedLayer : null; + const imageGenerationRequests = useMemo( + () => createLayeredDesignAssetGenerationPlan(document), + [document], + ); + const pendingImageTasks = useMemo( + () => listPendingLayeredDesignImageTasks(document), + [document], + ); + const imageLayerCount = useMemo( + () => document.layers.filter(isImageDesignLayer).length, + [document.layers], + ); + const aspectRatio = `${Math.max(1, document.canvas.width)} / ${Math.max( + 1, + document.canvas.height, + )}`; + const backgroundColor = document.canvas.backgroundColor ?? "#ffffff"; + const normalizedProjectRootPath = projectRootPath?.trim(); + const canSubmitImageTasks = + Boolean(normalizedProjectRootPath) && generationBusyTarget === null; + const canRefreshImageTasks = + canSubmitImageTasks && pendingImageTasks.length > 0; + + const emitState = (nextState: DesignCanvasState) => { + onStateChange(nextState); + }; + + const emitDocument = ( + nextDocument: DesignCanvasState["document"], + selectedLayerId = selectedLayer?.id, + ) => { + emitState({ + ...state, + document: nextDocument, + selectedLayerId, + }); + }; + + const submitImageLayerGeneration = async ( + target: "all" | "selected", + ) => { + const workspaceRoot = normalizedProjectRootPath; + if (!workspaceRoot) { + setGenerationStatus({ + tone: "warning", + message: "绑定工作区后才能提交图层图片任务。", + }); + return; + } + + const requests = + target === "all" + ? imageGenerationRequests + : selectedImageLayer + ? [ + createSingleLayerAssetGenerationRequest( + document, + selectedImageLayer.id, + ), + ] + : []; + + if (requests.length === 0) { + setGenerationStatus({ + tone: "warning", + message: + target === "all" + ? "当前没有待生成的图片图层。" + : "请选择图片或特效图层后再重生成。", + }); + return; + } + + setGenerationBusyTarget(target); + setGenerationStatus({ + tone: "info", + message: `正在提交 ${requests.length} 个图层图片任务...`, + }); + + try { + const submissions = await createLayeredDesignImageTaskArtifacts({ + document, + requests, + projectRootPath: workspaceRoot, + projectId: projectId ?? undefined, + contentId: contentId ?? document.id, + createTaskArtifact: createImageTaskArtifact, + }); + let nextDocument = recordLayeredDesignImageTaskSubmissions( + document, + submissions, + ); + let appliedCount = 0; + + for (const submission of submissions) { + const appliedDocument = applyLayeredDesignImageTaskOutput( + nextDocument, + submission.generationRequest, + submission.output, + ); + if (appliedDocument) { + nextDocument = appliedDocument; + appliedCount += 1; + } + } + + emitDocument(nextDocument, selectedLayer?.id); + setGenerationStatus({ + tone: "success", + message: + appliedCount > 0 + ? `已提交 ${submissions.length} 个图片任务,并写回 ${appliedCount} 个已完成结果。` + : `已提交 ${submissions.length} 个图片任务,等待任务完成后写回图层资产。`, + }); + } catch (error) { + setGenerationStatus({ + tone: "error", + message: + error instanceof Error + ? error.message + : "提交图层图片任务失败。", + }); + } finally { + setGenerationBusyTarget(null); + } + }; + + const refreshSubmittedImageTasks = async () => { + const workspaceRoot = normalizedProjectRootPath; + if (!workspaceRoot) { + setGenerationStatus({ + tone: "warning", + message: "绑定工作区后才能刷新图层图片任务。", + }); + return; + } + + if (pendingImageTasks.length === 0) { + setGenerationStatus({ + tone: "warning", + message: "当前没有等待写回的图层图片任务。", + }); + return; + } + + setGenerationBusyTarget("refresh"); + setGenerationStatus({ + tone: "info", + message: `正在刷新 ${pendingImageTasks.length} 个图层图片任务...`, + }); + + try { + const result = await refreshLayeredDesignImageTaskResults({ + document, + projectRootPath: workspaceRoot, + getTaskArtifact: getImageTaskArtifact, + }); + + emitDocument(result.document, selectedLayer?.id); + setGenerationStatus({ + tone: + result.failedCount > 0 + ? "warning" + : result.appliedCount > 0 + ? "success" + : "info", + message: + result.appliedCount > 0 + ? `已刷新 ${result.refreshedCount} 个图片任务,并写回 ${result.appliedCount} 个图层结果。` + : result.failedCount > 0 + ? `已刷新 ${result.refreshedCount} 个图片任务,${result.failedCount} 个失败,${result.pendingCount} 个仍在生成。` + : `已刷新 ${result.refreshedCount} 个图片任务,${result.pendingCount} 个仍在生成。`, + }); + } catch (error) { + setGenerationStatus({ + tone: "error", + message: + error instanceof Error + ? error.message + : "刷新图层图片任务失败。", + }); + } finally { + setGenerationBusyTarget(null); + } + }; + + const selectLayer = (layerId: string) => { + emitState({ + ...state, + selectedLayerId: layerId, + }); + }; + + const updateZoom = (delta: number) => { + emitState({ + ...state, + zoom: Math.min(1.2, Math.max(0.42, state.zoom + delta)), + }); + }; + + const moveSelectedLayer = (xDelta: number, yDelta: number) => { + if (!selectedLayer || selectedLayer.locked) return; + emitDocument( + updateLayerTransform(document, { + layerId: selectedLayer.id, + transform: { + x: selectedLayer.x + xDelta, + y: selectedLayer.y + yDelta, + }, + }), + ); + }; + + const changeSelectedZIndex = (delta: number) => { + if (!selectedLayer || selectedLayer.locked) return; + emitDocument( + updateLayerTransform(document, { + layerId: selectedLayer.id, + transform: { + zIndex: selectedLayer.zIndex + delta, + }, + }), + ); + }; + + const toggleSelectedVisibility = () => { + if (!selectedLayer) return; + emitDocument( + updateLayerVisibility(document, { + layerId: selectedLayer.id, + visible: !selectedLayer.visible, + }), + ); + }; + + const toggleSelectedLock = () => { + if (!selectedLayer) return; + emitDocument( + updateLayerLock(document, { + layerId: selectedLayer.id, + locked: !selectedLayer.locked, + }), + ); + }; + + const exportDesignProject = async () => { + setExportBusy(true); + setGenerationStatus({ + tone: "info", + message: "正在导出 design.json、assets manifest 和当前预览 PNG...", + }); + + try { + const bundle = createLayeredDesignExportBundle(document); + const svgDataUrl = createLayeredDesignPreviewSvgDataUrl(document); + + downloadTextFile(bundle.designFile); + downloadTextFile(bundle.manifestFile); + downloadTextFile(bundle.previewSvgFile); + for (const assetFile of bundle.assetFiles) { + downloadDataUrlFile(assetFile.downloadName, assetFile.src); + } + + const pngDataUrl = await renderSvgDataUrlToPngDataUrl( + svgDataUrl, + document.canvas.width, + document.canvas.height, + ); + downloadDataUrlFile(bundle.previewPngFile.downloadName, pngDataUrl); + + setGenerationStatus({ + tone: "success", + message: `已导出 design.json、export manifest、preview.png,并下载 ${bundle.assetFiles.length} 个内嵌 assets;远程 assets 保留引用。`, + }); + } catch (error) { + setGenerationStatus({ + tone: "error", + message: + error instanceof Error ? error.message : "导出图层设计工程失败。", + }); + } finally { + setExportBusy(false); + } + }; + + return ( + + + + LayeredDesignDocument + 图层 + + + {panelLayers.map((layer) => ( + selectLayer(layer.id)} + > + {resolveLayerIcon(layer)} + + {layer.name} + + {layerTypeLabels[layer.type]} / z {layer.zIndex} + {!layer.visible ? " / 已隐藏" : ""} + {layer.locked ? " / 已锁定" : ""} + + + {layer.visible ? "显示" : "隐藏"} + + ))} + + + + + + + {document.title} + + {document.canvas.width} x {document.canvas.height} /{" "} + {document.layers.length} 个图层 / {imageLayerCount} 个图片层 /{" "} + {document.status} + + + + + + + + + + + + {generationStatus ? ( + + {generationStatus.message} + + ) : null} + + + + {visibleLayers.map((layer) => ( + selectLayer(layer.id)} + aria-label={`选择图层 ${layer.name}`} + > + {renderLayerContent(layer, document.assets)} + + ))} + + + + + + + 属性 + {selectedLayer?.name ?? "未选择图层"} + + + {selectedLayer ? ( + <> + + 位置与尺寸 + + + X + {Math.round(selectedLayer.x)} + + + Y + {Math.round(selectedLayer.y)} + + + 宽 + + {Math.round(selectedLayer.width)} + + + + 高 + + {Math.round(selectedLayer.height)} + + + + 透明度 + + {Math.round(selectedLayer.opacity * 100)}% + + + + 层级 + {selectedLayer.zIndex} + + + + + + 图层动作 + + + + + + + + + + + + + + + 生成来源 + + 类型:{layerTypeLabels[selectedLayer.type]};来源: + {selectedLayer.source}。 + {selectedAsset + ? ` 资产:${selectedAsset.id},模型:${ + selectedAsset.modelId ?? "未记录" + }。` + : " 当前图层没有绑定图片资产。"} + + + + ) : ( + + 当前文档还没有图层。 + + )} + + + + ); + }, +); + +DesignCanvas.displayName = "DesignCanvas"; diff --git a/src/components/workspace/design/types.ts b/src/components/workspace/design/types.ts new file mode 100644 index 000000000..bc37a8903 --- /dev/null +++ b/src/components/workspace/design/types.ts @@ -0,0 +1,127 @@ +import { + createImageLayer, + createLayeredDesignDocument, + createTextLayer, + normalizeLayeredDesignDocument, +} from "@/lib/layered-design"; +import type { + LayeredDesignDocument, + LayeredDesignDocumentInput, +} from "@/lib/layered-design"; +import type { + CreateImageGenerationTaskArtifactRequest, + MediaTaskArtifactOutput, + MediaTaskLookupRequest, +} from "@/lib/api/mediaTasks"; + +export interface DesignCanvasState { + type: "design"; + document: LayeredDesignDocument; + selectedLayerId?: string; + zoom: number; +} + +export interface DesignCanvasProps { + state: DesignCanvasState; + onStateChange: (state: DesignCanvasState) => void; + onClose?: () => void; + onBackHome?: () => void; + projectRootPath?: string | null; + projectId?: string | null; + contentId?: string | null; + createImageTaskArtifact?: ( + request: CreateImageGenerationTaskArtifactRequest, + ) => Promise; + getImageTaskArtifact?: ( + request: MediaTaskLookupRequest, + ) => Promise; +} + +function createBlankDesignDocument(): LayeredDesignDocument { + const createdAt = new Date().toISOString(); + return createLayeredDesignDocument({ + id: `design-${Date.now()}`, + title: "未命名图层设计", + canvas: { + width: 1080, + height: 1440, + backgroundColor: "#f8fafc", + }, + layers: [ + createImageLayer({ + id: "background", + name: "背景", + type: "image", + assetId: "asset-background-placeholder", + x: 0, + y: 0, + width: 1080, + height: 1440, + zIndex: 0, + }), + createTextLayer({ + id: "headline", + name: "标题文案", + type: "text", + text: "双击后续版本可编辑文案", + x: 120, + y: 160, + width: 840, + height: 120, + fontSize: 54, + color: "#0f172a", + align: "center", + zIndex: 10, + }), + ], + assets: [ + { + id: "asset-background-placeholder", + kind: "background", + src: "", + width: 1080, + height: 1440, + hasAlpha: false, + createdAt, + }, + ], + createdAt, + updatedAt: createdAt, + }); +} + +export function createInitialDesignCanvasState( + documentInput?: LayeredDesignDocumentInput | LayeredDesignDocument, +): DesignCanvasState { + const document = documentInput + ? normalizeLayeredDesignDocument(documentInput) + : createBlankDesignDocument(); + const selectedLayerId = + document.layers[document.layers.length - 1]?.id ?? document.layers[0]?.id; + + return { + type: "design", + document, + selectedLayerId, + zoom: 0.72, + }; +} + +export function createDesignCanvasStateFromContent( + content: string, +): DesignCanvasState { + const trimmed = content.trim(); + if (!trimmed) { + return createInitialDesignCanvasState(); + } + + try { + const parsed = JSON.parse(trimmed) as LayeredDesignDocumentInput; + return createInitialDesignCanvasState(parsed); + } catch { + return createInitialDesignCanvasState({ + ...createBlankDesignDocument(), + title: trimmed.slice(0, 80) || "未命名图层设计", + }); + } +} diff --git a/src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx b/src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx new file mode 100644 index 000000000..bfc1b84a9 --- /dev/null +++ b/src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx @@ -0,0 +1,335 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { capabilityDraftsApi } from "@/lib/api/capabilityDrafts"; +import { CapabilityDraftPanel } from "./CapabilityDraftPanel"; + +vi.mock("@/lib/api/capabilityDrafts", () => ({ + capabilityDraftsApi: { + list: vi.fn(), + verify: vi.fn(), + register: vi.fn(), + }, +})); + +interface RenderResult { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: RenderResult[] = []; + +function renderPanel(props?: Parameters[0]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(); + }); + mountedRoots.push({ container, root }); + return container; +} + +describe("CapabilityDraftPanel", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.mocked(capabilityDraftsApi.list).mockReset(); + vi.mocked(capabilityDraftsApi.verify).mockReset(); + vi.mocked(capabilityDraftsApi.register).mockReset(); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + }); + + it("没有项目根目录时只显示选择项目提示,不读取草案", () => { + const container = renderPanel(); + + expect(container.textContent).toContain("能力草案"); + expect(container.textContent).toContain("选择或进入一个项目"); + expect(capabilityDraftsApi.list).not.toHaveBeenCalled(); + }); + + it("应展示未验证草案,并明确没有运行或注册入口", async () => { + vi.mocked(capabilityDraftsApi.list).mockResolvedValueOnce([ + { + draftId: "capdraft-1", + name: "竞品监控草案", + description: "每天汇总竞品价格和上新变化。", + userGoal: "持续监控竞品爆款并产出待复核清单。", + sourceKind: "manual", + sourceRefs: ["docs/research/creaoai"], + permissionSummary: ["Level 0 只读发现"], + generatedFiles: [ + { relativePath: "SKILL.md", byteLength: 32, sha256: "abc" }, + { relativePath: "scripts/check.ts", byteLength: 64, sha256: "def" }, + ], + verificationStatus: "unverified", + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + draftRoot: "/tmp/work/.lime/capability-drafts/capdraft-1", + manifestPath: + "/tmp/work/.lime/capability-drafts/capdraft-1/manifest.json", + }, + ]); + + const container = renderPanel({ workspaceRoot: "/tmp/work" }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(capabilityDraftsApi.list).toHaveBeenCalledWith({ + workspaceRoot: "/tmp/work", + }); + expect(container.textContent).toContain("竞品监控草案"); + expect(container.textContent).toContain("未验证"); + expect(container.textContent).toContain("还没有运行 verification gate"); + expect(container.textContent).toContain("Level 0 只读发现"); + expect(container.textContent).toContain("SKILL.md / scripts/check.ts"); + expect(container.textContent).toContain("当前没有运行、注册或自动化入口"); + expect(container.textContent).toContain("运行验证"); + expect(container.textContent).not.toContain("立即运行"); + const forbiddenActionButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("注册成方法")); + expect(forbiddenActionButton).toBeUndefined(); + }); + + it("运行验证后应刷新草案状态,并只展示注册入口", async () => { + vi.mocked(capabilityDraftsApi.list).mockResolvedValueOnce([ + { + draftId: "capdraft-verify", + name: "只读 CLI 报告草案", + description: "整理 CLI 输出。", + userGoal: "生成 Markdown 趋势摘要。", + sourceKind: "cli", + sourceRefs: ["trendctl --help"], + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + generatedFiles: [ + { relativePath: "SKILL.md", byteLength: 128, sha256: "a" }, + { + relativePath: "contract/input.schema.json", + byteLength: 32, + sha256: "b", + }, + { + relativePath: "contract/output.schema.json", + byteLength: 32, + sha256: "c", + }, + { + relativePath: "examples/input.sample.json", + byteLength: 16, + sha256: "d", + }, + ], + verificationStatus: "unverified", + lastVerification: null, + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T00:00:00.000Z", + draftRoot: "/tmp/work/.lime/capability-drafts/capdraft-verify", + manifestPath: + "/tmp/work/.lime/capability-drafts/capdraft-verify/manifest.json", + }, + ]); + vi.mocked(capabilityDraftsApi.verify).mockResolvedValueOnce({ + draft: { + draftId: "capdraft-verify", + name: "只读 CLI 报告草案", + description: "整理 CLI 输出。", + userGoal: "生成 Markdown 趋势摘要。", + sourceKind: "cli", + sourceRefs: ["trendctl --help"], + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + generatedFiles: [], + verificationStatus: "verified_pending_registration", + lastVerification: { + reportId: "capver-1", + status: "passed", + summary: "最小 verification gate 通过,等待后续注册阶段。", + checkedAt: "2026-05-05T01:00:00.000Z", + failedCheckCount: 0, + }, + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T01:00:00.000Z", + draftRoot: "/tmp/work/.lime/capability-drafts/capdraft-verify", + manifestPath: + "/tmp/work/.lime/capability-drafts/capdraft-verify/manifest.json", + }, + report: { + draftId: "capdraft-verify", + reportId: "capver-1", + status: "passed", + summary: "最小 verification gate 通过,等待后续注册阶段。", + checkedAt: "2026-05-05T01:00:00.000Z", + failedCheckCount: 0, + checks: [ + { + id: "package_structure", + label: "包结构", + status: "passed", + message: "通过", + suggestions: [], + canAgentRepair: false, + }, + ], + }, + }); + + const container = renderPanel({ workspaceRoot: "/tmp/work" }); + + await act(async () => { + await Promise.resolve(); + }); + + const verifyButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("运行验证"), + ); + expect(verifyButton).toBeTruthy(); + + await act(async () => { + verifyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(capabilityDraftsApi.verify).toHaveBeenCalledWith({ + workspaceRoot: "/tmp/work", + draftId: "capdraft-verify", + }); + expect(container.textContent).toContain("验证通过,待注册"); + expect(container.textContent).toContain("所有检查均已通过"); + expect(container.textContent).toContain("注册只会复制为 Workspace 本地 Skill"); + const registerButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("注册到 Workspace")); + expect(registerButton).toBeTruthy(); + const runButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("立即运行"), + ); + expect(runButton).toBeUndefined(); + }); + + it("注册后应展示注册目录,但仍不展示运行或自动化入口", async () => { + vi.mocked(capabilityDraftsApi.list).mockResolvedValueOnce([ + { + draftId: "capdraft-register", + name: "只读 CLI 报告草案", + description: "整理 CLI 输出。", + userGoal: "生成 Markdown 趋势摘要。", + sourceKind: "cli", + sourceRefs: ["trendctl --help"], + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + generatedFiles: [ + { relativePath: "SKILL.md", byteLength: 128, sha256: "a" }, + ], + verificationStatus: "verified_pending_registration", + lastVerification: { + reportId: "capver-1", + status: "passed", + summary: "最小 verification gate 通过,等待后续注册阶段。", + checkedAt: "2026-05-05T01:00:00.000Z", + failedCheckCount: 0, + }, + lastRegistration: null, + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T01:00:00.000Z", + draftRoot: "/tmp/work/.lime/capability-drafts/capdraft-register", + manifestPath: + "/tmp/work/.lime/capability-drafts/capdraft-register/manifest.json", + }, + ]); + vi.mocked(capabilityDraftsApi.register).mockResolvedValueOnce({ + draft: { + draftId: "capdraft-register", + name: "只读 CLI 报告草案", + description: "整理 CLI 输出。", + userGoal: "生成 Markdown 趋势摘要。", + sourceKind: "cli", + sourceRefs: ["trendctl --help"], + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + generatedFiles: [ + { relativePath: "SKILL.md", byteLength: 128, sha256: "a" }, + ], + verificationStatus: "registered", + lastVerification: { + reportId: "capver-1", + status: "passed", + summary: "最小 verification gate 通过,等待后续注册阶段。", + checkedAt: "2026-05-05T01:00:00.000Z", + failedCheckCount: 0, + }, + lastRegistration: { + registrationId: "capreg-1", + registeredAt: "2026-05-05T01:10:00.000Z", + skillDirectory: "capability-register", + registeredSkillDirectory: "/tmp/work/.agents/skills/capability-register", + sourceDraftId: "capdraft-register", + sourceVerificationReportId: "capver-1", + generatedFileCount: 4, + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + }, + createdAt: "2026-05-05T00:00:00.000Z", + updatedAt: "2026-05-05T01:10:00.000Z", + draftRoot: "/tmp/work/.lime/capability-drafts/capdraft-register", + manifestPath: + "/tmp/work/.lime/capability-drafts/capdraft-register/manifest.json", + }, + registration: { + registrationId: "capreg-1", + registeredAt: "2026-05-05T01:10:00.000Z", + skillDirectory: "capability-register", + registeredSkillDirectory: "/tmp/work/.agents/skills/capability-register", + sourceDraftId: "capdraft-register", + sourceVerificationReportId: "capver-1", + generatedFileCount: 4, + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + }, + }); + + const onRegisteredSkillsChanged = vi.fn(); + const container = renderPanel({ + workspaceRoot: "/tmp/work", + onRegisteredSkillsChanged, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const registerButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("注册到 Workspace")); + expect(registerButton).toBeTruthy(); + + await act(async () => { + registerButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(capabilityDraftsApi.register).toHaveBeenCalledWith({ + workspaceRoot: "/tmp/work", + draftId: "capdraft-register", + }); + expect(container.textContent).toContain("已注册"); + expect(container.textContent).toContain("已注册目录:capability-register"); + expect(container.textContent).toContain("运行与自动化仍需后续 runtime gate"); + expect(onRegisteredSkillsChanged).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain("立即运行"); + expect(container.textContent).not.toContain("创建自动化"); + }); +}); diff --git a/src/features/capability-drafts/components/CapabilityDraftPanel.tsx b/src/features/capability-drafts/components/CapabilityDraftPanel.tsx new file mode 100644 index 000000000..ef75f06fe --- /dev/null +++ b/src/features/capability-drafts/components/CapabilityDraftPanel.tsx @@ -0,0 +1,416 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { CheckCircle2, RefreshCw } from "lucide-react"; +import { + capabilityDraftsApi, + type CapabilityDraftRecord, +} from "@/lib/api/capabilityDrafts"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + canVerifyCapabilityDraft, + canExecuteCapabilityDraft, + canRegisterCapabilityDraft, + getCapabilityDraftStatusPresentation, + summarizeCapabilityDraftFailedChecks, + summarizeCapabilityDraftFiles, + summarizeCapabilityDraftPermissions, + summarizeCapabilityDraftRegistration, + summarizeCapabilityDraftVerification, +} from "../domain/capabilityDraftPresentation"; + +interface CapabilityDraftPanelProps { + workspaceRoot?: string | null; + projectPending?: boolean; + projectError?: string | null; + highlightedDraftId?: string | null; + onRegisteredSkillsChanged?: () => void; + className?: string; +} + +const STATUS_TONE_CLASSNAMES = { + amber: "border-amber-200 bg-amber-50 text-amber-700", + emerald: "border-emerald-200 bg-emerald-50 text-emerald-700", + rose: "border-rose-200 bg-rose-50 text-rose-700", + slate: "border-slate-200 bg-slate-50 text-slate-600", +}; + +function sortDraftsForDisplay( + drafts: CapabilityDraftRecord[], + highlightedDraftId?: string | null, +): CapabilityDraftRecord[] { + const normalizedHighlight = highlightedDraftId?.trim(); + return [...drafts].sort((left, right) => { + const leftHighlighted = + normalizedHighlight && left.draftId === normalizedHighlight ? 1 : 0; + const rightHighlighted = + normalizedHighlight && right.draftId === normalizedHighlight ? 1 : 0; + if (leftHighlighted !== rightHighlighted) { + return rightHighlighted - leftHighlighted; + } + return right.updatedAt.localeCompare(left.updatedAt); + }); +} + +export function CapabilityDraftPanel({ + workspaceRoot, + projectPending = false, + projectError, + highlightedDraftId, + onRegisteredSkillsChanged, + className, +}: CapabilityDraftPanelProps) { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [verifyingDraftId, setVerifyingDraftId] = useState(null); + const [registeringDraftId, setRegisteringDraftId] = useState( + null, + ); + const [verificationMessage, setVerificationMessage] = useState( + null, + ); + const [registrationMessage, setRegistrationMessage] = useState( + null, + ); + const normalizedWorkspaceRoot = workspaceRoot?.trim() || null; + + const loadDrafts = useCallback(async () => { + if (!normalizedWorkspaceRoot) { + setDrafts([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const nextDrafts = await capabilityDraftsApi.list({ + workspaceRoot: normalizedWorkspaceRoot, + }); + setDrafts(nextDrafts); + } catch (loadError) { + setDrafts([]); + setError(String(loadError)); + } finally { + setLoading(false); + } + }, [normalizedWorkspaceRoot]); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + if (!normalizedWorkspaceRoot) { + setDrafts([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const nextDrafts = await capabilityDraftsApi.list({ + workspaceRoot: normalizedWorkspaceRoot, + }); + if (!cancelled) { + setDrafts(nextDrafts); + } + } catch (loadError) { + if (!cancelled) { + setDrafts([]); + setError(String(loadError)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [normalizedWorkspaceRoot]); + + const visibleDrafts = useMemo( + () => sortDraftsForDisplay(drafts, highlightedDraftId).slice(0, 3), + [drafts, highlightedDraftId], + ); + + const effectiveError = projectError || error; + const isBusy = projectPending || loading; + + const handleVerifyDraft = useCallback( + async (draft: CapabilityDraftRecord) => { + if (!normalizedWorkspaceRoot || verifyingDraftId) { + return; + } + + setVerifyingDraftId(draft.draftId); + setError(null); + setVerificationMessage(null); + setRegistrationMessage(null); + try { + const result = await capabilityDraftsApi.verify({ + workspaceRoot: normalizedWorkspaceRoot, + draftId: draft.draftId, + }); + setDrafts((current) => + current.map((item) => + item.draftId === result.draft.draftId ? result.draft : item, + ), + ); + setVerificationMessage( + `${result.report.summary} ${summarizeCapabilityDraftFailedChecks( + result.report, + )}`, + ); + } catch (verifyError) { + setError(String(verifyError)); + } finally { + setVerifyingDraftId(null); + } + }, + [normalizedWorkspaceRoot, verifyingDraftId], + ); + + const handleRegisterDraft = useCallback( + async (draft: CapabilityDraftRecord) => { + if (!normalizedWorkspaceRoot || registeringDraftId) { + return; + } + + setRegisteringDraftId(draft.draftId); + setError(null); + setVerificationMessage(null); + setRegistrationMessage(null); + try { + const result = await capabilityDraftsApi.register({ + workspaceRoot: normalizedWorkspaceRoot, + draftId: draft.draftId, + }); + setDrafts((current) => + current.map((item) => + item.draftId === result.draft.draftId ? result.draft : item, + ), + ); + setRegistrationMessage( + `已注册到当前 Workspace:${result.registration.skillDirectory}。运行与自动化仍需后续 runtime gate。`, + ); + onRegisteredSkillsChanged?.(); + } catch (registerError) { + setError(String(registerError)); + } finally { + setRegisteringDraftId(null); + } + }, + [normalizedWorkspaceRoot, onRegisteredSkillsChanged, registeringDraftId], + ); + + return ( +
+
+
+
+ + 草案区 + +

+ 能力草案 +

+
+

+ Coding Agent + 产出的做法先停在这里;未验证前不会注册成方法,也不会自动运行。 +

+
+ {normalizedWorkspaceRoot ? ( + + ) : null} +
+ + {!normalizedWorkspaceRoot ? ( +
+ 选择或进入一个项目后,才能查看该项目里的能力草案。 +
+ ) : effectiveError ? ( +
+ 能力草案暂时没读到:{effectiveError} +
+ ) : isBusy ? ( +
+ 正在读取能力草案... +
+ ) : visibleDrafts.length === 0 ? ( +
+ 当前项目还没有能力草案。后续 Coding Agent + 生成的新能力会先进入这里复核。 +
+ ) : ( +
+ {visibleDrafts.map((draft) => { + const status = getCapabilityDraftStatusPresentation( + draft.verificationStatus, + ); + const canRun = canExecuteCapabilityDraft(draft); + const canRegister = canRegisterCapabilityDraft(draft); + const canVerify = canVerifyCapabilityDraft(draft); + const isVerifying = verifyingDraftId === draft.draftId; + const isRegistering = registeringDraftId === draft.draftId; + + return ( +
+
+ + {status.label} + + + {draft.sourceKind || "manual"} + +
+
+

+ {draft.name} +

+

+ {draft.description || draft.userGoal} +

+
+
+
+ 目标: + {draft.userGoal} +
+
+ 权限: + {summarizeCapabilityDraftPermissions(draft)} +
+
+ 文件: + {summarizeCapabilityDraftFiles(draft)} +
+
+ 验证: + {summarizeCapabilityDraftVerification(draft)} +
+
+ 注册: + {summarizeCapabilityDraftRegistration(draft)} +
+
+ {status.description} + {!canRun && + !canRegister && + draft.verificationStatus !== "registered" + ? " 当前没有运行、注册或自动化入口。" + : null} + {canRegister + ? " 注册只会复制为 Workspace 本地 Skill,不会立即运行。" + : null} + {draft.verificationStatus === "registered" + ? " 当前没有运行或自动化入口。" + : null} +
+
+ {canVerify || canRegister ? ( +
+

+ {canRegister + ? "注册只写当前 Workspace 的 .agents/skills,不接运行或自动化。" + : "只做静态门禁检查,不执行草案脚本。"} +

+
+ {canVerify ? ( + + ) : null} + {canRegister ? ( + + ) : null} +
+
+ ) : null} +
+ ); + })} +
+ )} + + {verificationMessage ? ( +
+ {verificationMessage} +
+ ) : null} + {registrationMessage ? ( +
+ {registrationMessage} +
+ ) : null} +
+ ); +} diff --git a/src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.test.tsx b/src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.test.tsx new file mode 100644 index 000000000..451e2d12e --- /dev/null +++ b/src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.test.tsx @@ -0,0 +1,187 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { capabilityDraftsApi } from "@/lib/api/capabilityDrafts"; +import { WorkspaceRegisteredSkillsPanel } from "./WorkspaceRegisteredSkillsPanel"; + +vi.mock("@/lib/api/capabilityDrafts", () => ({ + capabilityDraftsApi: { + listRegisteredSkills: vi.fn(), + }, +})); + +interface RenderResult { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: RenderResult[] = []; + +function renderPanel( + props?: Parameters[0], +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(); + }); + mountedRoots.push({ container, root }); + return { container, root }; +} + +describe("WorkspaceRegisteredSkillsPanel", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.mocked(capabilityDraftsApi.listRegisteredSkills).mockReset(); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + }); + + it("没有项目根目录时只显示选择项目提示,不读取已注册能力", () => { + const { container } = renderPanel(); + + expect(container.textContent).toContain("Workspace 已注册能力"); + expect(container.textContent).toContain("选择或进入一个项目"); + expect(capabilityDraftsApi.listRegisteredSkills).not.toHaveBeenCalled(); + }); + + it("应展示已注册能力来源和 runtime gate,且不提供运行入口", async () => { + vi.mocked(capabilityDraftsApi.listRegisteredSkills).mockResolvedValueOnce([ + { + key: "workspace:capability-report", + name: "只读 CLI 报告", + description: "把本地只读 CLI 输出整理成 Markdown 报告。", + directory: "capability-report", + registeredSkillDirectory: "/tmp/work/.agents/skills/capability-report", + registration: { + registrationId: "capreg-1", + registeredAt: "2026-05-05T01:10:00.000Z", + skillDirectory: "capability-report", + registeredSkillDirectory: + "/tmp/work/.agents/skills/capability-report", + sourceDraftId: "capdraft-1", + sourceVerificationReportId: "capver-1", + generatedFileCount: 4, + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + }, + permissionSummary: ["Level 0 只读发现", "允许执行本地 CLI"], + metadata: {}, + allowedTools: [], + resourceSummary: { + hasScripts: true, + hasReferences: false, + hasAssets: false, + }, + standardCompliance: { + isStandard: true, + validationErrors: [], + deprecatedFields: [], + }, + launchEnabled: false, + runtimeGate: + "已注册为 Workspace 本地 Skill 包;进入运行前还需要 P3B runtime binding 与 tool_runtime 授权。", + }, + ]); + + const { container } = renderPanel({ workspaceRoot: "/tmp/work" }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(capabilityDraftsApi.listRegisteredSkills).toHaveBeenCalledWith({ + workspaceRoot: "/tmp/work", + }); + expect(container.textContent).toContain("只读 CLI 报告"); + expect(container.textContent).toContain("已注册"); + expect(container.textContent).toContain("待 runtime gate"); + expect(container.textContent).toContain("capdraft-1 / capver-1"); + expect(container.textContent).toContain("Level 0 只读发现 / 允许执行本地 CLI"); + expect(container.textContent).toContain("scripts"); + expect(container.textContent).toContain("Agent Skills 标准通过"); + expect(container.textContent).toContain("tool_runtime 授权"); + expect(container.textContent).not.toContain("立即运行"); + expect(container.textContent).not.toContain("创建自动化"); + expect(container.textContent).not.toContain("继续这套方法"); + }); + + it("refreshSignal 变化时应重新读取已注册能力", async () => { + vi.mocked(capabilityDraftsApi.listRegisteredSkills) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + key: "workspace:capability-new", + name: "新注册能力", + description: "刷新后出现。", + directory: "capability-new", + registeredSkillDirectory: "/tmp/work/.agents/skills/capability-new", + registration: { + registrationId: "capreg-2", + registeredAt: "2026-05-05T01:20:00.000Z", + skillDirectory: "capability-new", + registeredSkillDirectory: + "/tmp/work/.agents/skills/capability-new", + sourceDraftId: "capdraft-2", + sourceVerificationReportId: "capver-2", + generatedFileCount: 3, + permissionSummary: ["Level 0 只读发现"], + }, + permissionSummary: ["Level 0 只读发现"], + metadata: {}, + allowedTools: [], + resourceSummary: { + hasScripts: false, + hasReferences: false, + hasAssets: false, + }, + standardCompliance: { + isStandard: true, + validationErrors: [], + deprecatedFields: [], + }, + launchEnabled: false, + runtimeGate: "等待 runtime gate。", + }, + ]); + + const { container, root } = renderPanel({ + workspaceRoot: "/tmp/work", + refreshSignal: 0, + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(container.textContent).toContain("当前项目还没有通过 P3A 注册的能力"); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + + expect(capabilityDraftsApi.listRegisteredSkills).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain("新注册能力"); + }); +}); diff --git a/src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.tsx b/src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.tsx new file mode 100644 index 000000000..a93127208 --- /dev/null +++ b/src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { CheckCircle2, RefreshCw } from "lucide-react"; +import { + capabilityDraftsApi, + type WorkspaceRegisteredSkillRecord, +} from "@/lib/api/capabilityDrafts"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface WorkspaceRegisteredSkillsPanelProps { + workspaceRoot?: string | null; + projectPending?: boolean; + projectError?: string | null; + refreshSignal?: number; + className?: string; +} + +function summarizePermissionSummary(skill: WorkspaceRegisteredSkillRecord) { + if (skill.permissionSummary.length === 0) { + return "未声明额外权限,默认停留在只读发现与注册审计。"; + } + return skill.permissionSummary.slice(0, 2).join(" / "); +} + +function summarizeResourceSummary(skill: WorkspaceRegisteredSkillRecord) { + const resources = [ + skill.resourceSummary.hasScripts ? "scripts" : null, + skill.resourceSummary.hasReferences ? "references" : null, + skill.resourceSummary.hasAssets ? "assets" : null, + ].filter((item): item is string => Boolean(item)); + + return resources.length > 0 ? resources.join(" / ") : "纯 Skill 说明"; +} + +function summarizeStandardCompliance(skill: WorkspaceRegisteredSkillRecord) { + if (skill.standardCompliance.validationErrors.length > 0) { + return `标准检查仍有 ${skill.standardCompliance.validationErrors.length} 个问题`; + } + return skill.standardCompliance.isStandard + ? "Agent Skills 标准通过" + : "Agent Skills 标准状态待确认"; +} + +function sortRegisteredSkills( + skills: WorkspaceRegisteredSkillRecord[], +): WorkspaceRegisteredSkillRecord[] { + return [...skills].sort((left, right) => + right.registration.registeredAt.localeCompare( + left.registration.registeredAt, + ), + ); +} + +export function WorkspaceRegisteredSkillsPanel({ + workspaceRoot, + projectPending = false, + projectError, + refreshSignal = 0, + className, +}: WorkspaceRegisteredSkillsPanelProps) { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const normalizedWorkspaceRoot = workspaceRoot?.trim() || null; + + const loadRegisteredSkills = useCallback(async () => { + if (!normalizedWorkspaceRoot) { + setSkills([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const nextSkills = await capabilityDraftsApi.listRegisteredSkills({ + workspaceRoot: normalizedWorkspaceRoot, + }); + setSkills(nextSkills); + } catch (loadError) { + setSkills([]); + setError(String(loadError)); + } finally { + setLoading(false); + } + }, [normalizedWorkspaceRoot]); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + if (!normalizedWorkspaceRoot) { + setSkills([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const nextSkills = await capabilityDraftsApi.listRegisteredSkills({ + workspaceRoot: normalizedWorkspaceRoot, + }); + if (!cancelled) { + setSkills(nextSkills); + } + } catch (loadError) { + if (!cancelled) { + setSkills([]); + setError(String(loadError)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void run(); + + return () => { + cancelled = true; + }; + }, [normalizedWorkspaceRoot, refreshSignal]); + + const visibleSkills = useMemo( + () => sortRegisteredSkills(skills).slice(0, 4), + [skills], + ); + const effectiveError = projectError || error; + const isBusy = projectPending || loading; + + return ( +
+
+
+
+ + 注册区 + +

+ Workspace 已注册能力 +

+
+

+ 这里只有已通过验证并写入当前项目的 Skill 包;运行仍要等 runtime + gate。 +

+
+ {normalizedWorkspaceRoot ? ( + + ) : null} +
+ + {!normalizedWorkspaceRoot ? ( +
+ 选择或进入一个项目后,才能查看该项目已注册的 generated skill。 +
+ ) : effectiveError ? ( +
+ 已注册能力暂时没读到:{effectiveError} +
+ ) : isBusy ? ( +
+ 正在读取已注册能力... +
+ ) : visibleSkills.length === 0 ? ( +
+ 当前项目还没有通过 P3A 注册的能力。草案通过验证并注册后,会先出现在这里。 +
+ ) : ( +
+ {visibleSkills.map((skill) => ( +
+
+ + + 已注册 + + + 待 runtime gate + +
+
+

+ {skill.name || skill.directory} +

+

+ {skill.description || "已注册为当前 Workspace 的本地 Skill 包。"} +

+
+
+
+ 目录: + {skill.directory} +
+
+ 来源: + {skill.registration.sourceDraftId} + {skill.registration.sourceVerificationReportId + ? ` / ${skill.registration.sourceVerificationReportId}` + : ""} +
+
+ 权限: + {summarizePermissionSummary(skill)} +
+
+ 资源: + {summarizeResourceSummary(skill)} +
+
+ 标准: + {summarizeStandardCompliance(skill)} +
+
+ 待运行接入: + {skill.runtimeGate || + "进入运行前还需要 Query Loop 与 tool_runtime 授权。"} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts b/src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts new file mode 100644 index 000000000..f608048a0 --- /dev/null +++ b/src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { + canExecuteCapabilityDraft, + canRegisterCapabilityDraft, + canVerifyCapabilityDraft, + getCapabilityDraftStatusPresentation, + summarizeCapabilityDraftFailedChecks, + summarizeCapabilityDraftFiles, + summarizeCapabilityDraftPermissions, + summarizeCapabilityDraftRegistration, + summarizeCapabilityDraftVerification, +} from "./capabilityDraftPresentation"; + +describe("capabilityDraftPresentation", () => { + it("未验证草案默认不允许执行或注册", () => { + const draft = { verificationStatus: "unverified" as const }; + + expect(canExecuteCapabilityDraft(draft)).toBe(false); + expect(canRegisterCapabilityDraft(draft)).toBe(false); + expect(canVerifyCapabilityDraft(draft)).toBe(true); + expect(getCapabilityDraftStatusPresentation("unverified")).toMatchObject({ + label: "未验证", + tone: "amber", + }); + }); + + it("应归纳权限与文件摘要", () => { + expect( + summarizeCapabilityDraftPermissions({ permissionSummary: [] }), + ).toContain("默认停留在只读发现"); + expect( + summarizeCapabilityDraftFiles({ + generatedFiles: [ + { relativePath: "SKILL.md", byteLength: 10, sha256: "a" }, + { relativePath: "scripts/run.ts", byteLength: 20, sha256: "b" }, + { relativePath: "examples/input.json", byteLength: 30, sha256: "c" }, + { + relativePath: "tests/self-check.json", + byteLength: 40, + sha256: "d", + }, + ], + }), + ).toBe("SKILL.md / scripts/run.ts / examples/input.json 等 4 个文件"); + }); + + it("应展示验证通过与失败摘要,并允许进入注册但不允许执行", () => { + const verifiedDraft = { + verificationStatus: "verified_pending_registration" as const, + lastVerification: { + reportId: "capver-1", + status: "passed" as const, + summary: "最小 verification gate 通过,等待后续注册阶段。", + checkedAt: "2026-05-05T00:00:00.000Z", + failedCheckCount: 0, + }, + }; + + expect( + getCapabilityDraftStatusPresentation("verification_failed"), + ).toMatchObject({ + label: "验证未通过", + tone: "rose", + }); + expect( + getCapabilityDraftStatusPresentation("verified_pending_registration"), + ).toMatchObject({ + label: "验证通过,待注册", + tone: "slate", + }); + expect(canExecuteCapabilityDraft(verifiedDraft)).toBe(false); + expect(canRegisterCapabilityDraft(verifiedDraft)).toBe(true); + expect(summarizeCapabilityDraftVerification(verifiedDraft)).toContain( + "等待后续注册阶段", + ); + expect( + summarizeCapabilityDraftFailedChecks({ + checks: [ + { + id: "input_contract", + label: "输入 contract", + status: "failed", + message: "缺少输入 contract。", + suggestions: [], + canAgentRepair: true, + }, + { + id: "output_contract", + label: "输出 contract", + status: "failed", + message: "缺少输出 contract。", + suggestions: [], + canAgentRepair: true, + }, + ], + }), + ).toBe("输入 contract / 输出 contract"); + }); + + it("应展示注册状态和注册目录,但仍不允许执行", () => { + const registeredDraft = { + verificationStatus: "registered" as const, + lastRegistration: { + registrationId: "capreg-1", + registeredAt: "2026-05-05T00:10:00.000Z", + skillDirectory: "capability-readonly-report", + registeredSkillDirectory: + "/tmp/work/.agents/skills/capability-readonly-report", + sourceDraftId: "capdraft-1", + sourceVerificationReportId: "capver-1", + generatedFileCount: 4, + permissionSummary: ["Level 0 只读发现"], + }, + }; + + expect(getCapabilityDraftStatusPresentation("registered")).toMatchObject({ + label: "已注册", + tone: "emerald", + }); + expect(canExecuteCapabilityDraft(registeredDraft)).toBe(false); + expect(canRegisterCapabilityDraft(registeredDraft)).toBe(false); + expect(summarizeCapabilityDraftRegistration(registeredDraft)).toBe( + "已注册目录:capability-readonly-report", + ); + }); +}); diff --git a/src/features/capability-drafts/domain/capabilityDraftPresentation.ts b/src/features/capability-drafts/domain/capabilityDraftPresentation.ts new file mode 100644 index 000000000..1ec8997cc --- /dev/null +++ b/src/features/capability-drafts/domain/capabilityDraftPresentation.ts @@ -0,0 +1,130 @@ +import type { + CapabilityDraftRecord, + CapabilityDraftStatus, + CapabilityDraftVerificationReport, +} from "@/lib/api/capabilityDrafts"; + +export type CapabilityDraftTone = "amber" | "emerald" | "rose" | "slate"; + +export interface CapabilityDraftStatusPresentation { + label: string; + description: string; + tone: CapabilityDraftTone; +} + +const STATUS_PRESENTATION: Record< + CapabilityDraftStatus, + CapabilityDraftStatusPresentation +> = { + unverified: { + label: "未验证", + description: "只能查看和继续修复,不能运行、注册或接入自动化。", + tone: "amber", + }, + failed_self_check: { + label: "自检未通过", + description: "需要先修复草案内容,再进入验证门禁。", + tone: "rose", + }, + verification_failed: { + label: "验证未通过", + description: + "verification gate 发现结构、权限或 contract 问题,需要修复后重试。", + tone: "rose", + }, + verified_pending_registration: { + label: "验证通过,待注册", + description: "最小验证已通过,可以注册到当前 Workspace,但仍不会运行或接入自动化。", + tone: "slate", + }, + registered: { + label: "已注册", + description: "已写入当前 Workspace 的本地 Skill 目录;运行与自动化仍需后续 runtime gate。", + tone: "emerald", + }, +}; + +export function getCapabilityDraftStatusPresentation( + status: CapabilityDraftStatus, +): CapabilityDraftStatusPresentation { + return STATUS_PRESENTATION[status] ?? STATUS_PRESENTATION.unverified; +} + +export function canExecuteCapabilityDraft( + draft: Pick, +): boolean { + void draft; + return false; +} + +export function canRegisterCapabilityDraft( + draft: Pick, +): boolean { + return draft.verificationStatus === "verified_pending_registration"; +} + +export function canVerifyCapabilityDraft( + draft: Pick, +): boolean { + return draft.verificationStatus !== "registered"; +} + +export function summarizeCapabilityDraftPermissions( + draft: Pick, +): string { + if (draft.permissionSummary.length === 0) { + return "未声明额外权限,默认停留在只读发现与草案内写入。"; + } + return draft.permissionSummary.slice(0, 3).join(" / "); +} + +export function summarizeCapabilityDraftFiles( + draft: Pick, +): string { + if (draft.generatedFiles.length === 0) { + return "暂无文件清单"; + } + const shown = draft.generatedFiles + .slice(0, 3) + .map((file) => file.relativePath) + .join(" / "); + return draft.generatedFiles.length > 3 + ? `${shown} 等 ${draft.generatedFiles.length} 个文件` + : shown; +} + +export function summarizeCapabilityDraftVerification( + draft: Pick, +): string { + if (!draft.lastVerification) { + return "还没有运行 verification gate。"; + } + return draft.lastVerification.summary; +} + +export function summarizeCapabilityDraftRegistration( + draft: Pick, +): string { + if (!draft.lastRegistration) { + return "还没有注册到 Workspace。"; + } + const directory = draft.lastRegistration.skillDirectory.trim(); + return directory + ? `已注册目录:${directory}` + : "已注册到当前 Workspace。"; +} + +export function summarizeCapabilityDraftFailedChecks( + report: Pick, +): string { + const failedChecks = report.checks.filter( + (check) => check.status === "failed", + ); + if (failedChecks.length === 0) { + return "所有检查均已通过。"; + } + return failedChecks + .slice(0, 3) + .map((check) => check.label || check.id) + .join(" / "); +} diff --git a/src/features/capability-drafts/index.ts b/src/features/capability-drafts/index.ts new file mode 100644 index 000000000..1615fa806 --- /dev/null +++ b/src/features/capability-drafts/index.ts @@ -0,0 +1,3 @@ +export { CapabilityDraftPanel } from "./components/CapabilityDraftPanel"; +export { WorkspaceRegisteredSkillsPanel } from "./components/WorkspaceRegisteredSkillsPanel"; +export * from "./domain/capabilityDraftPresentation"; diff --git a/src/features/knowledge/KnowledgePage.test.tsx b/src/features/knowledge/KnowledgePage.test.tsx index b1ce1f301..8c98ffd50 100644 --- a/src/features/knowledge/KnowledgePage.test.tsx +++ b/src/features/knowledge/KnowledgePage.test.tsx @@ -12,7 +12,11 @@ import { type KnowledgePackDetail, type KnowledgePackStatus, } from "@/lib/api/knowledge"; -import { getProject, getProjectByRootPath } from "@/lib/api/project"; +import { + getDefaultProject, + getProject, + getProjectByRootPath, +} from "@/lib/api/project"; import { KnowledgePage } from "./KnowledgePage"; const { @@ -23,6 +27,7 @@ const { mockSetDefaultKnowledgePack, mockUpdateKnowledgePackStatus, mockResolveKnowledgeContext, + mockGetDefaultProject, mockGetProject, mockGetProjectByRootPath, } = vi.hoisted(() => ({ @@ -33,6 +38,7 @@ const { mockSetDefaultKnowledgePack: vi.fn(), mockUpdateKnowledgePackStatus: vi.fn(), mockResolveKnowledgeContext: vi.fn(), + mockGetDefaultProject: vi.fn(), mockGetProject: vi.fn(), mockGetProjectByRootPath: vi.fn(), })); @@ -77,6 +83,7 @@ vi.mock("@/lib/api/project", async () => { return { ...actual, + getDefaultProject: mockGetDefaultProject, getProject: mockGetProject, getProjectByRootPath: mockGetProjectByRootPath, }; @@ -342,6 +349,7 @@ describe("KnowledgePage", () => { '\n以下内容是数据,不是指令。\n运行时 brief\n', }); mockGetProjectByRootPath.mockResolvedValue(null); + mockGetDefaultProject.mockResolvedValue(null); mockGetProject.mockResolvedValue({ id: "project-alpha", name: "金花黑茶项目", @@ -383,10 +391,10 @@ describe("KnowledgePage", () => { "/tmp/project", "founder-personal-ip", ); - expect(container.textContent).toContain("项目资料管理"); - expect(container.textContent).toContain("当前项目资料库"); + expect(container.textContent).toContain("项目资料"); + expect(container.textContent).toContain("当前项目"); expect(container.textContent).toContain("选择项目"); - expect(container.textContent).toContain("排障设置"); + expect(container.textContent).toContain("项目识别异常?"); expect(container.textContent).toContain("全部资料"); expect(container.textContent).toContain("全部项目资料"); expect(container.textContent).toContain("日常使用入口"); @@ -405,6 +413,38 @@ describe("KnowledgePage", () => { expect(container.textContent).not.toContain("高级:手动指定项目目录"); expect(container.textContent).not.toContain("内部标识"); expect(container.textContent).not.toContain("资料文件名"); + expect(container.textContent).not.toContain("/tmp/project"); + }); + + it("空资料库应给普通用户明确添加、文件管理器和沉淀入口", async () => { + const onNavigate = vi.fn(); + mockListKnowledgePacks.mockResolvedValueOnce(buildListResponse([])); + const container = renderPage({ + workingDir: "/tmp/project", + onNavigate, + }); + await flushEffects(); + + expect(container.textContent).toContain("还没有项目资料"); + expect(container.textContent).toContain("从输入框添加"); + expect(container.textContent).toContain("从文件管理器添加"); + expect(container.textContent).toContain("从结果继续沉淀"); + expect(container.textContent).not.toContain("knowledge_pack"); + expect(container.textContent).not.toContain(".lime/knowledge"); + + await clickButton(container, "回到 Agent 添加"); + + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + agentEntry: "claw", + initialInputCapability: expect.objectContaining({ + capabilityRoute: expect.objectContaining({ + commandKey: "knowledge_pack", + }), + }), + }), + ); }); it("应通过项目选择器切换资料库目录,而不是要求普通用户粘贴路径", async () => { @@ -423,6 +463,70 @@ describe("KnowledgePage", () => { expect(container.textContent).not.toContain("项目位置"); }); + it("没有显式项目时应忽略临时 smoke 目录并恢复默认项目", async () => { + window.localStorage.setItem( + "lime.knowledge.working-dir", + "/tmp/lime-knowledge-smoke-current", + ); + mockGetDefaultProject.mockResolvedValueOnce({ + id: "project-default", + name: "默认项目", + workspaceType: "general", + rootPath: "/Users/demo/Documents/lime-default", + isDefault: true, + createdAt: 1_712_345_678_900, + updatedAt: 1_712_345_678_900, + isFavorite: false, + isArchived: false, + tags: [], + }); + + const container = renderPage(); + await flushEffects(6); + + expect(getDefaultProject).toHaveBeenCalled(); + expect(listKnowledgePacks).toHaveBeenCalledWith({ + workingDir: "/Users/demo/Documents/lime-default", + }); + expect(listKnowledgePacks).not.toHaveBeenCalledWith({ + workingDir: "/tmp/lime-knowledge-smoke-current", + }); + expect(container.textContent).toContain("默认项目"); + }); + + it("存在最近项目时应优先恢复该项目,而不是直接使用临时目录缓存", async () => { + window.localStorage.setItem( + "lime.knowledge.working-dir", + "/tmp/lime-knowledge-smoke-current", + ); + window.localStorage.setItem( + "agent_last_project_id", + JSON.stringify("project-smoke"), + ); + mockGetProject.mockResolvedValueOnce({ + id: "project-smoke", + name: "当前项目", + workspaceType: "temporary", + rootPath: "/tmp/lime-knowledge-smoke-current", + isDefault: false, + createdAt: 1_712_345_678_900, + updatedAt: 1_712_345_678_900, + isFavorite: false, + isArchived: false, + tags: [], + }); + + const container = renderPage(); + await flushEffects(6); + + expect(getProject).toHaveBeenCalledWith("project-smoke"); + expect(getDefaultProject).not.toHaveBeenCalled(); + expect(listKnowledgePacks).toHaveBeenCalledWith({ + workingDir: "/tmp/lime-knowledge-smoke-current", + }); + expect(container.textContent).toContain("当前项目"); + }); + it("手动导入应能粘贴资料并开始整理", async () => { const container = renderPage({ workingDir: "/tmp/project" }); await flushEffects(); @@ -513,6 +617,52 @@ describe("KnowledgePage", () => { }); }); + it("资料详情应隐藏内部字段、路径和运行时摘要格式", async () => { + const noisyPack = buildPackDetail("custom-material", { + description: "活动资料", + type: "custom", + status: "ready", + }); + noisyPack.guide = + "# 适用场景\n用于活动预热和销售话术。\nmetadata: hidden"; + noisyPack.preview = + "新任务\n\n## 何时使用\n新任务\n- 缺失事实时,询问用户或标记待确认。\n- 不编造来源资料没有提供的事实。"; + noisyPack.compiled[0] = { + ...noisyPack.compiled[0], + preview: + "```md\n# 引用摘要\nstatus: draft\ntrust: unreviewed\nsources/source.md\n运行时 brief:不要展示\n关键事实:活动只面向会员。\n```", + }; + noisyPack.sources[0] = { + ...noisyPack.sources[0], + preview: + "/Users/demo/project/.lime/knowledge/packs/custom-material/sources/source.md 原始资料:会员活动。", + }; + mockListKnowledgePacks.mockResolvedValue(buildListResponse([noisyPack])); + mockGetKnowledgePack.mockResolvedValue(noisyPack); + + const container = renderPage({ + workingDir: "/tmp/project", + selectedPackName: "custom-material", + }); + await flushEffects(); + await clickButton(container, "资料详情"); + + expect(container.textContent).toContain("通用资料"); + expect(container.textContent).toContain("用于活动预热和销售话术"); + expect(container.textContent).toContain("关键事实:活动只面向会员。"); + expect(container.textContent).not.toContain("custom"); + expect(container.textContent).not.toContain("status: draft"); + expect(container.textContent).not.toContain("trust: unreviewed"); + expect(container.textContent).not.toContain("sources/source.md"); + expect(container.textContent).not.toContain("compiled/brief.md"); + expect(container.textContent).not.toContain("/Users/demo"); + expect(container.textContent).not.toContain("运行时 brief"); + expect(container.textContent).not.toContain("metadata"); + expect(container.textContent).not.toContain("何时使用"); + expect(container.textContent).not.toContain("缺失事实时"); + expect(container.textContent).not.toContain("不编造来源资料"); + }); + it("用于生成应回到现有 Agent、预填意图并携带资料 metadata", async () => { const onNavigate = vi.fn(); mockGetProjectByRootPath.mockResolvedValueOnce({ @@ -557,10 +707,44 @@ describe("KnowledgePage", () => { grounding: "recommended", }), }, + initialKnowledgePackSelection: { + enabled: true, + packName: "founder-personal-ip", + workingDir: "/tmp/project", + label: "创始人个人 IP 项目资料", + status: "ready", + }, autoRunInitialPromptOnMount: false, }); }); + it("回到 Agent 添加应打开输入框项目资料入口", async () => { + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(2026050501); + const onNavigate = vi.fn(); + const container = renderPage({ + workingDir: "/tmp/project", + onNavigate, + }); + await flushEffects(); + + await clickButton(container, "回到 Agent 添加"); + + expect(onNavigate).toHaveBeenCalledWith("agent", { + agentEntry: "claw", + projectId: undefined, + initialInputCapability: { + capabilityRoute: { + kind: "builtin_command", + commandKey: "knowledge_pack", + commandPrefix: "@资料", + }, + requestKey: 2026050501, + }, + }); + + dateNowSpy.mockRestore(); + }); + it("Agent 整理应携带内部整理 skill 上下文", async () => { const onNavigate = vi.fn(); const container = renderPage({ @@ -576,7 +760,7 @@ describe("KnowledgePage", () => { expect(onNavigate).toHaveBeenCalledWith("agent", { agentEntry: "claw", projectId: undefined, - initialUserPrompt: expect.stringContaining("请整理这个资料包"), + initialUserPrompt: expect.stringContaining("请整理这份项目资料"), initialRequestMetadata: { knowledge_builder: { skill_name: "knowledge_builder", diff --git a/src/features/knowledge/KnowledgePage.tsx b/src/features/knowledge/KnowledgePage.tsx index e9a50c5f0..fc94fb5cc 100644 --- a/src/features/knowledge/KnowledgePage.tsx +++ b/src/features/knowledge/KnowledgePage.tsx @@ -29,14 +29,17 @@ import { type KnowledgePackStatus, type KnowledgePackSummary, } from "@/lib/api/knowledge"; -import { getProject, getProjectByRootPath } from "@/lib/api/project"; +import { + getDefaultProject, + getProject, + getProjectByRootPath, +} from "@/lib/api/project"; import type { KnowledgePageParams, Page, PageParams } from "@/types/page"; import { cn } from "@/lib/utils"; import { DETAIL_TABS, PACK_TYPES, VIEW_TABS, - getPackTypeLabel, resolveStatusLabel, type DetailTab, type KnowledgeView, @@ -45,7 +48,9 @@ import { buildPackMetrics, getErrorMessage, getPackTitle, + getUserFacingPackTypeLabel, normalizePackNameInput, + sanitizeKnowledgePreview, } from "./domain/knowledgeVisibility"; import { buildKnowledgeBuilderPrompt } from "./agent/knowledgePromptBuilder"; import { @@ -66,7 +71,8 @@ interface KnowledgePageProps { type AsyncStatus = "idle" | "loading" | "ready" | "error"; const WORKING_DIR_STORAGE_KEY = "lime.knowledge.working-dir"; -const DEFAULT_PACK_NAME = "founder-personal-ip"; +const LAST_PROJECT_ID_STORAGE_KEY = "agent_last_project_id"; +const DEFAULT_PACK_NAME = "project-material"; const DEFAULT_SOURCE_FILE_NAME = "source.md"; function readStoredWorkingDir(): string { @@ -89,10 +95,49 @@ function persistWorkingDir(value: string): void { } } +function isLikelyTransientWorkingDir(value: string): boolean { + const normalized = value.trim().replace(/\\/g, "/").toLowerCase(); + if (!normalized) { + return false; + } + + return ( + normalized.includes("/tmp/") || + normalized.includes("/var/folders/") || + normalized.includes("lime-knowledge-smoke") || + normalized.includes("lime-knowledge-") + ); +} + +function readReusableStoredWorkingDir(): string { + const storedWorkingDir = readStoredWorkingDir(); + return isLikelyTransientWorkingDir(storedWorkingDir) ? "" : storedWorkingDir; +} + +function readLastProjectId(): string { + if (typeof window === "undefined") { + return ""; + } + + const rawValue = window.localStorage + .getItem(LAST_PROJECT_ID_STORAGE_KEY) + ?.trim(); + if (!rawValue) { + return ""; + } + + try { + const parsed = JSON.parse(rawValue) as unknown; + return typeof parsed === "string" ? parsed.trim() : ""; + } catch { + return rawValue; + } +} + export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { const initialWorkingDir = pageParams?.workingDir?.trim() ?? ""; const initialStoredWorkingDir = - initialWorkingDir || readStoredWorkingDir(); + initialWorkingDir || readReusableStoredWorkingDir(); const [workingDirInput, setWorkingDirInput] = useState(() => initialStoredWorkingDir, ); @@ -120,9 +165,8 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { const [packNameInput, setPackNameInput] = useState( pageParams?.selectedPackName?.trim() || DEFAULT_PACK_NAME, ); - const [packDescription, setPackDescription] = - useState("创始人个人 IP 知识库"); - const [packType, setPackType] = useState("personal-ip"); + const [packDescription, setPackDescription] = useState("项目资料"); + const [packType, setPackType] = useState("brand-product"); const sourceFileName = DEFAULT_SOURCE_FILE_NAME; const [sourceText, setSourceText] = useState(""); @@ -194,6 +238,41 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { persistWorkingDir(normalizedFromParams); }, [pageParams?.workingDir, workingDir]); + useEffect(() => { + if (workingDir || pageParams?.workingDir?.trim()) { + return; + } + + let cancelled = false; + const lastProjectId = readLastProjectId(); + const projectPromise = lastProjectId + ? getProject(lastProjectId) + .catch(() => null) + .then((project) => project ?? getDefaultProject()) + : getDefaultProject(); + + void projectPromise + .then((project) => { + const nextWorkingDir = project?.rootPath.trim() ?? ""; + if (cancelled || !project || !nextWorkingDir) { + return; + } + + setSelectedProjectId(project.id); + setSelectedProjectName(project.name); + setWorkingDirInput(nextWorkingDir); + setWorkingDir(nextWorkingDir); + persistWorkingDir(nextWorkingDir); + }) + .catch(() => { + // 没有默认项目时保持空态,让用户通过项目选择器进入。 + }); + + return () => { + cancelled = true; + }; + }, [pageParams?.workingDir, workingDir]); + useEffect(() => { if (!workingDir) { return; @@ -489,7 +568,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { const normalizedPackName = selectedPackName || normalizePackNameInput(packNameInput); if (!workingDir || !normalizedPackName) { - setNotice("请先选择项目和资料包标识"); + setNotice("请先选择项目和资料名称"); return; } @@ -524,6 +603,21 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { workingDir, ]); + const handleOpenAgentKnowledgeHub = useCallback(() => { + onNavigate?.("agent", { + agentEntry: "claw", + projectId: selectedProjectId ?? undefined, + initialInputCapability: { + capabilityRoute: { + kind: "builtin_command", + commandKey: "knowledge_pack", + commandPrefix: "@资料", + }, + requestKey: Date.now(), + }, + }); + }, [onNavigate, selectedProjectId]); + const handleSendWithKnowledge = useCallback((packNameOverride?: string) => { const packName = packNameOverride?.trim() || @@ -549,6 +643,13 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { projectId: selectedProjectId ?? undefined, initialUserPrompt: "请基于当前项目资料生成内容", initialRequestMetadata: requestMetadata, + initialKnowledgePackSelection: { + enabled: true, + packName, + workingDir, + label: packForRequest ? getPackTitle(packForRequest) : packName, + status: packForRequest?.metadata.status, + }, autoRunInitialPromptOnMount: false, }); }, [ @@ -579,35 +680,35 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
- 资料管理 + 管理与确认

- 项目资料管理 + 项目资料

- 日常整理和使用资料请回到 Agent 输入框;这里用于检查、确认、设为默认和归档项目资料。 + 日常添加和使用请回到 Agent 输入框;这里只处理检查、确认、设为默认和归档。

- + {selectedPackReady ? ( + + ) : null}
@@ -620,7 +721,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {

- 当前项目资料库 + 当前项目

{selectedProjectName ? ( @@ -738,7 +839,8 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {

- {pack.preview || "请检查后确认。"} + {sanitizeKnowledgePreview(pack.preview) || + "请检查后确认。"}

))} @@ -762,8 +864,70 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {
{packs.length === 0 && catalogStatus !== "loading" ? ( -
- 还没有项目资料。回到 Agent 输入框,点击“添加项目资料”开始。 +
+
+
+

+ 还没有项目资料 +

+

+ 项目资料从日常工作里沉淀:先把内容交给当前 Agent,整理确认后再用于生成。 +

+
+
+ + +
+
+
+ {[ + [ + "从输入框添加", + "粘贴资料或写清目标,点击输入框下方的项目资料图标整理。", + MessageSquareText, + ], + [ + "从文件管理器添加", + "在文件管理器选择文本或 Markdown 文件,直接设为项目资料。", + FolderOpen, + ], + [ + "从结果继续沉淀", + "Agent 输出里出现可复用事实时,点击“沉淀为项目资料”。", + ClipboardCheck, + ], + ].map(([title, description, GuideIcon]) => { + const Icon = GuideIcon as typeof MessageSquareText; + return ( +
+
+ + {title as string} +
+

+ {description as string} +

+
+ ); + })} +
) : ( packs.map((pack) => ( @@ -794,7 +958,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) {

- 请先检查内容缺口、风险提醒和引用摘要;确认后才会成为可默认使用的资料包。 + 请先检查内容缺口、风险提醒和引用摘要;确认后才会成为可默认使用的项目资料。

@@ -1086,7 +1250,7 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { {getPackTitle(selectedPack)}

- {getPackTypeLabel(selectedPack.metadata.type)} + {getUserFacingPackTypeLabel(selectedPack.metadata.type)}

@@ -1192,7 +1356,8 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { 适用场景

- {selectedPack.guide || "等待整理适用场景。"} + {sanitizeKnowledgePreview(selectedPack.guide) || + "等待整理适用场景。"}

@@ -1210,7 +1375,8 @@ export function KnowledgePage({ onNavigate, pageParams }: KnowledgePageProps) { 引用摘要

- {entry.preview || "引用摘要"} + {sanitizeKnowledgePreview(entry.preview) || + "引用摘要已生成,可在 Agent 生成时作为参考。"}

)) diff --git a/src/features/knowledge/agent/knowledgePromptBuilder.ts b/src/features/knowledge/agent/knowledgePromptBuilder.ts index 0553d2a23..8f763e573 100644 --- a/src/features/knowledge/agent/knowledgePromptBuilder.ts +++ b/src/features/knowledge/agent/knowledgePromptBuilder.ts @@ -18,17 +18,15 @@ export function buildKnowledgeBuilderPrompt(params: { packType?: string; description?: string; }) { + const displayName = params.description?.trim() || "项目资料"; const lines = [ - "请整理这个资料包,生成可审阅的项目资料草稿。", + "请整理这份项目资料,生成一份可检查确认的资料草稿。", "", - `资料包:${params.packName}`, + `资料名称:${displayName}`, ]; if (params.packType?.trim()) { - lines.push(`类型:${params.packType.trim()}`); - } - if (params.description?.trim()) { - lines.push(`说明:${params.description.trim()}`); + lines.push(`资料类型:${params.packType.trim()}`); } lines.push( diff --git a/src/features/knowledge/components/FileEntryList.tsx b/src/features/knowledge/components/FileEntryList.tsx index 8b1594994..4853d582a 100644 --- a/src/features/knowledge/components/FileEntryList.tsx +++ b/src/features/knowledge/components/FileEntryList.tsx @@ -1,7 +1,7 @@ import type { KnowledgePackFileEntry } from "@/lib/api/knowledge"; import { buildEntryDisplayLabel, - formatCount, + getKnowledgeEntryPreview, } from "../domain/knowledgeVisibility"; export function FileEntryList({ @@ -27,26 +27,27 @@ export function FileEntryList({ {emptyLabel}
) : ( - entries.map((entry) => ( -
-
-
- {buildEntryDisplayLabel(title, entry)} + entries.map((entry) => { + const preview = getKnowledgeEntryPreview(title, entry); + return ( +
+
+
+ {buildEntryDisplayLabel(title, entry)} +
+
已整理
-
- {formatCount(entry.bytes, "字节")} -
-
- {entry.preview ? ( -

- {entry.preview} -

- ) : null} -
- )) + {preview ? ( +

+ {preview} +

+ ) : null} + + ); + }) )} diff --git a/src/features/knowledge/components/KnowledgePackCard.tsx b/src/features/knowledge/components/KnowledgePackCard.tsx index 2d417a0b5..be7b3bf5e 100644 --- a/src/features/knowledge/components/KnowledgePackCard.tsx +++ b/src/features/knowledge/components/KnowledgePackCard.tsx @@ -1,7 +1,10 @@ import { BookOpen, MessageSquareText, ShieldCheck } from "lucide-react"; import type { KnowledgePackSummary } from "@/lib/api/knowledge"; import { StatusPill } from "./StatusPill"; -import { getPackTitle } from "../domain/knowledgeVisibility"; +import { + getPackTitle, + sanitizeKnowledgePreview, +} from "../domain/knowledgeVisibility"; export function KnowledgePackCard({ pack, @@ -35,14 +38,17 @@ export function KnowledgePackCard({ ) : null}

- {pack.preview || "等待整理适用场景、事实和边界。"} + {sanitizeKnowledgePreview(pack.preview) || + "等待整理适用场景、事实和边界。"}