diff --git a/.gitignore b/.gitignore index 5260ff055..c01f62254 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,8 @@ docs/roadmap/* !docs/roadmap/i18n/*.md !docs/roadmap/agentapp/ !docs/roadmap/agentapp/*.md +!docs/roadmap/zuanjia/ +!docs/roadmap/zuanjia/*.md docs/gongzonghao/ docs/bussniss/ docs/oem/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8c49675e3..bb4ef3df3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,49 +1,48 @@ -## Lime v1.39.0 +## Lime v1.40.0 -发布日期:`2026-05-15` -递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note、核心质量校验与发布提交,不包含 tag / push / 正式分发。 +发布日期:`2026-05-16` +递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note、Rust / 前端核心质量校验、命令契约与 GUI smoke,不包含 tag / push / 正式分发。 -> 发布说明:上一版 release tag 为 `v1.38.0`。本版升级到 `v1.39.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.38.0 发布说明不再作为当前 release note 事实源保留。 +> 发布说明:上一版 release tag 为 `v1.39.0`。本版升级到 `v1.40.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.39.0 发布说明不再作为当前 release note 事实源保留。 ### 发布概览 -- 应用版本从 `1.38.0` 升级到 `1.39.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。 -- Agent Chat / Workbench 主路径继续拆分巨型渲染面,`MessageList`、输入栏、历史水合、滚动控制、任务轻卡、图片预览和 Team Workspace 选择器收敛到更小的组件、selector 与 hook。 -- i18n 资源按 Agent 子域拆包,新增并同步 `agentHome`、`agentInputbar`、`agentMessageList`、`agentRuntime`、`agentSkills`、`agentTeamWorkspace` 五语言资源,原 `agent.json` 大幅瘦身。 -- Tauri mock / DevBridge mock 从单体 `core.ts` 拆分为多个域模块,命令契约检查支持识别 `defaultMocks` 的 spread registry,避免 mock 拆分后契约扫描失真。 -- Release updater manifest 强化 macOS DMG / Windows installer URL 处理,缺失 macOS DMG 时阻断错误清单,避免官网下载页错误回退到 updater 包。 -- Agent App 方向新增路线图与技术设计文档,明确 capability SDK、AI 内容工程和 P0 技术闭环的后续主线。 +- 应用版本从 `1.39.0` 升级到 `1.40.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。 +- Agent App 从 Lab / 草案能力推进到正式主入口:新增 `Agent Apps` 管理页、已安装 App 侧栏入口、运行页与内容工厂样板,支持安装预览、启动入口、运行状态和任务提交闭环。 +- Agent App Host Bridge / Runtime 面补齐前后端命令:新增 app inspect / install / uninstall / state / runtime task 命令、DevBridge dispatcher、浏览器 mock、治理目录册和前端 API 网关,保持命令契约同源。 +- Agent App 安装链路补齐 cloud bootstrap、package cache、setup state store、lifecycle action、cleanup rehearsal / residual audit 和 package identity,减少“已安装但不可启动”的假入口。 +- 专家广场进入主导航:新增专家目录、详情面板、安装覆盖层、运行绑定、analytics 投影和 Agent 对话内专家启动参数,支持从专家入口恢复或创建专属对话。 +- 旧 SceneApp GUI / Rust 命令 / DevBridge / lib API 主体清退,保留必要 legacy summary / follow-up 兼容投影,避免 current Agent App 主线继续与旧入口双轨并存。 ### 用户可见更新 -- 聊天消息区在历史恢复、运行中消息、图片 / 音频 / 转写任务卡和 artifact 入口上更稳定,避免新草稿、历史水合或旧会话恢复时串入上一轮轻卡状态。 -- 输入栏与 Team 选择体验继续收敛:Team selector、工作流状态、技能选择、图片附件和多模态提示补齐五语言文案与回归断言。 -- 图片、音频、转写等媒体任务轻卡继续以统一 media task index 为事实源,LimeCore policy 输入缺口、阻断和 allow 状态展示进入本地化资源。 -- Skill Forge / Service Skill 入口链路继续补齐挂起参数、A2UI 表单、runtime binding 和显式 session enable 证据,减少“已注册”与“已可自动执行”之间的误解。 -- GUI smoke 覆盖 workspace ready、browser runtime、site adapter、runtime tool surface、`@` 命令注册、Claw streaming、知识库 GUI 和设计画布主路径。 +- 左侧导航新增 `专家` 与 `Agent Apps`,并可根据已安装 Agent App 动态展示可启动入口;原 `场景应用` 独立页面不再作为当前主入口。 +- Agent App 管理页支持查看安装状态、来源、入口、能力就绪度、卸载排练与运行启动结果;内容工厂样板可作为默认演示 App 跑通入口体验。 +- Agent App 运行页可基于 entry runtime guard 检查权限、能力与入口状态,并把任务提交到 Agent runtime,避免 UI 只展示静态预览。 +- 专家广场提供种子专家目录、专家详情、安装提示和对话启动入口;Agent 对话区可展示专家信息面板并携带专家启动上下文。 +- 自动化、记忆、Agent Home、工作区和设置页同步移除 SceneApp 旧文案 / 入口残留,五语言资源补齐 `agentExperts`、Agent App 与导航相关文案。 ### 开发者与治理更新 -- `src/lib/tauri-mock/` 拆出 agent runtime、browser、knowledge、media task、memory、provider、skill forge、update、voice、workspace 等 mock registry,降低单文件复杂度。 -- `scripts/check-command-contracts.mjs` 适配拆分后的 mock 事实源,继续检查前端命令、Rust 注册、mock priority 与 default mock 的一致性。 -- `scripts/release-updater-manifest.mjs` 和对应测试覆盖 installer_url、macOS DMG 缺失、Windows 安装包与 updater manifest 组合边界。 -- Rust runtime、Skill 执行与 timeline 服务补充 sequence 归一化、synthetic item、fast response model routing、workspace skill runtime enable 等定向测试。 -- 删除旧 Playwright 调试 JSON 快照,并通过 `.gitignore` 收敛临时调试产物,避免 release 提交继续携带一次性浏览器状态文件。 +- 新增 `src/features/agent-app/runtime/`、`src/features/agent-app/sdk/`、`src/features/agent-app/adapters/` 与 schema gate,明确 Host Capability、CapabilityHost、runtime package loader、workflow runtime host 与 UI extension host 的边界。 +- 新增 `src-tauri/src/commands/agent_app_cmd.rs`、`src-tauri/src/commands/agent_app_runtime_cmd.rs`、`src-tauri/src/dev_bridge/dispatcher/agent_apps.rs` 与 `src-tauri/crates/core/src/agent_app_runtime_token.rs`,让 Agent App state / runtime token / task 命令进入 Rust current 主路径。 +- `src/lib/api/agentApps.ts` 与 `src/lib/api/agentAppRuntime.ts` 成为前端 Agent App 命令事实源,并与 `src/lib/tauri-mock/agentAppMocks.ts`、`mockPriorityCommands`、`agentCommandCatalog` 同步。 +- `docs/roadmap/agentapp/` 扩展 P5-P17 执行计划,补齐 cloud bootstrap、schema gate、setup resolver、package cache、runtime loader、entry guard、正式入口、生命周期清理和 GUI smoke 演练路线。 +- `.gitignore` 放行 `docs/roadmap/zuanjia/*.md`,专家方向文档与 Agent App 路线图一并进入版本化事实源。 +- 测试修复覆盖 Inputbar 语音录制最短时长、AgentThreadMemoryPrefetchPreview locale、manifest / content factory fixture、ExpertInfoPanel hook 依赖与 schema / analytics lint 边界。 ### 校验状态 - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 -- `npm run verify:app-version`:通过,目标版本 `1.39.0`。 -- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v139" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1347 passed; 0 failed; 1 ignored`,集成测试通过;真实联网测试保持 ignored。 -- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v139" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。 +- `npm run verify:app-version`:通过,目标版本 `1.40.0`。 +- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v140" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1320 passed; 0 failed; 1 ignored`,集成测试通过;真实联网测试保持 ignored。 +- `CARGO_TARGET_DIR="$HOME/.cache/lime-cargo-target-v140" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。 - `npm run lint`:通过。 -- `npm run typecheck`:通过。 -- `npm test`:通过,`56/56` 批次通过。 -- `npm run test -- "src/components/agent/chat/workspace/useWorkspaceAudioTaskPreviewRuntime.test.tsx" "src/components/agent/chat/workspace/useWorkspaceTranscriptionTaskPreviewRuntime.test.tsx"`:通过,用于锁定媒体任务 policy meta 本地化回归。 +- `npm test`:通过,`57/57` 批次通过。 - `npm run test:contracts`:通过;命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。 -- `npm run verify:gui-smoke`:通过;复用 headless Tauri / DevBridge,覆盖 workspace、browser runtime、site adapters、Skill Forge、runtime tool surface、`@` 命令、Claw streaming、knowledge GUI 与 design canvas。 +- `npm run verify:gui-smoke`:通过;覆盖 DevBridge、workspace ready、browser runtime、site adapters、Skill Forge / Service Skill、runtime tool surface、`@` 命令注册、Claw streaming、knowledge GUI 与 design canvas。 - `git diff --check`:通过。 --- -**完整变更**: `v1.38.0` -> `v1.39.0` +**完整变更**: `v1.39.0` -> `v1.40.0` diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 23f8b583c..86200aeaa 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -117,7 +117,7 @@ AI 图层化设计扁平图 OCR 分析同样继续走 current `LayeredDesignDocu - 若服务端下发的 `renderContract` 超出 Lime 当前支持范围,优先由服务端回退到已支持类型,客户端也必须退化到通用 timeline / artifact 展示 - `scene` 的展示命名、推荐文案和补参标题应继续围绕创作生产语义收敛;`@发布合规` 只是发布前风控检查,不应被产品文案扩写成独立“法务场景”,也不要在目录里长出“建立”这类脱离创作目标的泛入口 -`SceneApp` 的统一目录与运行前规划命令同样只允许走单一网关。当前前端主入口为 `src/lib/api/sceneapp.ts`,统一承接: +`SceneApp` 独立应用面已经下线,Agent App 是应用目录与运行时装配的 current 事实源。以下旧命令统一判为 `dead`,不得重新接回前端网关、Rust `generate_handler!`、DevBridge、mock 或启动关键真相命令: - `sceneapp_list_catalog` - `sceneapp_get_descriptor` @@ -126,15 +126,40 @@ AI 图层化设计扁平图 OCR 分析同样继续走 current `LayeredDesignDocu - `sceneapp_create_automation_job` - `sceneapp_list_runs` - `sceneapp_get_run_summary` +- `sceneapp_prepare_run_governance_artifact` - `sceneapp_get_scorecard` 固定约束: -- `SceneApp` 命令当前属于目录查询、运行前规划与治理摘要主链,不应在页面组件里直接裸 `invoke` -- `sceneapp_plan_launch` 只负责 preview 当前 planning,并读取已有项目级 Context Snapshot;不要再把它当成自动落盘命令 -- 显式把当前灵感对象、输入摘要与风格基线写回项目目录时,只能走 `sceneapp_save_context_baseline` -- 新的 SceneApp UI 先消费 `SceneAppDescriptor / SceneAppPlanResult / SceneAppScorecard`,不要重新从 `serviceSkills.ts`、`skillCatalog.ts`、卡片配置和 selector 里各自拼语义 -- 真正的执行仍应通过 runtime adapter 继续挂回现有本地主链,例如 `agent turn / browser_assist / automation_job / native_skill`;历史 `cloud_scene` 只允许按 compat binding family 理解,不再代表 current 执行面;`sceneapp_*` 不用于重新发明第二套 runtime taxonomy +- 不再恢复 `sceneapps` 独立页面、`SceneAppsPageParams`、最近访问恢复或目录页运行时;应用入口继续收敛到 `agent-app-lab` / Agent App 主链 +- 不再恢复 `src/lib/sceneapp/catalog.ts`、`listSceneAppCatalog`、`SceneAppCatalog`、目录统计卡或目录卡片 view model;应用目录与运行装配继续由 Agent App current 协议承接 +- 历史 `sceneapp_execution_summary` 只允许作为 Agent Chat / Automation 的只读结果摘要与灵感沉淀上下文保留,不能再触发新的 SceneApp 运行前规划、自动化创建、runs/scorecard 轮询或治理 artifact 动态准备 +- 若后续需要应用级目录、安装、运行、复盘或治理摘要,应扩展 Agent App current 协议,而不是复活 `sceneapp_*` 命令族 + +Agent App current 安装 / package / runtime 主链不得在页面或 feature island 里直接 `safeInvoke` / `invoke`。安装、package、UI runtime 生命周期统一经由 `src/lib/api/agentApps.ts`;完整 Agent task facade 统一经由 `src/lib/api/agentAppRuntime.ts`: + +- `agent_app_inspect_local_package` +- `agent_app_fetch_cloud_package` +- `agent_app_save_installed_state` +- `agent_app_list_installed` +- `agent_app_set_disabled` +- `agent_app_uninstall_rehearsal` +- `agent_app_uninstall` +- `agent_app_start_ui_runtime` +- `agent_app_get_ui_runtime_status` +- `agent_app_stop_ui_runtime` +- `agent_app_runtime_start_task` +- `agent_app_runtime_cancel_task` +- `agent_app_runtime_get_task` +- `agent_app_runtime_submit_host_response` + +`agent_app_fetch_cloud_package` 只负责 `packageUrl -> staging/cache -> APP.md manifest extraction -> sha256 package / manifest verification`,不生成 projection、不写 installed state、不绕过 P17.2 install review;Cloud / LimeCore 仍只提供 release metadata。 + +`agent_app_start_ui_runtime` 启动 App UI 子进程时只能注入 Lime 本机 Gateway 的短期 Agent App scoped token;不得把上游 Provider API Key 或全局 `server.api_key` 原样下发给 App。当前 token scope 固定为 `model-generation`,只允许 App 侧通过 `LIME_GATEWAY_BASE / LIME_ACCESS_TOKEN` 调 Lime Gateway 标准 `/v1/chat/completions` 或 `/v1/messages` 生成端点;图片、count tokens、Gemini 原生和其他控制面端点仍只接受全局 Gateway key。 + +`agent_app_runtime_*` 是 Agent App 进入 AgentRuntime 主链的 current facade:它只把 App-scoped task / host response 适配到 `agent_runtime_submit_turn`、`agent_runtime_interrupt_turn`、`agent_runtime_get_thread_read` 和 `agent_runtime_respond_action`,不得复制 Claw `*_skill_launch.rs`、不得新增垂直 `content_factory_*` Agent 命令,也不得把 `LIME_GATEWAY_*` 直接模型调用宣称为完整 Agent 能力。 + +P17.3 之前禁止真实删除 Agent App 本地数据:`agent_app_uninstall_rehearsal` 只生成 keep-data / delete-data 演练,`agent_app_uninstall` 只能返回同一演练摘要和未删除的 installed list,不得执行 `remove_file` / `remove_dir_all` 或移除 installed state。真实 delete-data 必须等后续路线图单独打开并补齐 evidence / residual audit / confirmation gate。 技能脚手架创建同样只允许走当前命令网关主链: diff --git a/docs/roadmap/agentapp/README.md b/docs/roadmap/agentapp/README.md index 81b5aff96..fdfc10c05 100644 --- a/docs/roadmap/agentapp/README.md +++ b/docs/roadmap/agentapp/README.md @@ -10,6 +10,8 @@ 服务端 / Cloud / LimeCore 的职责不写在本目录;对应文档在 `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp`。 +产品边界必须固定:Agent App 是业务工作台,Lime Agent 是 App 可编排的智能运行时。用户应该在 App 内完成业务流程,而不是为了生成、确认、交付或复盘被迫跳回 Lime 通用 Chat UI。Chat / Expert 只是 App 的一种入口或嵌入式协作方式,不是业务流程的唯一容器。 + ```text Lime Cloud / LimeCore Catalog / Release / License / Tenant Enablement / Gateway / ToolHub @@ -25,17 +27,37 @@ Agent App Runtime Package | 分类 | 对象 | 说明 | |---|---|---| -| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.3 标准、runtime package、schema、参考 CLI 与示例包。 | +| current | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.3 标准、runtime package、public JSON Schema、reference CLI、示例包与宿主实现指南。 | | current | `docs/roadmap/agentapp/capability-sdk.md` | Lime Desktop 侧 Capability SDK 与 Host Bridge 方案。 | -| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P4。 | +| current | `docs/roadmap/agentapp/implementation-plan.md` | 客户端落地实施方案,覆盖 P0-P17.5 当前计划;P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,P17.4.5 已在健康 DevBridge 下通过完整 GUI smoke;下一刀进入 P17.5 formal entry GUI smoke。 | | current | `docs/roadmap/agentapp/p0-technical-design.md` | P0 只读 App Host 技术设计,覆盖 manifest、projection、readiness、cleanup dry-run。 | | current | `docs/roadmap/agentapp/p1-mock-capability-host.md` | P1 Mock Capability Host 技术设计,覆盖 SDK facade、mock artifact/evidence、uninstall delete-data。 | | current | `docs/roadmap/agentapp/p2-adapter-capability-host.md` | P2 Adapter Capability Host 技术设计,覆盖本地 adapter store、knowledge / agent adapter、provenance 查询、delete-data 清理。 | | current | `docs/roadmap/agentapp/p3-ui-extension-host.md` | P3 UI Extension Host 技术设计,覆盖受控 UI Host、sandbox、injected SDK bridge、Lab 预览。 | -| current | `docs/roadmap/agentapp/p4-content-factory-demo.md` | P4 APP 内容工厂最小闭环,覆盖项目、知识、内容场景、内容资产、Artifact、Evidence、cleanup。 | +| current | `docs/roadmap/agentapp/p4-content-factory-demo.md` | P4 内容工厂最小闭环,覆盖项目、知识、内容场景、内容资产、Artifact、Evidence、cleanup。 | | current | `docs/roadmap/agentapp/p4-workflow-runtime.md` | P4.2 受控 Workflow Runtime,覆盖白名单 DSL、runtime policy、trace、cancel 和内容工厂 demo 迁移。 | -| current | `docs/roadmap/agentapp/content-factory-app.md` | APP 内容工厂作为客户端标杆案例的 UI / storage / workflow / artifact 方案。 | -| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图。 | +| current | `docs/roadmap/agentapp/content-factory-app.md` | 内容工厂作为客户端标杆案例的 UI / storage / workflow / artifact 方案。 | +| current | `docs/roadmap/agentapp/v0.3-rebaseline-plan.md` | v0.3 / 内容工厂升级后的实施重排计划,记录 P4-R rebaseline 与验证证据。 | +| current | `docs/roadmap/agentapp/p5-cloud-bootstrap.md` | P5 Cloud Bootstrap 客户端接入计划,覆盖 payload 校验、企业定制注册码、统一 package source、readiness 合并和 disable / offline / upgrade 回归。 | +| current | `docs/roadmap/agentapp/p6-schema-coverage.md` | P6 v0.3 projection / readiness schema coverage,覆盖 services、workflows、skills、tools、evals、secrets、overlays、lifecycle 和 setup checks。 | +| current | `docs/roadmap/agentapp/p7-schema-gate.md` | P7 本地 schema / snapshot gate,机械验证 projection / readiness 结构与 setup issue 字段。 | +| current | `docs/roadmap/agentapp/p8-setup-resolver.md` | P8 setup resolver / needs-setup 语义,把结构性 setup checks 推进到明确 readiness 状态。 | +| current | `docs/roadmap/agentapp/p9-setup-state-store.md` | P9 installed setup state store,把 setup resolver 输入推进为本地可查询、可清理的 binding 状态。 | +| current | `docs/roadmap/agentapp/p10-installed-state-persistence.md` | P10 installed app state snapshot,把 preview、setup、projection、readiness 汇总为可清理的可序列化安装状态。 | +| current | `docs/roadmap/agentapp/p11-local-persistence-adapter.md` | P11 Local Persistence Adapter,把 installed state 从 in-memory 推进到本地可恢复 persistence adapter。 | +| current | `docs/roadmap/agentapp/p12-package-cache-verify-rollback.md` | P12 Package Cache / Verify / Rollback,覆盖 package cache、hash verify、cached fallback、upgrade staging 和 rollback。 | +| current | `docs/roadmap/agentapp/p13-runtime-package-loader.md` | P13 Runtime Package Loader / UI Bundle Loader,覆盖已验证 package 的 runtime descriptor 和 UI bundle descriptor 加载边界。 | +| current | `docs/roadmap/agentapp/p14-entry-runtime-guard-permission-prompt.md` | P14 Entry Runtime Guard / Permission Prompt 实现,合并 entry readiness、permission、setup state、package verification 和 runtime policy。 | +| current | `docs/roadmap/agentapp/p15-lab-install-launch-flow.md` | P15 Lab Install / Launch Flow 实现,把安装审查、权限确认、启动和清理串成 Lab-only 端到端流程。 | +| current | `docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md` | P15-H GUI smoke / cleanup rehearsal hardening,记录 Lab 专用 smoke、证据输出、清理演练和 P16 入口 gate。 | +| current | `docs/roadmap/agentapp/p16-agent-app-manager-product-entry-gate.md` | P16 Agent App Manager / Product Entry Gate,记录实验岛内 App 管理面、entry launcher、lifecycle action、cleanup evidence 与剩余 P16-H 缺口。 | +| current | `docs/roadmap/agentapp/p16-h-multi-app-repository-lifecycle-hardening.md` | P16-H Multi-app repository / lifecycle hardening,记录多 App list、持久化 lifecycle、cleanup evidence export、residual audit 和 P17 前 gate。 | +| current | `docs/roadmap/agentapp/p17-formal-entry-gate-audit.md` | P17 正式入口前 Gate 审计,逐项映射 P16-H 证据、正式 Agent Apps 入口条件和剩余禁区。 | +| current | `docs/roadmap/agentapp/p17-formal-entry-contract.md` | P17.0 正式入口契约,固定 `agent-apps`、`agent-app-lab` 与 runtime surface 的职责边界、禁区、架构和后续 gates。 | +| current | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 Source / Install Contract Hardening 当前执行计划,固定 source state、install review、registration、Cloud release metadata、package verify、cached fallback 与 reference CLI cross-check 边界。 | +| current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 Lifecycle / Cleanup Contract Hardening 已完成执行计划,固定正式入口 enable / disable、uninstall rehearsal、cleanup evidence、residual audit 与 namespace 归属边界。 | +| current | `docs/roadmap/agentapp/p17-4-host-bridge-runtime.md` | P17.4-H Host Bridge Runtime 已完成执行计划,固定正式 App iframe 与 Lime Host 的 `lime.agentApp.bridge` 事件协议、主题同步、Host action、capability invoke、App-scoped Agent task 与结构化写回边界;P17.4.5 已通过完整 GUI smoke。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 catalog、release、tenant enablement、gateway、ToolHub 路线图;LimeCore 只做 control-plane metadata,真实 release 必须提供 `https` package URL 和完整 package / manifest hash,未激活注册码时不得下发可安装 package。 | ## 文档索引 @@ -49,7 +71,27 @@ Agent App Runtime Package | [p3-ui-extension-host.md](./p3-ui-extension-host.md) | P3 技术设计:受控 UI Host、sandbox policy、injected SDK bridge、Lab 预览。 | | [p4-content-factory-demo.md](./p4-content-factory-demo.md) | P4 技术设计:内容工厂最小闭环、content table Artifact、Evidence、delete-data 清理。 | | [p4-workflow-runtime.md](./p4-workflow-runtime.md) | P4.2 技术设计:受控 workflow runtime、白名单 DSL、trace、cancel、policy guard。 | -| [content-factory-app.md](./content-factory-app.md) | APP 内容工厂 Product-level Agent App 的客户端产品与实现方案。 | +| [content-factory-app.md](./content-factory-app.md) | 内容工厂 Product-level Agent App 的客户端产品与实现方案。 | +| [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md) | Agent App v0.3 / 内容工厂升级后的 rebaseline 实施计划。 | +| [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) | P5 技术计划:Cloud bootstrap 作为客户端 package source 输入,继续保持本地 install / projection / readiness / runtime / cleanup。 | +| [p6-schema-coverage.md](./p6-schema-coverage.md) | P6 技术计划:补齐 v0.3 projection / readiness schema coverage,但不进入正式主路径。 | +| [p7-schema-gate.md](./p7-schema-gate.md) | P7 技术计划:用本地 schema / snapshot gate 防止 projection / readiness 字段漂移。 | +| [p8-setup-resolver.md](./p8-setup-resolver.md) | P8 技术计划:增加 setup resolver 输入和 `needs-setup` readiness 语义。 | +| [p9-setup-state-store.md](./p9-setup-state-store.md) | P9 技术计划:本地 setup state store、readiness 输入和 cleanup preview 集成。 | +| [p10-installed-state-persistence.md](./p10-installed-state-persistence.md) | P10 技术计划:installed state snapshot、in-memory store、cleanup preview 集成。 | +| [p11-local-persistence-adapter.md](./p11-local-persistence-adapter.md) | P11 技术实现:本地 persistence adapter、启动恢复、坏文件隔离、delete-data 清理。 | +| [p12-package-cache-verify-rollback.md](./p12-package-cache-verify-rollback.md) | P12 技术实现:package cache、hash verify、cached fallback、upgrade staging 和 rollback。 | +| [p13-runtime-package-loader.md](./p13-runtime-package-loader.md) | P13 技术实现:runtime package loader、bundle manifest gate、UI host 接线前安全边界。 | +| [p14-entry-runtime-guard-permission-prompt.md](./p14-entry-runtime-guard-permission-prompt.md) | P14 技术计划:entry runtime guard、permission prompt descriptor、setup / readiness / policy 合并。 | +| [p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md) | P15 技术实现:Lab install / launch flow、install review、permission prompt、runtime launch、cleanup preview。 | +| [p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md) | P15-H 技术实现:Agent App Lab 专用 GUI smoke、证据输出、cleanup rehearsal 与 P16 gate。 | +| [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md) | P16 技术计划:Agent App Manager、entry launcher、lifecycle actions、cleanup evidence 和正式入口前 gate。 | +| [p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md) | P16-H 技术计划:多 App repository list、持久化 lifecycle、cleanup evidence export、residual audit 和 P17 前 gate。 | +| [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md) | P17 Gate 审计:正式入口前 checklist、验证记录、进入条件和 P17.0 契约入口。 | +| [p17-formal-entry-contract.md](./p17-formal-entry-contract.md) | P17.0 技术计划:正式 `agent-apps` 入口契约、Lab 边界、runtime surface、架构 / 时序 / 流程、后续 gates。 | +| [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md) | P17.2 技术计划:正式入口 source state、install review、registration、Cloud release metadata 与客户端安装边界。 | +| [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) | P17.3 技术计划:正式入口 lifecycle、cleanup rehearsal、evidence export、residual audit 与 namespace 分离。 | +| [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md) | P17.4-H 技术计划:Host Bridge v1、主题同步、Host action、capability invoke、iframe message 安全边界。 | ## 客户端边界 @@ -60,6 +102,10 @@ Agent App Runtime Package 5. 客户数据、workspace data、secrets 和 overlay 不进入官方 App package。 6. Expert 只是 `expert-chat` entry;客户端必须支持非聊天页面和业务 workflow。 7. Cloud 下发 release / tenant enablement,客户端做本地 readiness 和能力注入。 +8. App UI 与 Lime Host 的主题、语言、导航、通知、下载和 capability 请求统一走 `lime.agentApp.bridge`,不允许每个 App 自定义私有桥。 +9. App 拥有业务 UI、业务 workflow、业务状态、结构化结果写回和人工确认;Lime Agent / Host 拥有 task runtime、模型 / 工具 / 知识调用、权限、trace、Artifact、Evidence 和 cleanup。 +10. 通用 Chat 不能成为 Agent App 主流程的强制回跳点;如果核心工作仍需在 Chat 里完成,说明 App 只是 Chat 包装壳,不是 product-level Agent App。 +11. App 也不能为了避免回 Chat 而自建模型网关、凭证系统、权限系统、证据系统或工具调度器;这些属于 Lime capability。 ## 主路线 @@ -68,27 +114,95 @@ flowchart TD Standard[Agent App v0.3 Standard] --> Parser[Manifest Parser] Parser --> Projection[Client Projection] Projection --> Installer[Local Installer] - Installer --> SDK[Capability SDK] + Installer --> Guard[Entry Runtime Guard] + Guard --> SDK[Capability SDK] SDK --> UI[UI Extension Host] SDK --> Storage[Storage Namespace] SDK --> Worker[Worker / Workflow Runtime] - SDK --> Agent[Local Agent Runtime Bridge] + SDK --> Agent[App-scoped Agent Task Runtime] SDK --> Artifact[Artifact + Evidence] - Artifact --> App[APP 内容工厂 P0] + Agent --> WriteBack[Structured Result Write-back] + WriteBack --> Storage + Artifact --> App[内容工厂 P0] ``` ## 当前执行顺序 -当前已推进到 P4.1 内容工厂最小闭环: +路线图已升级到 Agent App v0.3 / 内容工厂;上游标准进一步补齐 discovery / installation、release metadata、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema 与 reference CLI。P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5 与 P17.3 lifecycle / cleanup contract 已完成当前实现 / 专用验证 / 计划收口。P17.4 runtime surface production hardening 已完成 guard-before-start、App 内 Agent task stream / get / cancel / retry、structured write-back guard、content factory bootstrap sample 与 P17.4.5 完整 GUI smoke;当前进入 P17.5 formal entry GUI smoke,仍不做 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统: ```text P0 manifest / projection / readiness / cleanup dry-run → P1 Mock Capability Host → P2 Adapter Capability Host → P3.1 UI Extension Host mount contract -→ P4.1 APP 内容工厂业务闭环 +→ P4-R v0.3 / 内容工厂 Rebaseline(已通过) +→ P4.1 内容工厂业务闭环 → P4.2 受控 Workflow Runtime -→ P5 Cloud Bootstrap +→ P5.0 v0.3 标准差距收口(已完成) +→ P5.1 Cloud Bootstrap payload DTO / parser / validator(已完成) +→ P5.2 统一 Package Source Adapter(已完成) +→ P5.3 Tenant Enablement + Local Readiness 合并(已完成) +→ P5.4 Disable / Upgrade / Offline Regression(已完成) +→ P5.5 Schema / Reference CLI Cross-check(已完成) +→ P6 v0.3 Projection / Readiness Schema Coverage(已完成) +→ P7 Schema Validator / Reference Snapshot Gate(已完成) +→ P8 Setup Resolver / Needs-Setup Semantics(已完成) +→ P9 Installed Setup State Store(已完成) +→ P10 Installed App State / Persistence(已完成) +→ P11 Local Persistence Adapter(已完成) +→ P12 Package Cache / Verify / Rollback(已完成) +→ P13 Runtime Package Loader / UI Bundle Loader(已完成) +→ P14 Entry Runtime Guard / Permission Prompt(已完成) +→ P15 Lab Install / Launch Flow(已完成) +→ P15-H GUI smoke / cleanup rehearsal hardening(已完成专用证据) +→ P16 Agent App Manager / Product Entry Gate(已完成最小实现) +→ P16-H.0 Multi-app repository / lifecycle hardening 计划收口(已完成) +→ P16-H.1 Repository-backed multi-app list(已完成最小实现) +→ P16-H.2 Selected app launcher + persisted lifecycle(已完成最小实现) +→ P16-H.3 Cleanup rehearsal evidence export(已完成最小实现) +→ P16-H.4 Residual audit(已完成最小实现) +→ P16-H.5 Agent App Lab GUI smoke + flag-off regression(已完成最小实现) +→ P17 正式入口前 Gate 审计(已完成) +→ P17.0 Formal Entry Contract(已完成计划收口) +→ P17.1 Formal route / nav / copy hardening(已完成最小实现) +→ P17.2.1 Source state model(已完成最小实现) +→ P17.2.2 Install review descriptor(已完成最小实现) +→ P17.2.3 Registration hardening(已完成最小实现) +→ P17.2.4a Cloud release descriptor / verification gate(已完成最小实现) +→ P17.2.4b-1 Cloud package acquisition seam / verified cache source(已完成最小实现) + ↳ sidecar:P17.4 Formal runtime surface pre-work(已完成补丁;不改变 P17.2 顺序) +→ P17.2.4b-2 packageUrl fetch / staging / manifest extraction(已完成最小实现) +→ P17.2.5 Schema / reference CLI / example package cross-check(已完成) +→ P17.3 Lifecycle / cleanup contract hardening(已完成) +→ P17.4 Runtime surface production hardening(已完成,含 P17.4.5 GUI smoke) +→ P17.5 Formal entry GUI smoke(下一刀,和 Lab smoke 分离) +→ P18 Typed Capability SDK / App-scoped Agent Task contract(让业务流程在 App 内完成,不回跳通用 Chat) ``` -不要先做市场页、Cloud、完整行业内容系统,也不要先把 entry 接入正式主路径;P5 前仍保持 Lab 实验岛。 +不要先做市场页、Cloud 管理台、完整行业内容系统,也不要让 App 绕过 SDK;P17 Gate 只证明可以进入正式入口设计,不等于发布 marketplace。当前最优先任务已推进到 P17.5:在 P17.4.5 完整 GUI smoke 已通过的基础上,为正式 `agent-apps` 入口补独立 smoke;Lab smoke 和全局 GUI smoke 只能作为辅助证据。 + +## 下一刀判定 + +P5.0-P5.5、P6、P7、P8、P9、P10、P11、P12、P13、P14、P15、P15-H、P16、P16-H、P17 Gate、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 与 P17.4.1-P17.4.5 已通过定向 / 专用验证 / GUI smoke / 计划收口;当前进入 P17.5 formal entry GUI smoke: + +1. P17.5 只做正式 `agent-apps` 入口 smoke 与证据 summary,不新增 marketplace、Cloud 管理台或完整行业系统。 +2. `agent-apps` 是用户入口,`agent-app-lab` 是研发验证入口;两者不能共用用户心智和验收证据。 +3. 继续复用 P11 repository、P14 guard、P15 cleanup preview、P16-H evidence / audit,不新增第二套 store / scanner / installer。 +4. Cloud / LimeCore 继续只提供 catalog / release / tenant metadata,不运行 Agent、不渲染 UI、不接管本地 storage。 +5. P17.2.4b-2 已新增唯一 package fetch / staging 命令 `agent_app_fetch_cloud_package`,调用只集中在 `src/lib/api/agentApps.ts`;Agent App feature island 仍不得直接 `safeInvoke` / `invoke`。 + +当前计划更新口径: + +- P16-H.3 已落纯函数 evidence builder、Manager JSON 预览和 Lab smoke 证据。 +- P16-H.4 已落 residual audit summary,只做 namespace 内残留检查,不做真实删除。 +- P16-H.5 已扩展 Agent App Lab 专用 smoke 与 flag-off 回归,不替代全局 GUI smoke,也不发布正式入口。 +- P17.0 已新增正式入口契约。 +- P17.1 已完成 `agent-apps` 路由、导航、文案和测试边界最小硬化。 +- P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 和 registration refresh。 +- P17.2.4a 已完成 release descriptor、verification gate、hash mismatch blocker 和 exact-hash cached fallback。 +- P17.2.4b-2 已完成 `packageUrl` fetch / staging / manifest extraction:缺 verified cache 时通过集中命令下载 `https` package、校验 package / manifest sha256、解包 `APP.md` 并写入 verified cache。 +- P17.3 lifecycle / cleanup contract hardening 已完成:formal 页面 disable / enable / uninstall rehearsal、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression 已收口;`agent_app_uninstall` 保持 rehearsal-only,不碰真实 delete-data。 +- P17.4.1-P17.4.4 已完成:runtime surface guard-before-start、App-scoped Agent task start / stream / get / cancel / retry、storage / artifact / evidence 声明级写回 guard,以及 content factory promise-based Host Bridge 样板。 +- LimeCore 服务端已明确:`content-factory-app` seeded catalog 不能内置假 release / 假下载地址 / 占位 hash;客户端 P17.2.4b-2 已把 package metadata 作为 release descriptor 输入,未激活注册码时仍停在 source state,不用 seeded fixture 或 dev resolver 兜底。 +- P17.4.5 已完成:代码层 contracts、typecheck、feature island boundary、定向测试与完整 `verify:gui-smoke -- --reuse-running --timeout-ms 300000` 均有证据;本轮未结束或重启隔壁 Tauri / Cargo / Vite 任务。 +- 下一刀 P17.5:新增或扩展正式 `agent-apps` smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off,并输出独立 summary。 diff --git a/docs/roadmap/agentapp/capability-sdk.md b/docs/roadmap/agentapp/capability-sdk.md index 12278b422..82ea5afae 100644 --- a/docs/roadmap/agentapp/capability-sdk.md +++ b/docs/roadmap/agentapp/capability-sdk.md @@ -1,11 +1,13 @@ # Agent App 客户端 Capability SDK 方案 -更新时间:2026-05-15 +更新时间:2026-05-16 ## 一句话目标 在 Lime Desktop 客户端侧提供稳定、版本化、可授权、可 mock 的 Capability SDK;Agent App 通过 SDK 调用本地平台能力,自己实现 UI、业务流程、数据模型和交付物,而不重复造 Lime 客户端底座、不依赖内部实现。 +更严格地说:业务用户应留在 App 的业务工作台里完成任务;Lime Agent 通过 `lime.agent` / `lime.workflow` 作为 App 可编排的任务运行时被调用。SDK 不是把用户送回通用 Chat 的跳板,也不是允许 App 绕过 Lime 自建模型、工具、凭证和证据系统的逃逸口。 + ## 边界 本文只讨论 Lime Desktop / Lime 客户端侧能力:App Host、Capability Bridge、UI Host、Storage Namespace、Worker / Workflow Runtime、本地 Agent Runtime Bridge。Cloud / LimeCore 的 catalog、release、tenant enablement、gateway、ToolHub 只作为上游输入,不在本文设计。 @@ -34,6 +36,9 @@ 6. **Cloud 不跑默认 Agent**:Cloud 做 catalog、release、license、tenant enablement、gateway。 7. **可 mock 和 contract test**:每个 capability 都要有 mock host 和契约测试。 8. **升级不覆盖用户资产**:官方包、tenant overlay、workspace data、secrets 分离。 +9. **业务不出 App**:Agent 任务进度、引用、错误、人工确认和结构化结果都应回到 App UI / workflow 内呈现。 +10. **Agent 不出 Lime 治理**:模型、工具、知识、文件、凭证、Artifact、Evidence、成本和权限都必须通过 `lime.*` capability。 +11. **完整 Agent 能力不等于模型 API**:`LIME_GATEWAY_*` / `OPENAI_BASE_URL` 只能是低阶模型 executor 或 degraded fallback;`lime.agent` / `lime.workflow` 的生产事实源必须回到 `docs/roadmap/agentruntime/app-surface-runtime.md` 定义的 AgentRuntime Surface。 ## 能力地图 @@ -73,6 +78,114 @@ flowchart TD Installer --> Bridge ``` +## 与 AgentRuntime Surface 的关系 + +Capability SDK 是 App 调用 Lime 能力的 facade,不是新的 Agent Runtime。Agent App、Claw Chat、Automation 共享的执行事实源是 AgentRuntime: + +```text +Agent App + -> @lime/app-sdk + -> Host Bridge / Capability Bridge + -> Agent App Runtime Surface + -> AgentRuntime control plane + -> Aster / lime_agent / Claw capability / Skills / Tools / Evidence +``` + +边界固定如下: + +| 层 | 做什么 | 不做什么 | +|---|---|---| +| `@lime/app-sdk` | 暴露 `lime.storage`、`lime.agent`、`lime.workflow` 等稳定 facade。 | 不暴露 Lime internal path,不执行模型和工具。 | +| Host Bridge | 安全传输、主题、语言、capability invoke、Host action。 | 不保存执行事实,不判断任务完成。 | +| Agent App Runtime Surface | 把 App task / workflow 映射成 AgentRuntime request,并附加 app provenance。 | 不复制 Claw skill launch,不新建第二套 runtime。 | +| AgentRuntime | 维护 session/thread/turn/task/event/read model/evidence。 | 不决定垂直 App UI 形态。 | +| Claw Capability Catalog | 把现有 `@配图`、`@搜索`、`@研报` 等能力注册为可复用 capability。 | 不再让能力只绑定 Chat/Inputbar。 | + +生产期 `lime.agent.startTask` / `lime.workflow.start` 必须通过后端 AgentRuntime Surface;前端 `CapabilityHost` / `WorkflowRuntimeHost` 只能作为 adapter、mock 或本地预览,不得成为生产执行事实源。详细设计见: + +- `docs/roadmap/agentruntime/app-surface-runtime.md` +- `docs/roadmap/agentruntime/backend-surface-facade-plan.md` +- `docs/roadmap/agentruntime/claw-capability-sharing.md` + +## Host Bridge v1 + +正式 App UI 运行在 iframe / sandbox 中时,Capability SDK 不能依赖 React context、全局 store 或直接 import Lime 模块。UI 侧 SDK 请求必须通过标准 Host Bridge 传输: + +```text +App UI + -> @lime/app-sdk facade + -> lime.agentApp.bridge message + -> AgentAppHostBridge + -> P14 guard / readiness / policy + -> Lime capability implementation +``` + +标准信封: + +```ts +interface LimeAgentAppBridgeMessage { + protocol: "lime.agentApp.bridge"; + version: 1; + type: string; + requestId?: string; + appId: string; + entryKey?: string; + payload?: unknown; +} +``` + +首版 Host Bridge 覆盖: + +| 方向 | 事件 | 说明 | +|---|---|---| +| Host -> App | `host:snapshot` / `theme:update` | 同步主题、语言、入口上下文、capability 摘要。 | +| Host -> App | `host:response` / `host:error` | 按 `requestId` 返回 SDK / Host action 结果。 | +| Host -> App | `host:visibility` | 页面可见性变化,供 App 暂停或恢复轻量同步。 | +| App -> Host | `app:ready` / `host:getSnapshot` | App 初始化和快照补偿。 | +| App -> Host | `host:toast` / `host:navigate` | 非技术提示和受控导航。 | +| App -> Host | `host:openExternal` / `host:download` | 受控外链和同源产物下载。 | +| App -> Host | `capability:invoke` | SDK capability 调用统一入口。 | + +安全规则: + +1. Host 必须校验 `event.source`、`event.origin`、`protocol`、`version`、`appId`、`entryKey`。 +2. `capability:invoke` 仍必须经过 manifest 声明、entry readiness、permission、policy 和 provenance。 +3. 未开放能力返回 blocked error,不返回 mock 成功、不写假数据。 +4. 主题同步只传当前已生效 token;App 只应用到自己的 DOM,不读取外层 DOM。 + +## App 内 Agent Task API + +`lime.agent` 是 App 调用 Lime Agent 的主能力,不是跳转通用 Chat 的快捷方式。App 决定业务任务何时启动、需要什么上下文、结果写回哪个业务对象;Host 决定 Agent task 如何运行、可用哪些工具 / 知识 / secret、如何记录 trace / artifact / evidence。 + +最小请求语义: + +```ts +interface LimeAgentTaskRequest { + appId: string; + entryKey: string; + taskKind: string; + idempotencyKey: string; + input: unknown; + expectedOutput?: unknown; + knowledge?: Array<{ key: string; mode: "retrieval" | "data" }>; + tools?: string[]; + humanReview?: boolean; +} +``` + +最小运行语义: + +| 阶段 | App 责任 | Lime Host / Agent 责任 | +|---|---|---| +| Start | 从当前页面 / workflow 组装业务输入、期望结构和幂等键。 | 校验 manifest、entry readiness、permission、policy、cost。 | +| Stream | 在 App 内显示进度、引用、工具调用、错误和可取消状态。 | 发送 `taskId`、`traceId`、status、tool call、citation、partial artifact、blocked error。 | +| Review | 让用户编辑、确认、拒绝或重试结果。 | 保留 trace,确保重试和取消可审计。 | +| Write-back | 通过 `lime.storage` 写业务对象,通过 `lime.artifacts` / `lime.evidence` 写交付物和依据。 | 自动附加 appId、entryKey、package provenance、workspace / tenant 上下文。 | + +验收口径:内容工厂的资料整理、场景生成、批量文案、交付和复盘都应在 App 页面内启动、观察、确认和写回;Expert Chat 只能作为嵌入式协作者读取同一上下文,不允许成为手工复制结果的旁路。 + +后端验收口径:App task 启动后必须能关联 `sessionId / threadId / turnId / taskId / traceId`,并可由 AgentRuntime read model / Evidence Pack 追溯;不能只返回模型文本或前端 mock task。 + ## 安装时序 ```mermaid @@ -175,7 +288,7 @@ entries: ### P1:真实垂直 App 验证 -- 用 APP 内容工厂验证 UI、storage、workflow、worker、Agent task、Artifact。 +- 用 内容工厂验证 UI、storage、workflow、worker、Agent task、Artifact。 - 支持文件选择、文档解析、Knowledge binding、批量生成和去 AI 味 eval。 - App 所有业务 UI 不进 Lime Core。 @@ -201,3 +314,4 @@ entries: | UI 安全 | App UI 诱导授权或越权访问。 | Host 控制容器 + runtime permission bridge 双拦截。 | | Migration 破坏数据 | App 升级损坏用户数据。 | migration plan、dry-run、backup、保留数据策略。 | | Cloud 变 Runtime | 破坏 Lime 本地运行定位。 | server-assisted 必须显式声明并受 policy 控制。 | +| Agent 能力退化为 API | App 绕过 Claw / Aster / Evidence 主链。 | `lime.agent` / `lime.workflow` 必须进入 AgentRuntime Surface,模型 token 仅作 executor / fallback。 | diff --git a/docs/roadmap/agentapp/content-factory-app.md b/docs/roadmap/agentapp/content-factory-app.md index 9dcd960fe..82e7adae5 100644 --- a/docs/roadmap/agentapp/content-factory-app.md +++ b/docs/roadmap/agentapp/content-factory-app.md @@ -1,17 +1,19 @@ -# APP 内容工厂 PRD +# 内容工厂 PRD -更新时间:2026-05-15 +更新时间:2026-05-16 ## 一句话目标 -基于 Lime Agent App 平台开发一个独立垂直产品:`APP 内容工厂`。它不是“内容专家聊天框”,而是有自己 UI、storage、worker、workflow、Knowledge 绑定、Artifact 和数据复盘闭环的完整业务应用,安装到 Lime Desktop 后本地运行。 +基于 Lime Agent App 平台开发一个独立垂直产品:`内容工厂`。它不是“内容专家聊天框”,而是有自己 UI、storage、worker、workflow、Knowledge 绑定、Artifact 和数据复盘闭环的完整业务应用,安装到 Lime Desktop 后本地运行。 + +验收心智:运营用户应在内容工厂内完成资料整理、场景规划、批量生产、交付和复盘;Lime Agent 在背后作为 `lime.agent` / `lime.workflow` 能力执行任务。用户不应为了关键步骤跳回 Lime 通用 Chat,也不允许内容工厂自建一套模型、凭证、权限、证据和工具底座来绕过 Lime。 ## 关键校准 这份需求不能用 `APP.md + prompt` 解决。功能背后必须有完整实现: ```text -APP 内容工厂 +内容工厂 ├── UI:项目首页 / 三层知识库 / 内容场景规划 / 内容工厂 / 策略报告 / 数据复盘 ├── Storage:项目、知识条目、内容场景、内容资产、复盘数据、洞察 ├── Worker:文件解析、知识结构化、批量生成、去 AI 味评分、PPT / 报告生成 @@ -23,6 +25,12 @@ APP 内容工厂 `APP.md` 只声明这些能力和入口;真实功能在 runtime package 中实现,并通过 `@lime/app-sdk` 调用 Lime 能力。 +内容工厂内的 Expert 只是协作者入口:它可以读取当前项目、资料版本、内容批次和复盘指标,也可以触发 App workflow;但它不能成为“用户把结果从聊天框复制回内容表”的旁路。 + +后端事实源:内容工厂的 AI 任务必须通过 `docs/roadmap/agentruntime/app-surface-runtime.md` 定义的 Agent App Runtime Surface 进入 AgentRuntime 主链。`LIME_GATEWAY_*` / 模型 API 只能作为低阶 executor 或降级能力,不能替代 Lime Agent 的工具、知识、权限、证据和 artifact 治理。 + +当前实现进展:Lime Host 的 `AgentAppRuntimePage` 已把 iframe Host Bridge 里的 `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse` 接入 `agent_app_runtime_*` facade,并由后端复用 `AgentRuntimeThreadReadModel` 投影 `taskStatus / taskEvents`。这意味着内容工厂页面内发起“生成文案 / 场景规划”等 Agent task 时,第一跳已经进入完整 Lime AgentRuntime 主链;当 runtime 追问上下文、等待确认或要求工具授权时,App 也能在页面内回传响应,而不是跳回通用 Chat。当前仍未完成实时 stream、artifact-created 专用投影、storage / artifact / evidence 的后端持久写回与跨刷新 task/session 恢复。 + ## 背景 来自行业内容团队的真实需求:团队希望把个人 IP、项目事实、内容方法论、素材资产和运营复盘沉淀成可复用系统,而不是每次在 Claude、豆包、Gemini、图片工具、PPT 工具之间复制粘贴。 @@ -63,7 +71,7 @@ APP 内容工厂 ### 主导航 ```text -APP 内容工厂 +内容工厂 ├── 项目首页 ├── 知识库 │ ├── IP 知识库 @@ -130,7 +138,7 @@ name: content-factory-app version: 0.3.0 status: draft appType: domain-app -description: APP 内容工厂,用于知识库构建、内容场景规划、批量内容生产和数据复盘。 +description: 内容工厂,用于知识库构建、内容场景规划、批量内容生产和数据复盘。 runtimeTargets: - local requires: @@ -258,6 +266,95 @@ flowchart TD | 外部调研、飞书同步 | `lime.tools`、`lime.secrets` | | 权限、成本、风险 | `lime.policy` | +## App 内 Agent 任务闭环 + +内容工厂的 Agent 能力必须作为业务任务嵌入 App,而不是跳转到通用 Chat: + +| 业务任务 | App 输入 | Lime Agent / Capability | App 内写回 | +|---|---|---|---| +| 资料整理 | 用户授权文件、资料类型、项目目标。 | `lime.files` / `lime.tools` 解析,`lime.agent.startTask` 结构化抽取,`lime.evidence` 记录来源。 | `knowledge_items` 草稿、资料健康度、人工确认状态。 | +| 场景规划 | 已确认知识库、目标平台、人群和数量。 | `lime.knowledge.search` 检索,`lime.agent.startTask` 生成结构化场景。 | `content_scenarios` 表、场景 Artifact、引用证据。 | +| 批量文案 / 脚本 | 场景、平台规则、数量、风格约束。 | `lime.agent.startTask` + writer skills,Eval 检查事实支撑和去 AI 味。 | `content_assets` / `script_batches`、A/B/C 分级、质量记录。 | +| 策略交付 | 内容批次、项目事实、竞品资料。 | `lime.agent.startTask` 生成报告结构,`lime.artifacts` 持久化交付物。 | 策略报告、PPT 大纲、交付状态。 | +| 数据复盘 | CSV / Excel 指标、历史内容资产。 | `lime.tools` 归一化,`lime.agent.startTask` 归因分析。 | `review_reports`、下轮动作、选题权重更新。 | + +每个任务都必须在内容工厂页面内展示进度、引用、工具调用、失败原因、重试和人工确认;最终结果以结构化对象写回 App storage,再生成 Artifact / Evidence。 + +当前 App Host 第一刀已具备:Host Bridge 的 `lime.agent` 调用会经 `AgentRuntimeCapabilityHost` 转入 `agent_app_runtime_start_task`,`streamTask / getTask` 先消费 `agent_app_runtime_get_task` 的 snapshot events,`cancelTask` 委托 `agent_app_runtime_cancel_task`,`retryTask` 复用同一 runtime session 重新提交 App task,`submitHostResponse` 委托 `agent_app_runtime_submit_host_response`。前端本地 adapter 继续只作为 storage / artifact / evidence / knowledge 的样板实现,不能再被用来证明内容工厂已经具备完整 Agent 能力。 + +### 写文案页面 Runtime 主链 + +“本轮任务”面板里的“生成文案和配套素材”和“只重写文案”是 App-scoped Agent task,不是普通表单 API: + +```text +写文案表单状态 + -> lime.agent.startTask / lime.workflow.start + -> Agent App Runtime Surface + -> AgentRuntime session / turn / task + -> Claw capability / Skill / Tool + -> AgentAppTaskStreamEvent + -> App 内 review + -> storage / artifact / evidence write-back +``` + +最小 task 输入: + +| 字段 | 来源 | +|---|---| +| `taskKind` | `content_factory.copy.generate` 或 `content_factory.copy.rewrite` | +| `task` | “今天要完成什么”输入框 | +| `category` | 品类输入框 | +| `platform` | 平台选择 | +| `audience` | 目标人群 | +| `coreWords` | 核心词 | +| `materialVersion` | 生产前检查中的资料版本 | +| `selectedScenarios` | 优先场景或自动补齐场景 | +| `scriptRefs` | 现有脚本 / 视频口播引用 | +| `expectedOutput` | 文案、素材 brief、脚本片段、交付包 | +| `humanReview` | 默认 `true` | + +最小事件投影: + +| 事件 | 页面反馈 | +|---|---| +| `task:started` | 本轮任务开始,锁定输入快照。 | +| `task:contextChecked` | 更新“生产前检查”卡片,不再只显示静态数字。 | +| `task:missingContextRequested` | 在 App 内补资料、补场景或追问用户。 | +| `task:toolCall` | 显示知识检索、搜索、图片、报告、PDF / 总结等工具进度。 | +| `task:citation` | 显示资料版本、场景、网页或文件引用。 | +| `task:partialArtifact` | 展示草稿文案、素材 brief、脚本片段。 | +| `task:reviewRequested` | 用户在当前页面确认、编辑、拒绝或重试。 | +| `task:completed` | 写回内容资产并生成交付物。 | +| `artifact:created` / `evidence:recorded` | 交付物和证据已落库。 | + +“只重写文案”必须限制 artifact policy:只允许改写当前文案资产,不允许重写资料、场景或脚本来源。“生成文案和配套素材”可以触发资料检查、场景补齐、素材 brief 和配图能力,但所有副作用都必须带 App provenance。 + +### 复用 Claw 能力 + +内容工厂不得复制 Claw 已有能力,首批复用关系见 `docs/roadmap/agentruntime/claw-capability-sharing.md`: + +| 内容工厂需求 | 复用 capability | 后端主链 | +|---|---|---| +| 生产前资料补齐 | `lime.capability.research.search` / `lime.capability.summary.generate` | `research_skill_launch` / `summary_skill_launch` | +| 竞品和策略分析 | `lime.capability.report.generate` | `report_skill_launch` | +| PDF / 资料读取 | `lime.capability.pdf.read` | `pdf_read_skill_launch` | +| 配套素材 / 海报 brief | `lime.capability.image.generate` / `lime.capability.cover.generate` | `image_skill_launch` / `cover_skill_launch` | +| 交付 PPT / 报告 | `lime.capability.presentation.generate` / `lime.capability.webpage.generate` | `presentation_skill_launch` / `webpage_skill_launch` | + +## Host Bridge 要求 + +内容工厂是第一个正式样板 App,不能使用私有 iframe 协议。它的 UI runtime 必须接入 Agent App 标准 Host Bridge: + +| 场景 | Bridge 事件 | 验收 | +|---|---|---| +| 跟随 Lime 主题 / 配色 | `host:snapshot`、`theme:update` | 主 App 切换主题后,内容工厂 iframe 内字体、颜色、背景和选中态同步变化。 | +| 非技术提示 | `host:toast` | App 内操作结果用 Lime Host 提示,不暴露 Gateway、Artifact、RAG 等技术词。 | +| 页面跳转 | `host:navigate` | 从项目中心进入资料、写文案、做脚本等入口时仍由 Host 校验 entry / route。 | +| 下载交付物 | `host:download` | 只允许下载同源 runtime 产物或 Host 已授权 Artifact。 | +| 后续能力调用 | `capability:invoke` | 资料解析、模型生成、Artifact 写入继续经过 readiness / permission / policy,不返回 mock 成功。 | + +内容工厂 App 内部只保留主题 fallback,方便脱离 Lime Host 做本地预览;真实嵌入 Lime 时,主题、语言、入口上下文和 Host actions 都以 `lime.agentApp.bridge` 快照为准。 + ## MVP 范围 P0 只做最小闭环: @@ -395,10 +492,29 @@ P0 不做: ## 下一刀 -当前客户端已完成 App Host、SDK、UI Host、内容工厂最小闭环和受控 workflow runtime 的实验岛切片。下一刀不扩成完整内容系统,先补真实 worker sandbox 前置边界、package hash 校验与 Cloud bootstrap: +当前客户端已完成 App Host、SDK、UI Host、内容工厂最小闭环、受控 workflow runtime、Cloud bootstrap、schema coverage、setup resolver、setup state store、installed state snapshot、local persistence adapter、package cache / verify / rollback、runtime package loader、P14 Entry Runtime Guard、P15 Lab Install / Launch Flow、P15-H Agent App Lab 专用 GUI smoke、P16 最小 Agent App Manager、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 lifecycle / cleanup contract,以及 P17.4.1-P17.4.5 Host Bridge / App 内 task / structured write-back / bootstrap 样板 / GUI smoke。下一刀不扩成完整内容系统,进入 P17.5 formal entry GUI smoke: ```text -真实 worker sandbox policy → package hash 校验 → Cloud bootstrap payload 适配 → 断网可用性验证 +repository-backed multi-app list(已完成) +→ selected app launcher(已完成) +→ persisted enable / disable(已完成) +→ cleanup evidence export(已完成) +→ residual audit(已完成) +→ Agent App Lab GUI smoke + flag-off regression(已完成) +→ P17 Gate 审计(已完成) +→ P17.0 Formal Entry Contract(已完成计划收口) +→ P17.1 Formal route / nav / copy hardening(已完成最小实现) +→ P17.2.1 Source state model(已完成) +→ P17.2.2 Install review descriptor(已完成) +→ P17.2.3 Registration hardening(已完成) +→ P17.2.4a Cloud release descriptor / verification gate(已完成) +→ P17.2.4b-1 acquisition seam / verified cache source(已完成) +→ P17.2.4b-2 packageUrl fetch / staging(已完成) +→ P17.2.5 Schema / reference CLI cross-check(已完成) +→ P17.3 Lifecycle / cleanup contract hardening(已完成) +→ P17.4 Host Bridge / App 内 task / structured write-back / bootstrap 样板(P17.4.1-P17.4.4 已完成) +→ P17.4.5 Security / smoke(已完成,完整 GUI smoke 通过) +→ P17.5 Formal entry GUI smoke(下一刀) ``` -只有 runtime policy、cleanup 和 Cloud 控制面边界稳定后,再进入真实文件解析、批量生成、质量评分和外部平台数据复盘。 +只有多 App 管理面、entry runtime guard、permission prompt、cleanup evidence、policy gate、Host Bridge 和正式 runtime smoke 稳定后,再进入真实文件解析、批量生成、质量评分和外部平台数据复盘。 diff --git a/docs/roadmap/agentapp/implementation-plan.md b/docs/roadmap/agentapp/implementation-plan.md index 74a74b0f1..b0293af87 100644 --- a/docs/roadmap/agentapp/implementation-plan.md +++ b/docs/roadmap/agentapp/implementation-plan.md @@ -6,7 +6,7 @@ Agent App 在 Lime Desktop 里必须先做成一个可关闭、可卸载、可删除数据、可移除代码的“实验岛”,而不是一开始改造 Chat、Skill、Workspace、Artifact 主路径。 -第一阶段目标不是做一个完整生态,也不是复刻行业内容系统,而是证明:一个真实 App package 可以在本地被安装、解析、投影、检查 readiness、调用 Capability SDK、生成可追溯产物,并且在方向失败时能干净清理。 +第一阶段目标不是做一个完整生态,也不是复刻行业内容系统,而是证明:一个真实 App package 可以在本地被安装、解析、投影、检查 readiness、调用 Capability SDK、生成可追溯产物,并且在方向失败时能干净清理。进入正式入口后,目标进一步收敛为:业务用户留在 App 自己的业务 UI 内完成流程,Lime Agent 作为受治理的 task runtime 被 App 调用,而不是把用户回跳到通用 Chat。 ```text Agent App Standard v0.3 @@ -26,6 +26,8 @@ Capability SDK Facade + Mock Host Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也不是把某个垂直业务写进 Lime Core。它是安装在 Lime Desktop 中运行的完整应用包:有自己的 UI、业务流程、数据模型、worker、workflow、artifact、权限和升级策略。 +新的产品判断是:Agent App 不是独立 Web / SaaS 的包装,也不是 Chat UI 的装饰壳。App 负责业务形态、业务状态和结构化写回;Lime 负责 Agent task、模型 / 工具 / 知识 / 文件 / secret / Artifact / Evidence / policy 等能力。业务不出 App 上下文,Agent 不出 Lime 能力治理边界。 + 这件事最大的风险不在 Cloud,而在客户端运行时: 1. App 需要调用 Lime 底层能力,天然和 Agent Runtime、Knowledge、Tool、Artifact、Evidence、Files、Policy 绑定。 @@ -41,7 +43,8 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 |---|---| | 验证 App package | 本地读取 Agent App v0.3 package / fixture,生成 manifest、projection、readiness。 | | 验证 SDK 边界 | App 只能通过 `@lime/app-sdk` capability facade 调 Lime 能力,不能 import Lime internal modules。 | -| 验证真实业务切片 | 用 APP 内容工厂跑通一个最小业务闭环,而不是停留在专家聊天。 | +| 验证真实业务切片 | 用 内容工厂跑通一个最小业务闭环,而不是停留在专家聊天。 | +| 验证 App 内 Agent 任务 | 在 App 页面 / workflow 内启动、观察、取消、重试、确认 Agent task,并把结构化结果写回业务对象。 | | 验证本地数据隔离 | App storage、artifact、evidence、log、package cache 独立命名空间,可按 app 清理。 | | 验证失败退出 | P0 就实现 cleanup plan / uninstall plan,保证失败后能删干净。 | | 验证未来 Cloud 对接 | 客户端先用 local JSON / fixture,Cloud 只在 P5 作为 catalog / release / tenant enablement 输入。 | @@ -50,11 +53,13 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 1. P0 不做公开市场、审核流、支付分账、企业分发控制台。 2. P0 不执行任意 App 代码,不支持任意 npm install、native binary、任意文件系统访问。 -3. P0 不把 APP 内容工厂写进 Lime Core。 +3. P0 不把 内容工厂写进 Lime Core。 4. P0 不改造 AgentChat、Skill Catalog、Artifact 主 schema。 5. P0 不要求 Cloud 先完成;Cloud 不运行默认 Agent Runtime。 6. P0 不承诺兼容历史实验数据;实验数据必须可删除。 7. P0 不把 App projection 写入全局 registry。 +8. P17+ 不把通用 Chat 当作 Agent App 业务流程的强制容器;Expert Chat 只能作为 entry / 嵌入式协作者。 +9. P17+ 不允许 App 为了“留在 App 内”而自建模型网关、凭证系统、权限系统、证据系统或工具调度器。 ## 总体原则 @@ -67,6 +72,8 @@ Agent App 不是专家卡片、不是 Prompt 包、不是 Markdown 目录,也 | 先可删除后扩展 | 安装、运行、产物、日志都必须有 cleanup plan。 | | SDK 暴露能力 | App 只调用 capability,不知道 Lime 内部 store、command、runtime 路径。 | | 产物必须可追溯 | Artifact / Evidence / Task / Log 都带 `sourceKind: agent_app` provenance。 | +| 业务不出 App | App 页面内承载任务进度、引用、工具调用、失败、人工确认和结构化写回。 | +| Agent 不出治理 | `lime.agent`、`lime.workflow`、`lime.tools`、`lime.knowledge` 等能力统一走 Host Bridge / Capability SDK / policy。 | ## 仓库边界 @@ -110,9 +117,10 @@ flowchart TD Host --> Manifest[Manifest Parser] Host --> Projection[Projection + Readiness] Host --> Store[App Package / Storage Namespace] - Host --> SDK[Capability SDK Facade] - Host --> UIHost[UI Extension Host] - Host --> Worker[Worker / Workflow Runtime] + Host --> Guard[Entry Runtime Guard] + Guard --> SDK[Capability SDK Facade] + Guard --> UIHost[UI Extension Host] + Guard --> Worker[Worker / Workflow Runtime] SDK --> Mock[Mock Capability Host] SDK --> Adapter[Thin Lime Adapters] @@ -127,15 +135,226 @@ flowchart TD UIHost --> AppUI[App UI Bundle] Worker --> AppWorker[App Worker] + Agent --> Task[App-scoped Agent Task] + Task --> Result[Structured Result Write-back] + Result --> AppData Store --> AppData[App Namespace Data] ``` 关键点: - `Host` 是实验岛,不是主路径。 +- `Guard` 是所有 entry 运行前的授权 gate,不是 UI 提示替代品。 - `SDK` 是稳定 facade,不是内部 API 透传。 - `Adapter` 必须薄,且可以按目录删除。 - `Cloud` 只提供元数据,不成为默认 runtime。 +- `Agent` 是 App 作用域任务运行时,不是通用 Chat 回跳点;结果必须回到 App storage / Artifact / Evidence。 + + +## v0.3 升级后的计划调整 + +路线图已从早期 `AI 内容工程化` 样板升级为 Agent App v0.3 / `内容工厂`。因此当前计划新增一个 P4-R rebaseline 阶段,作为 P5 Cloud Bootstrap 的前置 gate。 + +P4-R 的目标不是新增业务范围,而是让代码事实源与升级后的文档事实源重新一致: + +| 调整项 | 旧实现 | v0.3 current | +|---|---|---| +| 标准版本 | `manifestVersion: 0.2.x` | `manifestVersion: 0.3.0` | +| Fixture | `content-engineering-app.json` | `content-factory-app.json` | +| App ID | `shenlan-content-engineering` | `content-factory-app` | +| Workflow entry | `scene_exhaustion` | `content_scenario_planning` | +| Demo API | `runContentEngineeringDemo()` | `runContentFactoryDemo()` | +| Storage | `scenes/*` | `content_scenarios/*` | +| Artifact kind | `content_engineering_table` | `content_table` | +| Evidence kind | `content_engineering_demo` | `content_factory_demo` | + +新的执行顺序: + +```text +P4-R0 计划与事实源收口 +→ P4-R1 v0.3 manifest / fixture / readiness rebaseline +→ P4-R2 内容工厂 demo rename + 数据对象 rebaseline +→ P4-R3 WorkflowRuntimeHost rebaseline 到 content_factory_demo +→ P4-R4 Lab UI / i18n / tests rebaseline +→ P4-R5 verify:local 恢复全绿并确认不复活旧 SceneApp +→ P5 Cloud Bootstrap payload 本地适配器 +``` + +截至 2026-05-16,P4-R、P5、P6、P7、P8、P9、P10、P11、P12、P13 均已通过对应定向验证;P14 Entry Runtime Guard / Permission Prompt、P15 Lab Install / Launch Flow、P15-H GUI smoke / cleanup rehearsal、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5 与 P17.3 lifecycle / cleanup contract 已完成当前实现 / 专用验证 / 计划收口。P17.4.1-P17.4.5 已把正式 runtime surface 推进到 guard-before-start、App 内 Agent task、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke 证据;当前下一刀进入 P17.5 formal entry GUI smoke。阶段记录详见 [v0.3-rebaseline-plan.md](./v0.3-rebaseline-plan.md)、[p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md)、[p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md)、[p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)、[p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)、[p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md)、[p17-formal-entry-contract.md](./p17-formal-entry-contract.md)、[p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)、[p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) 与 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 + +上游 `agentapp` 标准随后补齐了宿主实现视角的 v0.3 细则:manifest / projection / readiness JSON schema、release metadata、overlay resolver、lifecycle、typed SDK expectations、reference CLI cross-check。P5 因此不能只做一个 Cloud payload DTO,而要先把这些标准差距变成客户端可执行计划;详见 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md)。 + +### 计划更新规则 + +1. `/Users/coso/Documents/dev/ai/limecloud/agentapp` 是 Agent App v0.3 标准事实源,用来约束 manifest schema、entry kind、capability 版本、runtime package、overlay、readiness 和 provenance。 +2. 本目录是 Lime Desktop 客户端实施事实源,用来决定客户端当前要落哪一刀、哪些文件要改、哪些测试要过。 +3. P4 demo 的 current 对象以 `content-factory-app / content_scenario_planning / content_factory_demo / content_table / content_scenarios` 为准;如果标准示例包出现不同业务 key,先记录差异,不在客户端实现里混用两套命名。 +4. P5 只消费 Cloud bootstrap payload;Cloud / LimeCore 不运行默认 Agent Runtime,也不渲染 App UI。 +5. 所有 App 能力调用必须穿过 Capability SDK,不能为了跑通内容工厂而 import Lime internal store 或恢复旧 SceneApp 主路径。 + +### 2026-05-15 标准升级二次校准 + +上游 `/Users/coso/Documents/dev/ai/limecloud/agentapp` 已从“概念标准 + 示例包”升级为带宿主实现指南的 v0.3 标准:新增 discovery / installation、release distribution、runtime model、security model、overlay resolver、readiness runner、typed Capability SDK、public JSON Schema 和 reference CLI。LimeCore 服务端路线图也已明确 control-plane-only 边界:服务端只保存 catalog、release、tenant enablement、policy、ToolHub / Gateway metadata;未激活注册码时不下发 `packageUrl / packageHash / manifestHash`,真实 release 必须是 `https` package URL 和完整 `sha256:<64 hex>` hash。客户端计划不需要推翻 P0-P17 主线,但必须把 P17.2 拆得更细,避免把 Cloud metadata、seeded catalog 或开发态源码目录误当成正式安装。 + +| 上游新增约束 | 客户端计划调整 | 阶段归属 | +|---|---|---| +| Release 必须 pin 到具体 `packageUrl / packageHash / manifestHash / compatibility / signatureRef?`。 | Cloud release 先归一化为 `AgentAppReleaseDescriptor`,再进入 P12 cache / verify;缺 hash、hash mismatch、manifest mismatch 均阻断。 | P17.2.4 | +| 安装审查基于 projection,不执行 App code。 | `installReview` 只能消费 verified package projection、readiness summary、permission / storage / cleanup 摘要;Cloud card 和 Lab fixture 不能直接生成 production review。 | P17.2.4 | +| Cached fallback 只保证已安装版本可用。 | offline / cached 状态只能启动已安装同 hash package;不能在离线时把 Cloud metadata 伪装成新安装。 | P17.2.4 | +| LimeCore 未激活注册码时不下发 package metadata。 | `registration-required / expired / revoked` 必须停在 source state;客户端不能用 seeded fixture、本地开发目录或历史 cache 创建新安装 review。 | P17.2.4b | +| `content-factory-app` seeded catalog 不能带假 release。 | seeded 只允许演示 registration-required;正式 review 必须来自已验证 package source。 | P17.2.4b | +| Public schema 与 reference CLI 成为机械契约。 | 用上游 `docs/public/schemas/*`、`agentapp-ref` 和 `docs/examples/content-factory-app` 做 projection / readiness cross-check,防止客户端字段漂移。 | P17.2.5 | +| Overlay 是 package hash 外的配置。 | P17.3 后 lifecycle / cleanup 必须把 overlay、secret binding、setup state 与 package code 分离;upgrade 不覆盖 overlay。 | P17.3 | +| Runtime surface 必须运行 App 自己的 UI / workflow,并通过 injected SDK。 | 已完成的正式 runtime surface 补丁只能算 P17.4 预铺;P17.4 仍要确认生产路径只加载 verified runtime package,不依赖 dev resolver,并把 SDK bridge / sandbox / provenance 做成可测 gate。 | P17.4 | +| Typed SDK 要有类型、schema、mock 和 contract tests。 | P18 前不得扩 raw worker;先把 `lime.ui / storage / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence` 的 typed host bridge 形成 SDK gate。 | P18 | + +P17.2.5 不是重复 P5.5:P5.5 只在 Cloud bootstrap 和早期 projection 层记录标准差异;P17.2.5 要把同一套 schema / CLI 对照接到正式 `agent-apps` source / install / review / readiness 主链上。 + +### P14 / P15 / P16 计划更新 + +上游 Agent App 标准进一步把宿主最低职责收敛成 `Discover → Validate → Project → Check readiness → Authorize → Inject capabilities → Isolate data → Clean up`。P0-P13 已覆盖到 `Check readiness` 与 runtime package loader,缺口集中在 `Authorize` 和 Lab 端到端编排: + +| 阶段 | 新定位 | 计划影响 | +|---|---|---| +| P14 | Entry Runtime Guard / Permission Prompt。 | 先把 entry 运行前授权做成硬 gate,合并 readiness、setup、package verification、runtime policy 和用户授权。 | +| P15 | Lab Install / Launch Flow。 | 已串联 install review、verify/cache、installed state、permission prompt、runtime launch 和 cleanup preview。 | +| P15-H | GUI Smoke / Cleanup Rehearsal。 | 已补 Agent App Lab 专用 GUI smoke 与证据输出;全局 GUI smoke 的外部 provider/model 缺口不伪装为 Agent App 失败。 | +| P16 | Agent App Manager / Product Entry Gate。 | 已在实验岛内完成最小 installed app 状态、entry launcher、enable / disable / uninstall preview 和 cleanup evidence;详见 [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)。 | +| P16-H | Multi-app repository / lifecycle hardening。 | P16-H.5 已完成最小实现;P17 Gate 已完成审计。 | + +这次调整的关键是:P14 是 P15 的前置 gate;P15 / P15-H 已复用 guard,不能为了 GUI smoke、后续演示或正式入口直接调用 loader 或 CapabilityHost。P16 也必须先留在实验岛内验证多 App 生命周期,不能跳到 marketplace 或主导航。 + +### P16-H 执行计划 + +P16-H 不是“把 Manager 做大”,而是把 P16 已证明的单 App 管理闭环压到平台级最小可靠性:多 App 列表、生命周期持久化、清理演练证据和残留检查都必须复用同一套 current 事实源。 + +| 顺序 | 交付 | 事实源 / 约束 | 验收 | +|---|---|---|---| +| P16-H.0 | 已完成:计划收口。 | `p16-h-multi-app-repository-lifecycle-hardening.md` 成为 P16-H 详细计划。 | README、implementation plan、P16 文档都指向同一下一刀。 | +| P16-H.1 | 已完成最小实现:Repository-backed multi-app list。 | 只读 P11 `LocalInstalledAgentAppStateRepository`;React state 只做展示缓存。 | Manager 能展示多个 installed app,并可选择当前 App。 | +| P16-H.2 | 已完成最小实现:Selected app launcher + persisted lifecycle。 | launch 继续走 P14 guard;disable / enable 继续走 repository lifecycle API。 | disabled App 不能旁路启动,刷新后状态不丢。 | +| P16-H.3 | 已完成最小实现:Cleanup rehearsal evidence export。 | 复用 P15 uninstall / cleanup preview,不执行真实 delete-data。 | 导出 summary 包含 appId、version、hash、strategy、targets、blockedTargets、timestamp。 | +| P16-H.4 | 已完成最小实现:Residual audit。 | 只检查 Agent App namespace,不扫描 Lime 主业务数据。 | 区分 retained、pending deletion、blocked out-of-scope、repository issue。 | +| P16-H.5 | 已完成最小实现:GUI smoke + flag-off regression。 | 只扩展 Agent App Lab 专用 smoke,不接主导航。 | summary 覆盖 multi-app list、selected app、disable blocker、cleanup evidence、residual audit 与 flag-off。 | +| P17 Gate | 已完成:正式入口前完成度审计。 | 只做审计与缺口登记,不直接发布 marketplace 或主导航。 | Gate checklist 已逐项映射 P16-H 证据与剩余缺口。 | +| P17.0 | 已完成计划收口:Formal Entry Contract。 | 只定义正式 Agent Apps 受控入口契约,不扩 marketplace / Cloud 管理台 / 真实 delete-data。 | 已写清 `agent-apps`、`agent-app-lab` 与 runtime surface 的责任边界、禁区、架构和后续 gates。 | +| P17.1 | 已完成最小实现:Formal route / nav / copy hardening。 | 只硬化正式入口路由、导航、状态文案和测试契约。 | `agent-apps` 不依赖 Lab flag;UI entry 从正式入口进入独立 runtime surface;正式入口文案五语言覆盖。 | +| P17.2 | 已完成最小实现:Source / install contract hardening。 | P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 与 registration hardening;P17.2.4a 已完成 Cloud release descriptor / verification gate;P17.2.4b-1 已完成 acquisition seam / verified cache source;P17.2.4b-2 已完成 packageUrl fetch / staging / manifest extraction;P17.2.5 已完成 schema / reference CLI / example package cross-check。详见 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)。 | local / cloud 安装已先 review 后 save,registration-active refresh 已有 UI 断言;packageUrl fetch 可生成 verified cache 后再进入 Cloud review;reference CLI cross-check 已覆盖 projection / readiness / review descriptor。 | +| P17.3 | 已完成最小实现:Lifecycle / cleanup contract hardening。 | P17.3.1-P17.3.6 已完成 lifecycle descriptor、formal page lifecycle UI、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression。详见 [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md)。 | disabled / cleanup-blocked 不能启动;uninstall 保持 rehearsal-only;evidence 不含 secret value;residual audit 只查 Agent App namespace。 | +| P17.4 | 已完成:Runtime surface production hardening。 | P17.4.1-P17.4.5 已完成 guard-before-start、Host Bridge task contract、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。详见 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md)。 | code-level tests、typecheck、contracts、feature island boundary 与 `verify:gui-smoke` 均有证据;runtime surface 可进入 P17.5 formal smoke。 | + +P17.5 完成前,仍禁止把正式入口标记为可交付;marketplace、Workspace pin、命令面板入口、Chat expert entry、真实 delete-data 和 Cloud 管理台仍不进入当前主线。 + +### 已完成计划:P17.0 Formal Entry Contract + +P17.0 的最小可交付不是“做市场”,而是把 P17 Gate 审计后的正式入口契约写清楚。本阶段已沉淀为 [p17-formal-entry-contract.md](./p17-formal-entry-contract.md): + +1. 定义 `agent-apps` current 入口允许的用户能力:install、launch、disable、registration、uninstall rehearsal、runtime surface。 +2. 定义 `agent-app-lab` 继续保留的研发验证能力:fixture、smoke、cleanup rehearsal、flag-off、实验 runtime 断言。 +3. 固定禁区:marketplace、Cloud 管理台、真实 delete-data、raw worker、绕过 SDK。 +4. 明确后续 gates:真实删除、public catalog、企业控制台、完整内容工厂业务系统都必须另立计划。 + +### 已完成计划:P17.1 Formal route / nav / copy hardening + +P17.1 的最小可交付不是重写 Agent Apps 页面,而是把正式入口已经存在的 route / nav / copy / state contract 做硬化。本阶段已完成最小实现: + +1. 先固定入口语义:`agent-apps` 是用户入口,`agent-app-lab` 是研发验证入口,Lab flag-off 不影响 `agent-apps`。 +2. 再固定路由参数:正式入口只接受 appId、entryKey、launchRequestKey 等 App 运行参数,不复用 Lab fixture 参数。 +3. 再固定状态文案:loading、empty、needs-setup、blocked、disabled、uninstall rehearsal、registration、runtime empty 必须五语言覆盖。 +4. 再固定测试证据:`AgentAppsPage`、`AgentAppRuntimePage`、`sidebarNav` 覆盖 formal entry,Lab smoke 只作为辅助回归。 +5. 最后做边界审计:不新增 Tauri command,不让 `src/features/agent-app` 直接 `safeInvoke` / `invoke`,不新增第二套 store / installer。 + +P17.1 已落地的具体证据: + +1. `AgentAppsPage` 新增 `onNavigate`,UI entry 经 P14 guard 允许后进入独立 `agent-app` runtime surface,而不是在正式入口内继续伪装 Lab mount。 +2. `AppPageContent` 将主导航 `onNavigate` 传给 `AgentAppsPage`,保证正式入口可以打开 App runtime。 +3. 本地安装入口改为选择包含 `APP.md` 的目录,不展示本机硬编码路径。 +4. 新增正式受控入口 badge 与 boundary note,并覆盖五语言。 +5. 定向测试覆盖 formal entry route、copy、select-local、AppPageContent props、sidebar footer / Lab flag 边界。 + +P17.1 不做 public catalog、真实 delete-data、Cloud 管理台、Workspace pin、命令面板入口或完整内容工厂业务系统。 + +### 已完成计划:P17.2 Source / install contract hardening + +P17.2 的最小可交付不是做 marketplace,而是把正式入口 source / install / registration 的状态契约收口。本阶段已完成 P17.2.1 / P17.2.2 / P17.2.3、P17.2.4a、P17.2.4b-1、P17.2.4b-2 与 P17.2.5;后续 P17.3 与 P17.4 已完成,当前进入 P17.5 formal entry GUI smoke: + +1. 已完成 source state:local folder、cloud release metadata、registration-required、registration-active、disabled、hash-missing、installed、offline fallback 已有清晰 UI 状态。 +2. 已完成 install review gate:安装前展示 appId、version、source、hash、manifest、capability summary,并把 confirm 前不写 repository 的 UI / API 边界拆出来;Cloud production review 的真实 package 来源仍由 P17.2.4 收口。 +3. 已完成 registration:注册码只改变 Cloud enablement,不写入 App package,不绕过 package verify,并已补强提交后 source state refresh 的 UI 断言。 +4. 已完成 P17.2.4a:把 Cloud release metadata 归一为 release descriptor,强制 `packageUrl / packageHash / manifestHash / compatibility`,Cloud review 缺 package manifest 或 hash mismatch 时阻断。 +5. 已完成 P17.2.4a:明确 cached fallback 必须 appId + version + packageHash + manifestHash 全匹配,不能把 Cloud card、tenant enablement 或开发态 resolver 当成新安装。 +6. 已完成 P17.2.4b-1:补可注入 package acquisition seam、verified cache 命中和 missing source blocker;UI 不再只能依赖调用方显式传入 `packageManifest`。 +7. 已完成 P17.2.4b-2:把 `packageUrl` 下载 / staging / manifest extraction 接入 P12 cache;唯一 Tauri fetch command 已同步前端 gateway、Rust 注册、治理目录册、DevBridge mock priority 与 tauri mock。 +8. P17.2.5:用上游 public schema、reference CLI 与 `content-factory-app` 示例包做 projection / readiness cross-check,确保客户端字段和标准文档不漂移。 +9. 最后做边界审计:仍复用 `src/lib/api/agentApps.ts`、P12 cache、P11 repository,不新增平行 installer;如必须改 Tauri bridge,按命令契约四侧同步。 + +### P17.2.4b 最小实施顺序 + +1. **Source acquisition seam**:在 `src/lib/api/agentApps.ts` 内集中新增 Cloud release package acquisition 边界,输入只接受 `CloudBootstrapApp` / release descriptor,输出必须是 verified package manifest + verification result;`src/features/agent-app` 仍不得直接 `safeInvoke`。 +2. **Cache-first**:优先复用 P12 package cache / package identity;已验证同 appId + version + packageHash + manifestHash 的 cache 可以生成 review,hash 不一致必须 blocker。 +3. **Fetch boundary**:没有 verified cache 时只通过唯一 Tauri bridge `agent_app_fetch_cloud_package` 下载 / staging / manifest extraction;不允许在 UI、feature island 或 Cloud API 客户端里各写一套 downloader。 +4. **UI blocker**:`AgentAppsPage` 需要把“缺 verified package source / registration inactive / package hash mismatch / manifest hash mismatch”展示为 source state 或 review blocker,而不是泛化 toast。 +5. **Repository rule**:P17.2.4b 只在用户确认 review 后写 P11 repository;下载失败、验证失败、用户取消都不能留下半安装态。 +6. **Validation**:定向测试必须覆盖 cache hit、fetch package、cache miss blocker、hash mismatch blocker、registration no-package metadata、review no-save 和 install confirm-save;新增 command 必须继续纳入 `test:contracts`。 + +### 2026-05-15 Formal Runtime Surface 补丁后的计划重排 + +正式入口已经补上 `agent_app_start_ui_runtime` 到 `AgentAppRuntimePage` iframe 的最小闭环:Host 不再渲染自己的假业务页面,而是启动 App 包自己的 UI 并打开 entry route。这个改动解决的是“已安装 App 能否进入自己的 runtime surface”,但不解决“Cloud release 是否已经被正式下载、校验、缓存并安装”。 + +因此计划按以下口径更新: + +1. 该补丁归类为 **P17.4 pre-work / current**:它是 runtime surface 的必要基础,不是 P17.2 完成证据。 +2. P17.2 主线已完成:P17.2.4b-2 已把 `cloud_release` 的 `packageUrl` 落成 verified package cache / staging;P17.2.5 已完成标准 schema / reference CLI cross-check;P17.3 与 P17.4.5 均已完成,当前进入 P17.5 formal entry GUI smoke。 +3. `LIME_AGENT_APP_LOCAL_ROOTS` 和开发态 `limecloud/` resolver 只服务本地开发 / 已存在源码包联调;生产路径不能用它们绕过 package hash、manifest hash 和 release descriptor。 +4. P17.4 后续验收必须证明:disabled / unverified / missing package 不会启动;runtime 只加载 P12 verified package;App UI 只能通过 injected SDK handles 调 Lime 能力。 +5. P17.5 formal smoke 必须覆盖正式 `agent-apps` 的 install / registration / launch / disable / uninstall rehearsal / runtime surface,不能继续用 Lab smoke 冒充正式入口证据。 + +### 已完成计划:P17 Gate + +P17 Gate 的最小可交付不是“上线正式 Agent Apps 入口”,而是对 P16-H.1-P16-H.5 做完成度审计,确认是否真的具备进入正式入口设计的条件: + +1. 先建 checklist:把多 App repository、selected launcher、persistent lifecycle、cleanup evidence、residual audit、flag-off smoke、command boundary、Cloud boundary、失败退出方案逐项映射到证据。 +2. 再验真实证据:不能只看文档状态,要检查测试、smoke summary、command contracts、boundary rg、source 文件和证据截图。 +3. 再分类缺口:缺代码、缺测试、缺 GUI 证据、缺文档、缺守卫必须分开,不把可选 polish 混进主线缺口。 +4. 最后给 Gate 结论:满足则进入 P17 正式入口设计;不满足则回 P16-H 补最短缺口。 + +P17 Gate 审计已沉淀到 [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md),并已继续完成 [P17.0 Formal Entry Contract](./p17-formal-entry-contract.md)、P17.1 formal route / nav / copy hardening、P17.2 source / install contract hardening、P17.3 lifecycle / cleanup contract hardening 与 P17.4 runtime surface production hardening;当前下一刀进入 P17.5 formal entry GUI smoke。 + +### 已完成计划:P16-H.5 + +P16-H.5 的最小可交付不是“发布正式入口”,而是把 P16-H.1-P16-H.4 的多 App lifecycle 证据固化成可重复 GUI smoke,并补 flag-off 后的失败退出证明: + +1. 先补 smoke 断言:`smoke:agent-app-lab` summary 必须覆盖 multi-app list、selected app、selected runtime app、cleanup evidence、residual audit visible / pending。 +2. 再补 flag-off regression:关闭 Agent App Lab 相关 host flag 后,Manager 不出现在普通 Chat / Skill / Artifact / Workspace 主路径。 +3. 再补边界审计:`src/features/agent-app` 仍无 `safeInvoke` / `invoke`、Tauri command、mock priority 或 raw Worker。 +4. 再处理契约阻塞:若工作区出现正式 API 网关或 Tauri command 候选,必须单独对齐 command catalog / Rust 注册 / mock,不并入 P16-H 实验岛。 +5. 最后更新证据:P16-H 文档记录 smoke summary、flag-off 证据、contracts 状态;通过后才评估 P17 正式入口方案。 + +本阶段明确不做 marketplace、正式主导航、Cloud 管理台、真实 delete-data、远程下载器或完整行业内容系统。 + +### 已完成计划:P16-H.4 + +P16-H.4 的最小可交付是把 P16-H.3 的 cleanup rehearsal evidence 转成 residual audit,不新增真实删除能力: + +1. 先补纯函数:输入 selected installed state、cleanup evidence、repository issue,输出 retained、pending deletion、blocked out-of-scope、repository issue 四类 residual。 +2. 再接 Manager:`AgentAppManagerPanel` 只展示 residual audit summary,不扫描真实文件系统、不调用 Tauri。 +3. 再接 Lab:keep-data / delete-data preview 后展示 selected app 的 audit 结果,仍复用 P11 repository 和 P16-H.3 evidence。 +4. 再补验证:组件测试覆盖四类 residual,Lab 测试覆盖 companion app,`smoke:agent-app-lab` summary 覆盖 residual audit visible / selected app。 +5. 最后更新证据:P16-H 文档记录命令结果;P16-H.4 完成后下一刀切到 P16-H.5 GUI smoke + flag-off regression。 + +本阶段明确不做真实删除、不做全盘 residual scan、不接正式入口、不新增 Tauri command、不让 Agent App 直接 `safeInvoke` / `invoke`。 + +### 已完成计划:P16-H.3 + +P16-H.3 的最小可交付不是“卸载功能”,而是把 keep-data / delete-data 演练固化成可复核 JSON evidence summary: + +1. 先补纯函数:基于 selected installed state 与 P15 cleanup preview 生成 `cleanup rehearsal evidence`,字段固定为 appId、appVersion、packageHash、manifestHash、strategy、targets、blockedTargets、generatedAt。 +2. 再接 Manager:`AgentAppManagerPanel` 只展示 Lab-only JSON 预览,不写文件、不下载、不调用 Tauri。 +3. 再接 Lab:`AgentAppLabPage` 的 keep-data / delete-data preview 必须使用 selected app state,不能回退到默认内容工厂 fixture。 +4. 再补验证:组件测试覆盖 JSON summary,Lab 测试覆盖 companion app,`smoke:agent-app-lab` summary 覆盖 selected appId / strategy / blocked target count。 +5. 最后更新证据:P16-H 文档记录命令结果;P16-H.3 完成后下一刀切到 P16-H.4 residual audit。 + +本阶段明确不做真实删除、不做全盘 residual scan、不接正式入口、不新增 Tauri command、不让 Agent App 直接 `safeInvoke` / `invoke`。 ## 模块设计 @@ -163,6 +382,10 @@ src/features/agent-app/ │ ├── packageVerifier.ts │ ├── localPackageStore.ts │ ├── installedAppState.ts +│ ├── packageCache.ts +│ ├── setupStateStore.ts +│ ├── cloudBootstrap.ts +│ ├── labInstallFlow.ts │ ├── cleanupPlan.ts │ ├── uninstallApp.ts │ └── installFlow.test.ts @@ -182,6 +405,8 @@ src/features/agent-app/ ├── runtime/ │ ├── uiExtensionHost.ts │ ├── workerRuntimeHost.ts +│ ├── runtimePackageLoader.ts +│ ├── entryRuntimeGuard.ts │ ├── runtimePolicy.ts │ └── runtimeSandbox.test.ts ├── ui/ @@ -521,9 +746,9 @@ App 自带模型网关 无 trace 的工具调用 ``` -## APP 内容工厂验证切片 +## 内容工厂验证切片 -APP 内容工厂只作为平台验证样板,不进入 Lime Core。 +内容工厂只作为平台验证样板,不进入 Lime Core。 P4 最小闭环: @@ -734,13 +959,13 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp - `UiExtensionHost` 明确阻断 raw Tauri API、Node API、未声明网络、下载和弹窗;worker runtime 仍保持关闭。 - P3 仍不新增 Tauri command、不注册正式主路由、不执行 App worker。 -### P4:APP 内容工厂最小业务闭环 +### P4:内容工厂最小业务闭环 目标:验证 Product-level App 是否值得继续投入。 交付: -- APP 内容工厂 fixture / demo package。 +- 内容工厂 fixture / demo package。 - 项目创建 UI。 - App namespace storage。 - 知识 fixture binding。 @@ -756,7 +981,7 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp - Artifact 和 Evidence 可追溯、可清理。 - 完成一次从项目创建到内容表生成再到卸载清理的闭环。 -当前客户端 P4.1 最小落地: +P4-R 后 P4.1 current 落地标准: - `runtime/contentFactoryDemo.ts` 只通过 `CapabilityHost` / `LimeAppSdk` 编排业务闭环,不 import Lime internal store。 - demo 先运行 `content_scenario_planning` adapter entry,再用同一 `workflowRunId` 写入 `projects/*`、`knowledge-bindings/*`、`content_scenarios/*`、`content-assets/*`。 @@ -765,7 +990,7 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp - delete-data 卸载测试覆盖 P4 demo 的 storage、artifact、evidence、task 清理。 - P4.1 仍不新增 Tauri command、不修改 AgentChat / Skill Catalog / Artifact 主 schema、不进入正式主导航。 -当前客户端 P4.2 最小落地: +P4-R 后 P4.2 current 落地标准: - `runtime/runtimePolicy.ts` 定义 workflow runtime policy:只允许 `storage.set`、`knowledge.search`、`agent.startTask`、`artifacts.create`、`evidence.record` 五类 DSL step。 - `runtime/workflowRuntimeHost.ts` 提供 `WorkflowRuntimeHost.runWorkflow()`,通过 `workerRuntimeEnabled` 受控开启,支持 trace、step 间 cancel、policy violation 和 disabled error。 @@ -776,13 +1001,25 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp ### P5:Cloud Bootstrap 接入 -目标:客户端 P0-P4 成立后,再消费 LimeCore 控制面。 +目标:客户端 P0-P4 成立后,再消费 LimeCore 控制面;Cloud 只是 package source / tenant metadata 输入,Desktop 仍是运行、权限、数据和清理边界。 范围: -- Cloud 下发 catalog、release metadata、package hash、tenant enablement、license、policy defaults。 -- Desktop 仍负责本地 install、projection、readiness、runtime。 -- 本地 JSON source 和 Cloud payload 使用同一 adapter。 +- Cloud 下发 catalog、release metadata、package URL、package hash、manifest hash、signature ref、tenant enablement、license、tool availability、policy defaults。 +- Desktop 仍负责 download、verify、install、projection、readiness、authorization、SDK injection、runtime、cleanup。 +- 本地 fixture / dev package / Cloud payload 使用同一 package source、manifest parser、projection compiler、readiness runner。 +- P5 继续保持 `cloudBootstrapEnabled=false` 默认关闭,只在 Lab 实验岛验证。 + +P5 分期以 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md) 为准: + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P5.0 | 标准差距收口:把上游 v0.3 schema、projection、readiness、overlay、lifecycle、typed SDK 变化转成客户端 gap matrix。 | 不改运行代码,不接 Cloud 管理台。 | +| P5.1 | `CloudBootstrapPayload` DTO / parser / validator。 | 不下载 package,不安装,不运行 App。 | +| P5.2 | 统一 Package Source Adapter,把 Cloud release 映射到现有安装链路。 | 不新增第二套 Cloud install flow。 | +| P5.3 | tenant enablement、tool availability、policy defaults 与 local readiness 合并。 | 不允许 Cloud 直接把 App 标记为 ready。 | +| P5.4 | disable、hash mismatch、offline、upgrade、uninstall 回归。 | `enabled=false` 不删除用户数据。 | +| P5.5 | 上游 schema / reference CLI cross-check。 | 不把 reference CLI 输出直接当 Lime runtime 实现。 | 验收: @@ -790,6 +1027,8 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp - Cloud disable 后客户端禁用 App,但不删除用户数据。 - package hash / manifest hash 校验失败时拒绝启用。 - tenant enablement 不绕过本地 readiness / permission guard。 +- projection / readiness / artifact / evidence / cleanup 都带 `packageHash` 和 `manifestHash` provenance。 +- `src/features/agent-app` 不出现 `safeInvoke` / `invoke` / Tauri command / raw Worker 越界入口。 ## 任务拆分 @@ -806,8 +1045,41 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp | P2.1 | storage / artifact / evidence adapter。 | `adapters/*` | adapter tests。 | | P2.2 | knowledge / agent adapter。 | `adapters/*` | adapter tests。 | | P3.1 | UI host。 | `runtime/uiExtensionHost.ts` | sandbox smoke。 | +| P4-R0 | 计划与事实源收口。 | `docs/roadmap/agentapp/*` | `git diff --check` + legacy 关键词审计。 | +| P4-R1 | v0.3 manifest / fixture / readiness rebaseline。 | `types.ts`、`manifest/*`、`fixtures/*`、`projection/*`、`readiness/*` | parser / projection / readiness / feature flag tests。 | +| P4-R2 | 内容工厂 demo rename 和数据对象 rebaseline。 | `runtime/contentFactoryDemo.ts`、`adapters/*`、`sdk/*` | content factory / adapter / SDK tests。 | +| P4-R3 | WorkflowRuntimeHost rebaseline 到 `content_factory_demo`。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts`、`runtime/workflowRuntimeCapabilityProfile.ts` | workflow runtime tests。 | +| P4-R4 | Lab UI、五语言 i18n、index export rebaseline。 | `ui/AgentAppLabPage.tsx`、`index.ts`、`src/i18n/resources/*/agent.json` | UI test + i18n tests。 | +| P4-R5 | Gate 恢复与旧 SceneApp 不复活。 | `src/features/agent-app/*`、相关 dangling 引用 | `test:contracts`、`verify:local`、GUI smoke。 | | P4.1 | 内容工厂切片。 | `fixtures/*`、`ui/*` | end-to-end demo。 | | P4.2 | 受控 workflow runtime。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts` | workflow runtime tests / UI test。 | +| P5.0 | v0.3 标准差距收口。 | `docs/roadmap/agentapp/p5-cloud-bootstrap.md`、标准 gap matrix | `git diff --check` + legacy 关键词审计。 | +| P5.1 | Cloud Bootstrap payload DTO / parser / validator。 | `install/cloudBootstrap.ts`、`types.ts`、`featureFlag.ts` | payload parser / feature flag tests。 | +| P5.2 | Bootstrap payload 统一 package source adapter。 | `install/*`、`manifest/*`、`projection/*` | package identity / install preview / projection tests。 | +| P5.3 | Tenant enablement + local readiness 合并。 | `readiness/*`、`projection/*`、`ui/*` | readiness / projection / UI tests。 | +| P5.4 | hash mismatch / disable / offline / upgrade 回归。 | `install/*`、`runtime/*`、`ui/*` | uninstall / workflow runtime / UI tests。 | +| P5.5 | Schema / reference CLI cross-check。 | `docs/roadmap/agentapp/*`、上游 `agentapp` reference CLI | `agentapp-ref validate/project/readiness` + 差异记录。 | +| P6 | v0.3 projection / readiness schema coverage。 | `types.ts`、`manifest/*`、`projection/*`、`readiness/*`、`fixtures/*` | parser / projection / readiness / schema coverage tests。 | +| P7 | 本地 schema / snapshot gate。 | `schema/schemaGate.ts`、`schema/schemaGate.test.ts`、`index.ts` | schema gate + projection / readiness tests。 | +| P8 | setup resolver / `needs-setup` 语义。 | `types.ts`、`readiness/*`、`ui/*`、五语言 `agent.json` | readiness / schema gate / UI / i18n tests。 | +| P9 | installed setup state store。 | `install/setupStateStore.ts`、`install/cleanupPlan.ts`、`sdk/*`、`adapters/*`、`ui/*` | setup state / readiness / cleanup / UI tests。 | +| P10 | installed app state snapshot 与 in-memory store。 | `install/installedAppState.ts`、`types.ts`、`install/cleanupPlan.ts`、`ui/*`、`index.ts` | installed state / setup state / projection / UI tests。 | +| P11 | Local Persistence Adapter。 | `install/installedAppState.ts` 或同目录 repository 文件、`install/setupStateStore.ts`、`install/cleanupPlan.ts`、`ui/*` | repository contract / persistence / cleanup / typecheck / contracts。 | +| P12 | Package Cache / Verify / Rollback。 | `install/packageCache.ts`、`install/packageIdentity.ts`、`install/cloudBootstrap.ts`、`install/cleanupPlan.ts`、`readiness/*` | package cache / hash verify / rollback / readiness tests。 | +| P13 | Runtime Package Loader / UI Bundle Loader。 | `install/packageCache.ts`、`runtime/*`、`projection/*`、`ui/*` | loader / UI host / policy guard tests。 | +| P14 | Entry Runtime Guard / Permission Prompt。 | `runtime/*`、`readiness/*`、`install/*`、`ui/*` | guard / permission prompt / setup / policy tests。 | +| P15 | Lab Install / Launch Flow。 | `install/labInstallFlow.ts`、`runtime/entryRuntimeGuard.ts`、`ui/*`、五语言 `agent.json` | lab flow / guard / package cache / installed state / UI tests。 | +| P15-H | GUI smoke / cleanup rehearsal hardening。 | `scripts/agent-app-lab-smoke.mjs`、`featureFlag.ts`、`ui/*`、`docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md` | `smoke:agent-app-lab` / typecheck / boundary audit。 | +| P16 | Agent App Manager / Product Entry Gate。 | `ui/AgentAppManagerPanel.tsx`、`ui/AgentAppLabPage.tsx`、`install/labInstallFlow.ts`、`runtime/entryRuntimeGuard.ts` | manager UI / entry launcher / lifecycle evidence / GUI smoke。 | +| P16-H | Multi-app repository / lifecycle hardening。 | `install/installedAppState.ts`、`ui/AgentAppManagerPanel.tsx`、`ui/AgentAppManagerPanel.test.tsx`、`scripts/agent-app-lab-smoke.mjs`、`p16-h-multi-app-repository-lifecycle-hardening.md` | repository-backed multi-app list / selected app launcher / persistent lifecycle / cleanup evidence export / residual audit。 | +| P17 Gate | 正式入口前 gate audit。 | `p17-formal-entry-gate-audit.md`、P16-H evidence、boundary audit | gate checklist / command contracts / typecheck / GUI evidence。 | +| P17.0 | Formal Entry Contract。 | `p17-formal-entry-contract.md`、README、implementation plan | `agent-apps` / `agent-app-lab` / runtime surface 职责边界清晰。 | +| P17.1 | Formal route / nav / copy hardening。 | `AgentAppsPage.tsx`、`AppPageContent.tsx`、navigation、五语言 `agent.json` | formal route / copy / sidebar / i18n / boundary tests。 | +| P17.2 | Source / install contract hardening。 | `p17-source-install-contract-hardening.md`、`AgentAppsPage.tsx`、`src/lib/api/agentApps.ts`、`install/*`、上游 `agentapp` schema / reference CLI | P17.2.1-P17.2.5 已覆盖 source state / install review / registration / release descriptor / verification gate / acquisition seam / verified cache source / packageUrl fetch / staging / reference CLI cross-check。 | +| P17.3 | Lifecycle / cleanup contract hardening。 | `p17-lifecycle-cleanup-contract-hardening.md`、`install/*`、`ui/AgentAppsPage.tsx`、cleanup evidence / residual audit、overlay / setup / secret binding 边界 | disable / enable、uninstall keep-data、delete-data rehearsal、export-then-delete preview 都复用同一 namespace 和 evidence,不执行真实删除。 | +| P17.4 | Runtime surface production hardening。 | `ui/AgentAppRuntimePage.tsx`、`runtime/*`、`src/lib/api/agentApps.ts`、`src-tauri/src/commands/agent_app_cmd.rs` | 当前 runtime surface 补丁只算预铺;生产路径必须只加载 verified package cache / staging,不依赖 dev resolver,并证明 injected SDK / sandbox / provenance 没有被绕过。 | +| P17.5 | Formal entry GUI smoke。 | `scripts/*smoke*`、正式 `agent-apps` route、五语言文案、P17 evidence summary | 新增或扩展正式入口 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off;Lab smoke 只保留研发回归。 | +| P18 | Typed Capability SDK gate。 | `sdk/*`、`runtime/*`、schema / mock / contract tests | 在扩 raw worker 前,把 `lime.ui / storage / agent / knowledge / tools / artifacts / workflow / policy / secrets / evidence` 的 typed host bridge 固定为稳定 SDK 契约。 | ## 验证策略 @@ -819,15 +1091,17 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp | Capability SDK | contract tests。 | | Tauri command | `npm run test:contracts`。 | | 用户可见 UI | 相关 `*.test.tsx` + GUI smoke。 | +| Agent App Lab GUI | `npm run smoke:agent-app-lab -- --timeout-ms 180000` + evidence summary。 | | 主路径接入 | `npm run verify:local` + `npm run verify:gui-smoke`。 | 进入正式主路径前必须满足: 1. `npm run verify:local` 通过。 -2. `npm run verify:gui-smoke` 覆盖 Lab 入口和关闭 flag 场景。 -3. SDK contract tests 覆盖 P0 capabilities。 -4. cleanup / uninstall tests 覆盖 keep data、delete data、export then delete。 -5. Artifact / Evidence provenance 查询和删除通过。 +2. `npm run smoke:agent-app-lab -- --timeout-ms 180000` 覆盖 Lab install / launch / cleanup preview。 +3. `npm run verify:gui-smoke` 的全局风险有明确结论;若失败来自外部 provider/model,必须单独记录,不得伪装为 Agent App 通过或失败。 +4. SDK contract tests 覆盖 P0 capabilities。 +5. cleanup / uninstall tests 覆盖 keep data、delete data、export then delete。 +6. Artifact / Evidence provenance 查询和删除通过。 ## 主路径接入门槛 @@ -878,16 +1152,111 @@ rg "agent-app|AgentApp|agent_app" src src-tauri docs/roadmap/agentapp | D3 | P3 结束 | UI Host 是否安全可控? | App UI 不能越权访问资源。 | 暂停 UI bundle,保留 projection UI。 | | D4 | P4 结束 | Product-level App 是否有平台价值? | 内容工厂闭环跑通。 | 清理实验岛,不进入正式入口。 | | D5 | P5 结束 | Cloud bootstrap 是否只是控制面? | 断网已安装 App 可用。 | Cloud 回退为 catalog-only。 | +| D6 | P14 结束 | Entry 授权 gate 是否可靠? | allow / needs-setup / blocked / denied 均有测试,Lab run / mount 不能绕过 guard。 | 暂停 P15,不继续串启动闭环。 | +| D7 | P15 结束 | Lab 安装启动闭环是否可演示且可清理? | install review、permission prompt、launch、cleanup preview 连续可用。 | 保持 Lab-only,不进入正式入口。 | +| D8 | P15-H 结束 | GUI 证据是否足够支撑进入 App 管理面? | Agent App Lab 专用 smoke 通过,summary / 截图 / cleanup preview 可复核。 | 不做 P16 App Manager,先补 GUI 或 cleanup hardening。 | +| D9 | P16 结束 | 最小 Manager 是否足够支撑多 App hardening? | 单 App Manager、entry launcher、disable / uninstall preview、cleanup evidence 与 Agent App Lab smoke 通过。 | 继续补 P16 UI / guard / evidence,不进入多 App。 | +| D10 | P16-H 结束 | 是否可以从 Lab 进入正式 Agent Apps 入口? | 多 App repository、持久化 lifecycle、cleanup evidence 导出、残留检查和 flag-off 回归稳定。 | 继续实验岛,不接正式主导航。 | +| D11 | P17.0 结束 | 正式入口契约是否足够约束后续实现? | `agent-apps` / `agent-app-lab` / runtime surface 职责、禁区、架构和后续 gates 已写清。 | 不进入代码 hardening,先补契约和验收口径。 | +| D12 | P17.1 结束 | 正式入口是否已脱离 Lab 心智? | `agent-apps` route / nav / copy 独立;UI entry 进入 `agent-app` runtime surface;Lab flag-off 不影响正式入口。 | 不进入 source hardening,先修正式入口导航和文案。 | +| D13 | P17.2 结束 | Source / install 契约是否足够支撑真实安装入口? | local / cloud / registration 均有 source state 和 install review;Cloud release 已 pin 到 package / manifest hash;写 repository 前完成 verify;cached fallback 不伪装新安装;上游 schema / reference CLI cross-check 通过;Lab fixture 不作为正式 source。 | 不进入 lifecycle / cleanup hardening,先补 source / install review。 | +| D14 | P17.3 结束 | Lifecycle / cleanup 是否足够支撑可失败退出? | disable / enable、uninstall rehearsal、export-then-delete preview、residual audit、overlay / setup / secret binding 分离均可复核;仍不执行真实 delete-data。 | 不进入 runtime production hardening,先补生命周期和清理证据。 | +| D15 | P17.4 结束 | Runtime surface 是否真正只运行 verified package 且只走标准 Host Bridge? | `cloud_release` 只从 verified cache / staging 启动;dev resolver 仅开发态可用;disabled / unverified package 无法启动;`lime.agentApp.bridge` 覆盖主题、导航、toast、download、capability invoke;SDK bridge / sandbox / provenance 有测试。 | 保持 runtime surface 为预铺,不发布 formal runtime。 | +| D16 | P17.5 结束 | 正式入口 smoke 是否能替代 Lab 证据? | `agent-apps` 专用 smoke 覆盖 install、registration、launch、disable、uninstall rehearsal、runtime surface、flag-off,并输出独立 summary。 | Lab smoke 继续只作研发证据,不把正式入口标为可交付。 | ## 下一刀 -P0-P4.2 已证明 Agent App 可以在客户端实验岛内完成 package 解析、projection、readiness、cleanup dry-run、SDK facade、mock / adapter capability、受控 UI Host、内容工厂最小业务闭环和受控 workflow runtime。下一刀进入真实 worker sandbox 前置清障与 P5 Cloud bootstrap,但仍不做市场页、不进正式主路径: +路线图已升级为 Agent App v0.3 / 内容工厂,且 P4-R rebaseline、P5.0-P5.5 Cloud Bootstrap、P6 schema coverage、P7 schema gate、P8 setup resolver、P9 setup state store、P10 installed state snapshot、P11 local persistence adapter、P12 package cache / verify / rollback、P13 runtime package loader、P14 entry runtime guard、P15 lab install / launch flow、P15-H Agent App Lab 专用 GUI smoke、P16 Agent App Manager、P16-H multi-app lifecycle hardening、P17 Gate 审计、P17.0、P17.1、P17.2.1-P17.2.5、P17.3 与 P17.4.1-P17.4.5 已通过定向 / 专用验证 / GUI smoke / 计划收口。当前下一刀是 P17.5 formal entry GUI smoke:不新增 marketplace、Cloud 管理台、真实 delete-data 或完整行业内容系统;P17.5 只为正式 `agent-apps` 建独立 GUI smoke 与 evidence summary。 ```text -真实 worker sandbox policy 草案 -→ package hash / manifest hash 校验 -→ Cloud bootstrap payload 本地适配器 -→ 断网已安装 App 可用性验证 +P4-R0~P4-R5 v0.3 / 内容工厂 Rebaseline(已通过) +→ P5.0 v0.3 标准差距收口(已通过) +→ P5.1 Cloud Bootstrap payload DTO / parser / validator(已通过) +→ P5.2 Bootstrap payload 本地 package source adapter(已通过) +→ P5.3 Tenant enablement + local readiness 合并判断(已通过) +→ P5.4 hash mismatch / disable / offline regression tests(已通过) +→ P5.5 schema / reference CLI cross-check(已通过) +→ P6 v0.3 projection / readiness schema coverage(已通过) +→ P7 schema validator / reference snapshot gate(已通过) +→ P8 setup resolver / needs-setup semantics(已通过) +→ P9 installed setup state store(已通过) +→ P10 installed app state / persistence(已通过) +→ P11 local persistence adapter(已通过) +→ P12 package cache / verify / rollback(已通过) +→ P13 runtime package loader / UI bundle loader(已通过) +→ P14 entry runtime guard / permission prompt(已完成) +→ P15 lab install / launch flow(已完成) +→ P15-H GUI smoke / cleanup rehearsal hardening(已完成专用证据) +→ P16 Agent App Manager / Product Entry Gate(已完成最小实现) +→ P16-H.0 Multi-app repository / lifecycle hardening 计划收口(已完成) +→ P16-H.1 Repository-backed multi-app list(已完成最小实现) +→ P16-H.2 Selected app launcher + persisted lifecycle(已完成最小实现) +→ P16-H.3 Cleanup rehearsal evidence export(已完成最小实现) +→ P16-H.4 Residual audit(已完成最小实现) +→ P16-H.5 Agent App Lab GUI smoke + flag-off regression(已完成最小实现) +→ P17 正式入口前 Gate 审计(已完成) +→ P17.0 Formal Entry Contract(已完成计划收口) +→ P17.1 Formal route / nav / copy hardening(已完成最小实现) +→ P17.2.1 Source state model(已完成最小实现) +→ P17.2.2 Install review descriptor(已完成最小实现) +→ P17.2.3 Registration hardening(已完成最小实现) +→ P17.2.4a Cloud release descriptor / verification gate(已完成最小实现) +→ P17.2.4b-1 Cloud package acquisition seam / verified cache source(已完成最小实现) + ↳ sidecar:P17.4 Formal runtime surface pre-work(已完成补丁;不改变 P17.2 顺序) +→ P17.2.4b-2 packageUrl fetch / staging / manifest extraction(已完成最小实现) +→ P17.2.5 Schema / reference CLI / example package cross-check(已完成) +→ P17.3 Lifecycle / cleanup contract hardening(已完成) +→ P17.4 Runtime surface production hardening(已完成,含 P17.4.5 GUI smoke) +→ P17.5 Formal entry GUI smoke(下一刀,和 Lab smoke 分离) ``` +P14 文件边界: + +- 复用 P8-P13 的 readiness、setup state、package verification、runtime package loader 和 UI host,不新增第二套 guard。 +- Guard 输出 allow / needs-setup / blocked / denied,并生成 permission prompt descriptor。 +- Permission prompt 只展示 capability / permission / policy 摘要,不保存 secret value。 +- runtime policy block 优先于 UI 展示。 +- `cloudBootstrapEnabled` 默认保持关闭;Lab 内显式开启才能读取 bootstrap fixture。 +- P14 不做 marketplace、正式主导航、raw worker sandbox 或任意 App package JS 执行。 +- Cloud / LimeCore 不运行默认 Agent Runtime,不渲染 App UI,不接管本地 storage。 + +P15 文件边界(已按实验岛落地): + +- 复用 P5-P14 的 source、schema gate、package cache、installed state、readiness、guard、runtime loader 和 cleanup plan,不新增正式 installer 服务。 +- Lab flow 只编排 `source-selected → package-reviewed → package-verified → installed → setup-review → permission-review → launched → cleanup-preview`。 +- P15 未做 marketplace、正式主导航、公开审核流、远程下载器或 Cloud 管理台。 +- P15 的运行入口仍先通过 P14 guard;不允许 UI 为了演示直接调用 `mountEntry()` 或 `runEntry()`。 + +P15-H 文件边界(已按实验岛落地): + +- `smoke:agent-app-lab` 只证明 Agent App Lab install / launch / cleanup preview,不替代全局 `verify:gui-smoke`。 +- localStorage host flags 只用于本地 Lab / GUI smoke,不作为生产远程开关。 +- 证据输出在 `.lime/qc/gui-evidence/agent-app-lab/`,不写入 App package 或 Cloud metadata。 +- P15-H 仍不新增 Tauri command,不接正式主导航,不执行 raw worker。 + +P16 文件边界(已落地): + +- 已在实验岛内做 installed app 状态、状态徽标、entry launcher 和 lifecycle actions 最小实现。 +- 所有 launch / mount 继续复用 P14 guard;所有 install / uninstall 继续复用 P10-P15 的 state、cache 和 cleanup preview。 +- P16 只定义正式入口前 gate,不直接做 marketplace、Cloud 管理台或完整行业内容系统。 + +P16-H 文件边界(已完成): + +- Manager list 必须读取 P11 repository / local persistence adapter,不新增平行 store。 +- P16-H 详细计划以 [p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md) 为准。 +- Disable / enable 必须持久化 installed state,不只停留在 React state。 +- Cleanup rehearsal evidence、residual audit、Agent App Lab GUI smoke、flag-off regression 与 P17 Gate 审计已具备证据;P17.0 已把正式入口契约收口,P17.1 已完成正式入口 route / nav / copy 最小硬化。 +- P16-H 历史阶段仍不新增 Tauri command,不让 Agent App 直接 `safeInvoke` / `invoke`;正式入口后续由 P17 current 链承担。 + +P17 文件边界(当前主线): + +- P17.0 以 [p17-formal-entry-contract.md](./p17-formal-entry-contract.md) 为 current 契约,不把 P17 Gate 审计当成实现计划。 +- P17.1 已完成 `agent-apps` route / nav / copy / state / tests 最小硬化,不发布 marketplace。 +- P17.2 以 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md) 为已完成执行计划;P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 和 registration hardening,P17.2.4a 已完成 release descriptor / verification gate,P17.2.4b-1 已完成 acquisition seam / verified cache source,P17.2.4b-2 已完成 packageUrl fetch / staging / manifest extraction,P17.2.5 已对齐上游 schema / reference CLI。 +- P17.3 以 [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md) 为已完成执行计划;P17.3.1-P17.3.6 已收口 lifecycle descriptor、formal page lifecycle UI、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression,继续不碰真实 delete-data、不新增命令、不改 Cloud / LimeCore。 +- P17.4 以 [p17-4-host-bridge-runtime.md](./p17-4-host-bridge-runtime.md) 为已完成执行计划;P17.4.1-P17.4.5 已完成,任何 `cloud_release` runtime 都必须先成为 verified package cache / staging,不允许用开发态 resolver 或本地源码目录补正式安装缺口;App iframe 与 Host 的主题、导航、toast、download、capability invoke、Agent task 和 structured write-back 统一走标准 Host Bridge。 +- `agent-apps` 是用户入口;`agent-app-lab` 是研发验证入口;两者证据不能互相冒充。 +- P17 继续复用 P11 repository、P14 guard、P15 cleanup preview、P16-H evidence / residual audit,不新增第二套 store / installer / scanner。 +- Cloud / LimeCore 仍只提供 catalog / release / tenant metadata,不运行 Agent、不渲染 UI、不接管本地 storage。 + 后续退出条件仍是:如果需要修改超过 3 个 Lime 核心主路径模块、App 需要绕过 SDK 直接调 internal store、或数据无法按 namespace 清理,就停止堆业务功能,回到 SDK / Host 边界。 diff --git a/docs/roadmap/agentapp/p0-technical-design.md b/docs/roadmap/agentapp/p0-technical-design.md index 980cbcfe4..d6c19dcbc 100644 --- a/docs/roadmap/agentapp/p0-technical-design.md +++ b/docs/roadmap/agentapp/p0-technical-design.md @@ -498,7 +498,7 @@ P0 UI 不出现“运行 App”“打开 App 页面”“启用 App 到主导航 "version": "0.3.0", "status": "draft", "appType": "domain-app", - "description": "APP 内容工厂 fixture", + "description": "内容工厂 fixture", "runtimeTargets": ["local"], "requires": { "lime": { diff --git a/docs/roadmap/agentapp/p10-installed-state-persistence.md b/docs/roadmap/agentapp/p10-installed-state-persistence.md new file mode 100644 index 000000000..9aaf0b2ed --- /dev/null +++ b/docs/roadmap/agentapp/p10-installed-state-persistence.md @@ -0,0 +1,83 @@ +# Agent App P10 Installed App State / Persistence + +更新时间:2026-05-15 + +## 一句话目标 + +P10 的目标是把 Agent App 的 installed preview、setup state、projection、readiness 汇总为可恢复的 installed state snapshot。当前只实现可序列化状态模型和 in-memory store,不写真实文件、不下载 package、不进入正式主路径。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| State 类型 | `InstalledAgentAppState` 已加入 `src/features/agent-app/types.ts`。 | +| State 构造 | `buildInstalledAgentAppState()` 可从 `InstalledAppPreview` 和 `AgentAppSetupState` 构造 snapshot。 | +| In-memory store | `InMemoryInstalledAgentAppStateStore` 支持 `upsert()`、`get()`、`list()`、`setDisabled()`、`remove()`、`clear()`。 | +| Cleanup 集成 | `AppCleanupPlan` 增加 `installedStatePaths`,路径为 `/agent-apps/installed/.json`。 | +| Uninstall 集成 | Mock / Adapter host delete-data 目标包含 installed state path。 | +| UI Preview | Lab cleanup panel 把 installed state path 纳入 package 清理组展示。 | +| 导出入口 | `src/features/agent-app/index.ts` 导出 installed state API。 | + +## 数据边界 + +Installed state 允许保存: + +```text +package identity +normalized manifest +projection snapshot +readiness snapshot +setup binding refs +disabled flag +installedAt / updatedAt +``` + +禁止保存: + +```text +secret value +API key / OAuth token +客户知识正文 +workspace 文件内容 +App storage 业务记录 +Tool 调用输入输出全文 +raw package code +``` + +## 当前状态模型 + +```ts +type InstalledAgentAppState = { + appId: string + identity: PackageIdentity + manifest: NormalizedAppManifest + projection: AgentAppProjection + readiness: ReadinessResult + setup: AgentAppSetupState + disabled: boolean + installedAt: string + updatedAt: string +} +``` + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,14 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,56 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| P11 已补本地 persistence adapter。 | P12 已继续完成 package cache / verify / rollback。 | +| 未加载 package runtime / UI bundle。 | P13 已处理 runtime package loader / UI bundle loader。 | +| Installed state 尚未与 Cloud bootstrap refresh 合并。 | P16 评估 App Manager refresh / migration policy,不在 P10 内补第二套同步。 | +| 未做 GUI smoke。 | P15-H 已补 Agent App Lab 专用 GUI smoke;正式主路径前仍需全局 GUI smoke 结论。 | + +## 下一刀 + +P10 已完成 installed state snapshot 和 in-memory store,P11-P13 已继续完成 [Local Persistence Adapter](./p11-local-persistence-adapter.md)、[Package Cache / Verify / Rollback](./p12-package-cache-verify-rollback.md) 与 [Runtime Package Loader / UI Bundle Loader](./p13-runtime-package-loader.md)。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p11-local-persistence-adapter.md b/docs/roadmap/agentapp/p11-local-persistence-adapter.md new file mode 100644 index 000000000..d866719f6 --- /dev/null +++ b/docs/roadmap/agentapp/p11-local-persistence-adapter.md @@ -0,0 +1,209 @@ +# Agent App P11 Local Persistence Adapter + +更新时间:2026-05-15 + +## 一句话目标 + +P11 的目标是把 P10 已经验证的 installed state snapshot 从 in-memory store 推进到客户端本地可恢复的 persistence adapter。P11 仍只做 Agent App 实验岛内的状态持久化边界,不下载 package、不接市场页、不进入正式主导航、不新增 Tauri command。 + +## 背景 + +P10 已经证明客户端可以把 `installed preview + setup state + projection + readiness` 汇总为可序列化的 `InstalledAgentAppState`,并把 installed state path 纳入 cleanup / uninstall preview。 + +但 in-memory store 不能支撑真实安装闭环: + +1. 重启 Lime Desktop 后无法恢复已安装 App。 +2. Cloud bootstrap 断网回退缺少本地 installed registry。 +3. setup binding 已进入 store,但还没有统一的持久化边界。 +4. uninstall delete-data 只能预览路径,还不能删除真实状态文件。 + +因此 P11 只补“本地状态仓库”这一层,不扩大为下载器、市场页或正式运行入口。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Repository / envelope | `InstalledAgentAppStateEnvelope`、`AgentAppSetupStateEnvelope`、`AgentAppPersistenceDriver`、`LocalInstalledAgentAppStateRepository` 已落到 `src/features/agent-app/install/installedAppState.ts`。 | +| 本地 Lab driver | `BrowserLocalStorageAgentAppPersistenceDriver` 提供 Lab 可恢复 persistence driver;`InMemoryAgentAppPersistenceDriver` 保留为测试 driver。 | +| save / restore | Repository 写入 installed state 与 setup state envelope,并可通过 `get()` / `list()` 恢复 projection / readiness snapshot。 | +| schema gate | 恢复时执行 projection / readiness schema gate;坏文件或坏 snapshot 返回 load issue,不进入可运行状态。 | +| disable / remove | Repository 支持 `setDisabled()` 与 `remove()`;`remove()` 同时删除 installed state 与 setup state。 | +| uninstall 集成 | `uninstallApp()` 支持注入 `installedStateRepository`,卸载时同步清理本地 installed / setup persistence 状态。 | +| 导出入口 | `src/features/agent-app/index.ts` 导出 P11 repository、driver 和 envelope 类型。 | + +## 架构图 + +```mermaid +flowchart TD + Preview[InstalledAppPreview] --> Builder[buildInstalledAgentAppState] + Setup[AgentAppSetupState] --> Builder + Builder --> Port[InstalledStateRepository Port] + + Port --> Memory[InMemory Store for tests] + Port --> File[Local Persistence Adapter] + + File --> Validate[Schema / Snapshot Gate] + File --> Registry[Installed Registry JSON] + File --> SetupFile[Setup State JSON] + + Registry --> Restore[Restore Projection + Readiness Snapshot] + SetupFile --> Restore + Restore --> Lab[Agent App Lab] + Restore --> Cleanup[Cleanup / Uninstall Plan] +``` + +关键点: + +1. `Repository Port` 是 App Host 的依赖,避免 UI / runtime 直接知道文件细节。 +2. `InMemory Store` 保留为测试和实验岛 fallback。 +3. `Local Persistence Adapter` 只能保存 snapshot 与 refs,不能保存 secret value、客户知识正文、workspace data 或 App storage 业务记录。 +4. 读入状态必须先过本地 schema / snapshot gate,避免坏文件污染运行时。 + +## 时序图 + +### 安装 / 更新状态 + +```mermaid +sequenceDiagram + participant Lab as Agent App Lab + participant Preview as Install Preview + participant Builder as State Builder + participant Repo as InstalledStateRepository + participant File as Local Persistence Adapter + + Lab->>Preview: build preview from package source + Preview-->>Lab: manifest + projection + readiness + Lab->>Builder: buildInstalledAgentAppState(preview, setup) + Builder-->>Lab: InstalledAgentAppState + Lab->>Repo: save(state) + Repo->>File: validate + serialize + File-->>Repo: atomic write result + Repo-->>Lab: persisted state +``` + +### 启动恢复 + +```mermaid +sequenceDiagram + participant App as Lime Desktop Startup + participant Repo as InstalledStateRepository + participant File as Local Persistence Adapter + participant Gate as Schema Gate + participant Lab as Agent App Lab + + App->>Repo: listInstalled() + Repo->>File: read registry files + File->>Gate: validate snapshot + Gate-->>File: valid / invalid + File-->>Repo: valid states + quarantined issues + Repo-->>Lab: installed states + Lab-->>App: render restored Lab state +``` + +### 卸载并删除数据 + +```mermaid +sequenceDiagram + participant Lab as Agent App Lab + participant Cleanup as Cleanup Plan + participant Repo as InstalledStateRepository + participant File as Local Persistence Adapter + + Lab->>Cleanup: build uninstall delete-data plan + Cleanup-->>Lab: installedStatePaths + setupStatePaths + Lab->>Repo: remove(appId, deleteData=true) + Repo->>File: delete installed + setup state files + File-->>Repo: deletion result + Repo-->>Lab: cleanup evidence +``` + +## 数据契约 + +P11 新增一个明确的本地持久化 envelope: + +```ts +type InstalledAgentAppStateEnvelope = { + schemaVersion: 1 + savedAt: string + state: InstalledAgentAppState +} +``` + +允许保存: + +```text +package identity +normalized manifest +projection snapshot +readiness snapshot +setup binding refs +disabled flag +installedAt / updatedAt / savedAt +schemaVersion +``` + +禁止保存: + +```text +secret value +API key / OAuth token +客户知识正文 +workspace 文件内容 +App storage 业务记录 +Tool 调用输入输出全文 +raw package code +``` + +## 分期状态 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P11.0 | 已完成:定义 repository port、envelope 和错误类型。 | 未绑定 Tauri 文件系统。 | +| P11.1 | 已完成:保留 P10 in-memory store,并新增 repository / driver contract。 | 未改 Lab 正式行为。 | +| P11.2 | 已完成:实现 local persistence adapter,写入 `/agent-apps/installed/.json` 与 setup state envelope。 | 未硬编码平台用户目录,未新增 Tauri command。 | +| P11.3 | 已完成:读取时执行 schema gate / snapshot validation,坏文件隔离为 load issue。 | 未自动修复未知 schema。 | +| P11.4 | 已完成:`uninstallApp()` 可注入 repository 并删除 installed / setup state。 | `keep-data` 仍不删除用户数据。 | +| P11.5 | 已完成:测试覆盖本地恢复、坏文件隔离、disable、remove 和卸载清理。 | 未接 marketplace,未进入正式主导航。 | + +## 文件边界 + +| 文件 | 当前改动 | +|---|---| +| `src/features/agent-app/install/installedAppState.ts` | 保留 builder 与 in-memory store,新增 repository contract、localStorage driver、in-memory driver、schema gate load validation。 | +| `src/features/agent-app/install/installedAppState.test.ts` | 覆盖 repository save / restore、localStorage driver、disable、remove、坏文件隔离和 schema gate。 | +| `src/features/agent-app/install/uninstallApp.ts` | 支持注入 installed state repository,卸载时清理 installed / setup persistence 状态。 | +| `src/features/agent-app/sdk/MockCapabilityHost.test.ts` | 覆盖卸载时 repository persistence 状态被清理。 | +| `src/features/agent-app/schema/schemaGate.ts` | 恢复坏 snapshot 时不崩溃,返回结构化 issue。 | +| `src/features/agent-app/index.ts` | 导出 repository、driver、envelope 与卸载 repository 类型。 | + +如果需要真实文件系统能力,优先复用 Lime 现有客户端存储封装;不能在 Agent App 内直接 `safeInvoke` / `invoke`,也不能新增 Agent App 专属 Tauri command。 + +## 验收标准 + +1. 重启后能从本地状态恢复 installed app snapshot。 +2. 断网时可用本地 installed registry 展示已安装 App 的 projection / readiness snapshot。 +3. `delete-data` 能清理 installed state 与 setup state;`keep-data` 不删除用户数据。 +4. 坏文件不会让 Lab 崩溃,必须输出 load issue 并跳过该 App。 +5. 状态文件不包含 secret value、客户知识正文、workspace data 或 raw package code。 +6. `src/features/agent-app` 仍没有 `safeInvoke` / `invoke` / Tauri 注册 / raw Worker 越界入口。 + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts` | 通过,18 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/runtime/uiExtensionHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,65 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| P11 只持久化 installed / setup state,不管理 package cache 实体。 | P12 已处理 package cache / verify / rollback。 | +| P11 不执行 App package JS,也不加载 UI bundle。 | P13 之后评估 runtime package loader。 | +| 当前 localStorage driver 面向 Lab persistence;真实文件 driver 仍需通过 Lime 平台存储封装注入。 | 不在 Agent App 内新增 Tauri command 或直接 `safeInvoke`。 | + +## 下一刀 + +P11 已完成 Local Persistence Adapter,P12-P13 已继续完成 [Package Cache / Verify / Rollback](./p12-package-cache-verify-rollback.md) 与 [Runtime Package Loader / UI Bundle Loader](./p13-runtime-package-loader.md)。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p12-package-cache-verify-rollback.md b/docs/roadmap/agentapp/p12-package-cache-verify-rollback.md new file mode 100644 index 000000000..f5fc7fb9f --- /dev/null +++ b/docs/roadmap/agentapp/p12-package-cache-verify-rollback.md @@ -0,0 +1,118 @@ +# Agent App P12 Package Cache / Verify / Rollback + +更新时间:2026-05-15 + +## 一句话目标 + +P12 的目标是在 P11 本地 installed state 可恢复之后,补齐 package cache、hash verify、upgrade staging 和 rollback 边界。P12 仍不做 marketplace、不接正式主导航、不执行任意 App package code,只让客户端能够安全地保存、校验、复用和回退 package 输入。 + +## 背景 + +P5 已把 Cloud bootstrap payload 收敛为统一 package source,P10/P11 已把安装状态推进到可恢复状态。但真实安装闭环仍缺少 package 层能力: + +1. package URL / package hash 目前只是 metadata,没有缓存实体。 +2. 断网恢复只能恢复 installed state snapshot,不能验证 package 文件仍存在。 +3. upgrade 还没有 staging 区,hash mismatch 后缺少 rollback 结果。 +4. cleanup plan 已包含 package cache path,但还没有 cache index / verify result。 + +P12 只补 package cache 这一层,不抢做 raw worker sandbox 或正式 App 入口。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Package hash 事实源 | `src/features/agent-app/install/packageIdentity.ts` 导出稳定 hash 构造函数,继续复用 `PackageIdentity`。 | +| Cache entry / repository | `src/features/agent-app/install/packageCache.ts` 新增 `AgentAppPackageCacheEntry`、verify result、stage / commit / rollback result 和 `InMemoryAgentAppPackageCacheRepository`。 | +| Hash verify | `verifyAgentAppPackageCacheEntry()` 校验 package hash / manifest hash;mismatch 不进入 active cache。 | +| Cached fallback | Repository `resolve()` 可返回 `cache_hit / cache_miss / hash_mismatch`,只复用 verified active entry。 | +| Upgrade staging | `stageUpgrade()` 不覆盖 active package;`commitStaged()` 成功后才更新 active entry。 | +| Rollback | `rollback()` 恢复 previous verified package,并输出 `agent_app` provenance evidence。 | +| Readiness blocker | `checkReadiness()` 接收 package verification result;missing / mismatch 会生成 `PACKAGE_HASH_MISSING` / `PACKAGE_HASH_MISMATCH` blocker。 | +| Cleanup 集成 | `AppCleanupPlan` 增加 package cache index / staging paths;Mock / Adapter uninstall delete-data 会纳入清理目标。 | + +## 架构图 + +```mermaid +flowchart TD + Source[Local / Cloud Package Source] --> Fetch[Package Fetcher Port] + Fetch --> Cache[Package Cache Adapter] + Cache --> Hash[Hash Verifier] + Hash --> Stage[Upgrade Staging] + Stage --> Commit{Verify ok?} + Commit -- yes --> Installed[Installed Package Pointer] + Commit -- no --> Rollback[Rollback to Previous Package] + Installed --> State[P11 Installed State Repository] + Rollback --> State + Cache --> Cleanup[Cleanup / Uninstall Plan] +``` + +## 时序图 + +```mermaid +sequenceDiagram + participant Lab as Agent App Lab + participant Source as Package Source + participant Cache as Package Cache + participant Verify as Hash Verifier + participant Repo as Installed State Repo + + Lab->>Source: resolve package identity + Source-->>Lab: packageUrl + packageHash + manifestHash + Lab->>Cache: fetchOrReuse(identity) + Cache-->>Verify: cached package bytes / manifest + Verify-->>Cache: verified / mismatch + Cache-->>Repo: commit verified package pointer + Repo-->>Lab: installed state updated +``` + +## 分期状态 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P12.0 | 已完成:定义 package cache entry、verify result、rollback result 类型。 | 未下载远程 package。 | +| P12.1 | 已完成:实现 in-memory cache repository。 | 未接 marketplace。 | +| P12.2 | 已完成:校验 `packageHash` / `manifestHash`,hash mismatch 生成 blocker。 | 未绕过本地 readiness。 | +| P12.3 | 已完成:支持 cached package fallback,只复用 verified package。 | 未让 Cloud 标记 ready。 | +| P12.4 | 已完成:支持 upgrade staging 与 rollback。 | 未覆盖旧可用 package。 | +| P12.5 | 已完成:package cache index / staged package 进入 cleanup / uninstall delete-data。 | `keep-data` 不删用户数据。 | + +## 文件边界 + +| 文件 | 当前改动 | +|---|---| +| `src/features/agent-app/install/packageIdentity.ts` | 导出 stable stringify、manifest hash、package hash helper,继续由 `PackageIdentity` 统一承载身份。 | +| `src/features/agent-app/install/packageCache.ts` | 新增 package cache entry / verify / resolve / stage / commit / rollback。 | +| `src/features/agent-app/install/packageCache.test.ts` | 覆盖 verified cache、hash mismatch、staging、commit、rollback、missing cache。 | +| `src/features/agent-app/readiness/checkReadiness.ts` | 接入 package verification blocker。 | +| `src/features/agent-app/install/cleanupPlan.ts` | package cache index / staging paths 进入 cleanup preview。 | +| `src/features/agent-app/sdk/MockCapabilityHost.ts`、`src/features/agent-app/adapters/AdapterCapabilityHost.ts` | uninstall delete-data 纳入 package cache index / staging paths。 | + +## 验收标准 + +1. hash mismatch 时拒绝启用新 package,并保留旧可用 package。 +2. 断网但本地 cache 存在且 hash 已验证时,可以恢复 installed state。 +3. upgrade staging 不覆盖旧 package;commit 成功后才更新 installed pointer。 +4. rollback 有明确结果和 evidence,不吞掉失败原因。 +5. cleanup / uninstall delete-data 能清理 package cache 和 staging。 +6. 不新增 Tauri command,不让 Agent App 直接 `safeInvoke` / `invoke`。 + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/packageCache.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,25 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/install/packageCache.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/runtime/uiExtensionHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,70 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| P12 只实现 package cache / verify / rollback,不加载 package UI bundle。 | P13 处理 runtime package loader / UI bundle loader。 | +| P12 不下载远程 package。 | 后续真实 fetcher 仍必须作为 package source adapter 注入,不直接接 Cloud 管理台。 | +| P12 不执行 App package JS。 | P13/P14 继续保持 raw worker sandbox 关闭。 | + +## 下一刀 + +P12 已完成 Package Cache / Verify / Rollback,P13 已继续完成 [Runtime Package Loader / UI Bundle Loader](./p13-runtime-package-loader.md)。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p13-runtime-package-loader.md b/docs/roadmap/agentapp/p13-runtime-package-loader.md new file mode 100644 index 000000000..1a1d09f2a --- /dev/null +++ b/docs/roadmap/agentapp/p13-runtime-package-loader.md @@ -0,0 +1,81 @@ +# Agent App P13 Runtime Package Loader / UI Bundle Loader + +更新时间:2026-05-15 + +## 一句话目标 + +P13 的目标是在 P12 package cache / verify / rollback 稳定后,开始让客户端从已验证 package 中加载受控 runtime descriptor 和 UI bundle descriptor。P13 仍不执行任意 App JS,不开放 raw worker sandbox,只做 loader、allowlist、bundle manifest 和 UI host 接线前的安全边界。 + +## 背景 + +P0-P12 已经覆盖 manifest、projection、readiness、SDK host、UI host、workflow runtime、Cloud bootstrap、setup state、installed state、local persistence 与 package cache。下一步不能直接“运行 App package”,否则会绕开 Capability SDK 和 sandbox policy。 + +因此 P13 只做 loader 层: + +1. 从已验证 package cache 读取 runtime descriptor。 +2. 校验 UI bundle descriptor 与 manifest projection 一致。 +3. 将 bundle 交给现有 UI Extension Host 的受控 mount contract。 +4. 保持 worker runtime / raw JS 执行关闭。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Runtime descriptor | `src/features/agent-app/runtime/runtimePackageLoader.ts` 新增 `AgentAppRuntimePackageDescriptor`、UI bundle descriptor 与 policy evidence。 | +| Verified cache gate | `loadRuntimePackageDescriptor()` 只接受通过 P12 `verifyAgentAppPackageCacheEntry()` 的 package cache entry。 | +| Bundle manifest gate | `findUiBundleDescriptor()` 校验 entry 必须存在于 projection,且必须是 `page / panel / settings` UI entry。 | +| UI Host 接线 | `mountRuntimePackageUiEntry()` 复用 P3 `UiExtensionHost.mountEntry()`,不绕过 sandbox / injected SDK bridge。 | +| Policy evidence | descriptor 固定输出 raw worker / network / filesystem / raw Tauri / Node API 全部关闭。 | +| 导出入口 | `src/features/agent-app/index.ts` 导出 loader API 和类型。 | + +## 架构图 + +```mermaid +flowchart TD + Cache[P12 Verified Package Cache] --> Loader[Runtime Package Loader] + Loader --> Descriptor[Runtime Descriptor] + Descriptor --> Gate[Bundle Manifest Gate] + Gate --> UIHost[P3 UI Extension Host] + Gate --> Policy[Runtime Policy] + UIHost --> Preview[Lab Preview Mount] + Policy --> Block[Block Raw Worker / Network / FS] +``` + +## 分期状态 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P13.0 | 已完成:定义 runtime package loader port 和 descriptor 类型。 | 未执行 JS。 | +| P13.1 | 已完成:从 verified cache 读取 package descriptor。 | 未下载 package。 | +| P13.2 | 已完成:校验 UI bundle descriptor 与 projection entry 一致。 | 未接正式主导航。 | +| P13.3 | 已完成:接入现有 `UiExtensionHost` 的 mount contract。 | 未开放 raw Tauri / Node API。 | +| P13.4 | 已完成:对 raw worker / network / filesystem 输出 policy block 证据。 | 未运行任意 worker bundle。 | +| P13.5 | 已完成:补 Lab-only unit coverage。 | 未进入 marketplace。 | + +## 验收标准 + +1. 只有已通过 P12 verify 的 package 能进入 loader。 +2. bundle descriptor entry 必须存在于 projection。 +3. UI mount 只能通过 P3 `UiExtensionHost`,不能绕过 Capability SDK。 +4. raw worker、network、filesystem 默认阻断并有测试证据。 +5. 不新增 Tauri command,不让 Agent App 直接 `safeInvoke` / `invoke`。 + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/runtime/runtimePackageLoader.test.ts src/features/agent-app/runtime/uiExtensionHost.test.ts src/features/agent-app/install/packageCache.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,17 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/install/packageCache.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/runtime/uiExtensionHost.test.ts src/features/agent-app/runtime/runtimePackageLoader.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,74 tests。 | +| `npm run typecheck` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| P13 只加载 descriptor,不执行 App package JS。 | P14/P15/P15-H 已继续通过 guard / prompt / smoke 验证 launch flow;P16 仍不执行 raw package JS。 | +| P13 没有合并 entry-level permission prompt。 | P14 已处理 Entry Runtime Guard / Permission Prompt。 | +| P13 不进入正式主导航。 | P16 先在实验岛内验证 App Manager,再评估是否从 Lab 升级。 | + +## 下一刀 + +P13 已完成 Runtime Package Loader / UI Bundle Loader,[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 已继续把 loader 与 entry-level permission、setup state、readiness 和 runtime policy 合并;[P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已串成 Lab-only 端到端流程,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager。P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p14-entry-runtime-guard-permission-prompt.md b/docs/roadmap/agentapp/p14-entry-runtime-guard-permission-prompt.md new file mode 100644 index 000000000..430719c60 --- /dev/null +++ b/docs/roadmap/agentapp/p14-entry-runtime-guard-permission-prompt.md @@ -0,0 +1,303 @@ +# Agent App P14 Entry Runtime Guard / Permission Prompt + +更新时间:2026-05-15 + +## 一句话目标 + +P14 已在 P13 runtime package loader 和 UI bundle loader 稳定后,把 entry-level readiness、setup state、package verification、runtime policy 和 permission prompt 合并成统一运行前守卫。P14 仍只服务 Lab 实验岛,不进入正式主导航,不执行 raw worker,不让 App 绕过 Capability SDK。 + +## 计划更新判断 + +上游 Agent App 标准已经把宿主最低职责明确为:`Discover → Validate → Project → Check readiness → Authorize → Inject capabilities → Isolate data → Clean up`。Lime 客户端 P0-P13 已经覆盖到 package、projection、readiness、setup、persistence、cache、loader,但还缺少独立的 `Authorize` 运行前 gate。 + +因此计划需要这样调整: + +1. P14 不再只是 UI 上的权限说明,而是所有 entry `run / mount` 前必须经过的统一 guard。 +2. P14 完成前,P15 的 Lab install / launch flow 不得直接串 runtime loader。 +3. P14 guard 必须复用 P8-P13 的事实源,不能新增第二套 readiness 或 package verification。 +4. Permission prompt 只展示 capability、permission、policy、setup 摘要,不保存 secret value,不携带客户数据。 +5. Cloud / LimeCore 仍只提供 catalog、release、tenant enablement、metadata;不运行 Agent、不渲染 UI、不接管本地 storage。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Guard evaluator | `src/features/agent-app/runtime/entryRuntimeGuard.ts` 新增 `evaluateAgentAppEntryRuntimeGuard()`,输出 `allow / needs-setup / blocked / denied`。 | +| Permission prompt descriptor | Guard 生成 capability、permission、setup、policy、secret slot 和 warning 摘要,不包含 secret value 或客户数据。 | +| Package / loader 合并 | Guard 复用 P12 package verification 与 P13 `loadRuntimePackageDescriptor()`,hash mismatch 优先 `blocked`。 | +| Lab 前置接线 | `AgentAppLabPage` 在 `runEntry`、`mountEntry` 和内容工厂 demo 前先调用 P14 guard;非 `allow` 不继续调用 CapabilityHost / UI Host。 | +| 五语言文案 | `src/i18n/resources/*/agent.json` 增加 guard 状态、摘要、policy 和 setup 文案。 | +| 导出入口 | `src/features/agent-app/index.ts` 导出 P14 guard API 和类型。 | + +## 当前输入事实源 + +| 输入 | 事实源 | P14 用法 | +|---|---|---| +| Installed preview | `InstalledAppPreview` | 获取 manifest、projection、readiness、cleanup plan 和 package identity。 | +| Setup state | `AgentAppSetupState` / `InMemoryAgentAppSetupStateStore` | 判断 required Knowledge / Skill / Tool / Secret / Overlay 是否已绑定。 | +| Package verification | `verifyAgentAppPackageCacheEntry()` | hash mismatch / missing 必须先于 runtime mount 阻断。 | +| Runtime descriptor | `loadRuntimePackageDescriptor()` | 确认 UI bundle descriptor 来自 verified package cache。 | +| UI sandbox policy | `agentAppUiSandboxPolicy` | 输出 raw Tauri / Node / network / filesystem 阻断证据。 | +| Readiness result | `ReadinessResult.entryReadiness` | 合并 app-level blocker 和 entry-level blocker。 | +| Host flags | `AgentAppHostFlags` | 确认 UI / worker / adapter / cloud bootstrap 是否显式开启。 | +| Projection declarations | `AgentAppProjection` | 汇总 capabilities、permissions、tools、secrets、policies、runtime package。 | + +## 架构图 + +```mermaid +flowchart TD + Preview[P10/P11 Installed Preview] --> Guard[P14 Entry Runtime Guard] + Setup[P9 Setup State] --> Guard + Cache[P12 Package Cache Verification] --> Guard + Loader[P13 Runtime Package Descriptor] --> Guard + Readiness[P8 Readiness + Entry Readiness] --> Guard + Policy[P3/P4 Runtime Policy Evidence] --> Guard + Flags[AgentAppHostFlags] --> Guard + + Guard --> Decision{Guard Status} + Decision --> Allow[allow] + Decision --> NeedsSetup[needs-setup] + Decision --> Blocked[blocked] + Decision --> Denied[denied] + + Allow --> Prompt[Permission Prompt Descriptor] + Prompt --> Lab[Lab Run / Mount] + NeedsSetup --> SetupUi[Setup Action Summary] + Blocked --> BlockUi[Blocked Reason Summary] + Denied --> DenyUi[User Denied Summary] +``` + +## 状态模型 + +| 状态 | 含义 | 是否允许 run / mount | 典型原因 | +|---|---|---:|---| +| `allow` | package、setup、readiness、policy 均满足,且用户已同意必要权限。 | 是 | verified package、entry ready、permission accepted。 | +| `needs-setup` | 结构合法,但 required setup 未完成。 | 否 | `project_knowledge` 未绑定、required skill / tool / secret 未配置。 | +| `blocked` | 宿主或 package 层硬阻断。 | 否 | hash mismatch、Cloud disabled、capability missing、runtime policy block。 | +| `denied` | 用户或 workspace owner 拒绝必要授权。 | 否 | required file / tool / model / secret permission 被拒绝。 | + +P14 不引入“半运行”状态:只要 entry 被判为 `needs-setup / blocked / denied`,Lab 不得继续调用 `UiExtensionHost.mountEntry()` 或 `CapabilityHost.runEntry()`。 + +## Guard 决策顺序 + +```text +1. 校验 entry 存在并属于当前 projection。 +2. 校验 feature flag 与 entry kind 是否允许当前 Lab 操作。 +3. 校验 package cache verification,missing / mismatch 直接 blocked。 +4. 校验 runtime package descriptor 与 entry bundle 是否一致。 +5. 校验 runtime policy evidence,raw worker / network / filesystem / Tauri / Node 默认 blocked。 +6. 合并 app-level readiness blockers。 +7. 合并 entry-level readiness blockers。 +8. 合并 setup state:required 未解决输出 needs-setup,optional 未解决输出 warning。 +9. 生成 permission prompt descriptor。 +10. 用户拒绝 required permission 输出 denied;同意后输出 allow。 +``` + +## Entry 类型处理 + +| Entry kind | P14 行为 | 不做什么 | +|---|---|---| +| `page` / `panel` / `settings` | 通过 runtime package loader 找到 UI bundle,再交给 UI Host。 | 不注册正式 route,不开放 raw Tauri / Node。 | +| `workflow` | 仅允许受控 workflow DSL 和现有 Lab runner。 | 不执行 raw worker bundle。 | +| `expert-chat` | 只生成 permission / capability review 摘要。 | 不接 Chat 主路径。 | +| `command` / `artifact` / `background-task` | 默认 blocked 或 Lab-only disabled,直到后续阶段定义 runtime host。 | 不自动注册命令、Artifact viewer 或后台任务。 | + +## Permission Prompt Descriptor 草案 + +```ts +type AgentAppEntryRuntimeGuardStatus = "allow" | "needs-setup" | "blocked" | "denied"; + +type AgentAppPermissionDecision = "not-required" | "requires-review" | "accepted" | "denied"; + +interface AgentAppPermissionPromptDescriptor { + appId: string; + appVersion: string; + entryKey: string; + entryTitle: string; + packageHash: string; + manifestHash: string; + decision: AgentAppPermissionDecision; + requestedCapabilities: Array<{ + capability: string; + requestedRange: string; + implementation: "mock" | "adapter" | "native"; + required: boolean; + }>; + requestedPermissions: Array<{ + key: string; + reason: string; + required: boolean; + }>; + setupSummary: Array<{ + kind: string; + key: string; + required: boolean; + resolved: boolean; + remediation?: string; + }>; + policySummary: { + rawWorkerAllowed: false; + networkAllowed: false; + fileSystemAllowed: false; + rawTauriAllowed: false; + nodeApiAllowed: false; + }; + secretSlots: Array<{ + key: string; + provider?: string; + required: boolean; + }>; + warnings: string[]; +} + +interface AgentAppEntryRuntimeGuardResult { + status: AgentAppEntryRuntimeGuardStatus; + prompt?: AgentAppPermissionPromptDescriptor; + blockers: ReadinessIssue[]; + warnings: ReadinessIssue[]; + provenance: AgentAppProvenance; +} +``` + +约束:`secretSlots` 只能展示 slot metadata,不能展示 secret value;prompt descriptor 不能包含 workspace 私有文件内容、Knowledge 原文或客户数据。 + +## 时序图:打开 UI entry + +```mermaid +sequenceDiagram + participant User as 用户 + participant Lab as Agent App Lab + participant Guard as Entry Runtime Guard + participant Cache as Package Cache + participant Loader as Runtime Loader + participant Prompt as Permission Prompt + participant UIHost as UI Extension Host + + User->>Lab: Open entry + Lab->>Guard: evaluate(appId, entryKey) + Guard->>Cache: verify package cache + Cache-->>Guard: verified / mismatch + Guard->>Loader: load descriptor + find bundle + Loader-->>Guard: descriptor / issue + Guard->>Guard: merge readiness + setup + policy + Guard-->>Lab: allow + prompt descriptor + Lab->>Prompt: render summary, request decision + Prompt-->>Lab: accepted + Lab->>UIHost: mountEntry(entryKey) + UIHost-->>Lab: sandboxed mount result +``` + +## 流程图:运行前阻断 + +```mermaid +flowchart TD + Start[用户触发 entry] --> Entry{entry 存在?} + Entry -- 否 --> Blocked[blocked: ENTRY_NOT_FOUND] + Entry -- 是 --> Verify{package verified?} + Verify -- 否 --> Blocked + Verify -- 是 --> Runtime{runtime policy 允许?} + Runtime -- 否 --> Blocked + Runtime -- 是 --> Ready{readiness blocked?} + Ready -- 是 --> Blocked + Ready -- 否 --> Setup{required setup 已解决?} + Setup -- 否 --> NeedsSetup[needs-setup] + Setup -- 是 --> Prompt{required permission 同意?} + Prompt -- 拒绝 --> Denied[denied] + Prompt -- 同意 --> Allow[allow] +``` + +## 用户故事 + +| 编号 | 用户故事 | 验收标准 | +|---|---|---| +| US-P14-01 | 作为用户,我在打开 App 页面前能看到它要用哪些能力和权限。 | Prompt descriptor 展示 capability、permission、policy 和 setup 摘要。 | +| US-P14-02 | 作为用户,我拒绝必要权限后 App 不会继续运行。 | Guard 输出 `denied`,Lab 不调用 run / mount。 | +| US-P14-03 | 作为管理员,我希望 hash mismatch 不能被权限确认绕过。 | Package verification blocker 优先级高于 prompt。 | +| US-P14-04 | 作为 App 作者,我希望缺 setup 时用户看到明确修复动作。 | `needs-setup` 输出 kind、key、required、remediation。 | +| US-P14-05 | 作为平台维护者,我希望 guard 复用已有 readiness 和 loader。 | 不新增第二套 readiness checker 或 package verifier。 | + +## 典型用例 + +| 用例 | 输入 | 预期结果 | +|---|---|---| +| 打开 `dashboard` page | verified package、UI runtime enabled、entry ready | 输出 `allow`,展示 prompt,确认后 mount UI Host。 | +| 运行 `content_scenario_planning` | `project_knowledge` required 但未绑定 | 输出 `needs-setup`,提示绑定 Agent Knowledge。 | +| 升级包 hash 不一致 | package cache entry 与 identity mismatch | 输出 `blocked`,不展示可确认运行按钮。 | +| App 声明 worker bundle | raw worker sandbox 未开放 | 输出 policy block 或 warning;P14 不执行 worker bundle。 | +| 用户拒绝文件读取 | required permission denied | 输出 `denied`,不调用 SDK。 | + +## 分期计划 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P14.0 | 已完成:定义 guard result、prompt descriptor、permission decision 类型。 | 未接正式主导航。 | +| P14.1 | 已完成:实现 guard evaluator,合并 readiness、setup state、package verification、runtime policy。 | 未新增第二套 readiness。 | +| P14.2 | 已完成:实现 prompt descriptor builder,只输出 capability / permission / policy 摘要。 | 未保存 secret value。 | +| P14.3 | 已完成:将 guard 接到 Lab `runEntry` / `mountEntry` 前置路径。 | 未执行 raw worker。 | +| P14.4 | 已完成:覆盖 allow / needs-setup / blocked / denied 测试。 | 未新增 Tauri command。 | +| P14.5 | 已完成:更新五语言 Lab 文案与边界审计。 | 未进入 marketplace。 | + +## 文件边界 + +| 文件 | 计划改动 | +|---|---| +| `src/features/agent-app/runtime/entryRuntimeGuard.ts` | 新增 guard evaluator、prompt descriptor builder 和 status 合并逻辑。 | +| `src/features/agent-app/runtime/entryRuntimeGuard.test.ts` | 覆盖 allow、needs-setup、blocked、denied、policy block、hash mismatch。 | +| `src/features/agent-app/runtime/runtimePackageLoader.ts` | 仅作为输入复用;如需补 issue code,只做局部扩展。 | +| `src/features/agent-app/runtime/uiExtensionHost.ts` | 保留最后防线;Lab 前置 guard 通过后再调用 mount。 | +| `src/features/agent-app/ui/AgentAppLabPage.tsx` | 在 run / open UI 前展示 guard 和 prompt 状态。 | +| `src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 覆盖 prompt、拒绝、needs-setup、blocked 不继续运行。 | +| `src/features/agent-app/index.ts` | 仅导出 P14 public test seam,不暴露 Lime internal。 | +| `src/i18n/resources/*/agent.json` | 增加五语言 prompt / guard 文案。 | + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/install/packageCache.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/runtime/uiExtensionHost.test.ts src/features/agent-app/runtime/runtimePackageLoader.test.ts src/features/agent-app/runtime/entryRuntimeGuard.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,17 files / 79 tests。 | +| `npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,3 files / 22 tests。 | +| `npm run test:contracts` | 通过。 | +| `git diff --check -- docs/roadmap/agentapp src/features/agent-app src/i18n/resources src/vite-env.d.ts` | 通过。 | +| `rg` boundary / legacy audit | 通过,`src/features/agent-app` 与五语言 `agent.json` 无 `safeInvoke` / Tauri / raw Worker 越界,也未复活旧内容工程化 key。 | +| `npm run typecheck` | 通过;已将既有 `src/lib/sceneapp/product.test.ts` 的详情视图测试输入收窄到产品层实际消费字段,未扩展 deprecated SceneApp 运行面。 | + +## 验收标准 + +1. `runEntry` 和 `mountEntry` 前必须先调用 P14 guard。 +2. `needs-setup` 不得被误判为 `allow`。 +3. `package_hash_mismatch`、`manifest_hash_mismatch`、Cloud disabled、runtime policy block 必须直接 `blocked`。 +4. Permission prompt 不展示 secret value、文件内容、Knowledge 原文或客户数据。 +5. UI Host 仍阻断 raw Tauri API、Node API、network、filesystem、download 和 popup。 +6. 不新增 Tauri command,不直接 `safeInvoke` / `invoke`。 +7. P14 仍保持 Lab 实验岛,不注册正式主导航、命令面板、Chat 主路径或 Artifact 主 schema。 + +## 最小验证 + +```bash +npm run test -- \ + src/features/agent-app/runtime/entryRuntimeGuard.test.ts \ + src/features/agent-app/runtime/runtimePackageLoader.test.ts \ + src/features/agent-app/runtime/uiExtensionHost.test.ts \ + src/features/agent-app/readiness/checkReadiness.test.ts \ + src/features/agent-app/install/setupStateStore.test.ts \ + src/features/agent-app/ui/AgentAppLabPage.test.tsx + +npm run typecheck +npm run test:contracts + +git diff --check -- docs/roadmap/agentapp src/features/agent-app src/i18n/resources + +rg -n "safeInvoke|invoke\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\(" src/features/agent-app || true +``` + +## 非目标 + +- 不做 marketplace、公开审核流、支付、企业管理台。 +- 不把 Agent App entry 接入正式主导航或 Chat 主路径。 +- 不执行任意 App package JS、raw worker bundle、native binary 或 npm install。 +- 不新增 Tauri command。 +- 不让 Cloud / LimeCore 运行默认 Agent Runtime 或渲染 App UI。 + +## 下一刀 + +P14 已完成当前实现与定向验证,P15 已继续把 install review、package cache、permission prompt、runtime loader、entry launch 和 cleanup 串成一个可演示的 Lab-only 端到端流程,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager。P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md b/docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md new file mode 100644 index 000000000..a52aa72ab --- /dev/null +++ b/docs/roadmap/agentapp/p15-h-gui-smoke-cleanup-rehearsal.md @@ -0,0 +1,106 @@ +# Agent App P15-H GUI Smoke / Cleanup Rehearsal Hardening + +更新时间:2026-05-15 + +## 一句话目标 + +P15-H 把 P15 的 Lab-only 安装启动闭环从“组件和单测可用”推进到“真实 GUI 可演示、失败可定位、清理可复演”。它仍然不把 Agent App 接入正式主导航,也不把 LimeCore / Cloud 变成 runtime。 + +## 为什么需要 P15-H + +P15 已经串起 `source → install review → verify/cache → installed state → setup/readiness → permission prompt → launch → cleanup preview`,但这还不能证明用户真的能在 Lime Desktop 里完整走完流程。P15-H 专门补三类证据: + +1. GUI 证据:能从桌面壳进入 Agent App Lab,打开安装流程,处理 setup,经过 guard 后挂载 App UI。 +2. 边界证据:Lab 运行仍复用 P14 guard,不绕过 package verification、setup、permission 或 runtime policy。 +3. 清理证据:cleanup preview 与 keep-data / delete-data 分支继续覆盖 package cache、installed state、setup state、storage、artifact、evidence。 + +## 当前落地 + +| 项 | 状态 | 证据 | +|---|---|---| +| Lab GUI smoke 脚本 | 已落地 | `scripts/agent-app-lab-smoke.mjs`。 | +| npm 入口 | 已落地 | `npm run smoke:agent-app-lab`。 | +| GUI smoke feature flags | 已落地 | `resolveAgentAppHostFlags()` 支持 `lime.agentAppHost.flags` localStorage JSON,仅服务本地 Lab / smoke。 | +| P15 flow 可见性 | 已验证 | smoke 检查 `[data-testid="agent-app-install-flow"]`。 | +| setup → guard → UI mount | 已验证 | smoke 点击 `agent-app-lab-resolve-setup` 与 `agent-app-open-ui-entry-dashboard`。 | +| 证据输出 | 已落地 | `.lime/qc/gui-evidence/agent-app-lab/agent-app-lab-smoke-summary.json` 与截图。 | + +## GUI Smoke 场景 + +```mermaid +sequenceDiagram + participant Smoke as smoke:agent-app-lab + participant DevBridge as DevBridge Health + participant App as Lime Desktop Web + participant Lab as Agent App Lab + participant Guard as P14 Guard + participant UIHost as P3 UI Host + + Smoke->>DevBridge: 等待 /health + Smoke->>App: 注入 localStorage Lab flags + Smoke->>App: 打开 Lime + Smoke->>Lab: 点击 Agent App Lab + Lab-->>Smoke: install flow 可见 + Smoke->>Lab: Resolve Lab setup + Lab->>Guard: 评估 package / setup / permission / policy + Guard-->>Lab: allow + Smoke->>Lab: Open dashboard UI entry + Lab->>UIHost: mountEntry() + UIHost-->>Smoke: UI runtime result 可见 + Smoke->>Smoke: 输出截图和 summary +``` + +## 清理演练范围 + +P15-H 不直接删除用户数据;它验证 cleanup preview 和卸载分支是否仍能解释清楚: + +| 清理对象 | keep-data | delete-data | +|---|---|---| +| package cache / staged package | 删除或保留已验证指针,取决于安装策略 | 删除 Agent App package cache 目标 | +| installed state / projection / readiness | 删除 App 安装态 | 删除 App 安装态 | +| setup state | 保留用户绑定数据 | 删除 App setup binding 状态 | +| app storage namespace | 保留 | 删除 App namespace | +| artifact / evidence / task refs | 保留 | 删除 `sourceKind: agent_app` 且匹配 appId 的实验产物 | +| 非 Agent App 数据 | 不触碰 | 不触碰 | + +## 非目标 + +1. 不做 marketplace、App Catalog、Cloud 管理台或企业分发控制台。 +2. 不把 Agent App entry 接入正式主导航、命令面板、Chat 主路径或 Artifact 主 schema。 +3. 不执行 raw worker bundle、任意 package JS、native binary 或 npm install。 +4. 不新增 Tauri command,不让 Agent App 直接 `safeInvoke` / `invoke`。 +5. 不把 GUI smoke 的 localStorage flags 变成生产开关或远程控制协议。 + +## 验收标准 + +1. `npm run smoke:agent-app-lab -- --timeout-ms 180000` 能输出 summary 和截图。 +2. summary 中 `labVisible / installFlowVisible / setupResolved / guardAllowed / uiMounted / launchedStatus / cleanupPreview / noConsoleErrors` 全为 `true`。 +3. smoke 打开 Lab 后没有新增 console error 或 failed request。 +4. 关闭 Lab flags 后 Agent App Lab 不成为正式主路径入口。 +5. `src/features/agent-app` 仍无 `safeInvoke` / `invoke` / Tauri command / raw Worker 越界入口。 +6. 全局 `npm run verify:gui-smoke` 的外部 provider/model 失败不能被伪装成 Agent App 通过;两类证据必须分开记录。 + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run smoke:agent-app-lab -- --timeout-ms 180000` | 通过;summary 输出到 `.lime/qc/gui-evidence/agent-app-lab/agent-app-lab-smoke-summary.json`,截图输出到 `.lime/qc/gui-evidence/agent-app-lab/agent-app-lab-smoke.png`。 | +| `npm run typecheck` | 通过。 | +| `npm run verify:gui-smoke` | 最新 P16 验证已通过;P15-H 仍保留 Agent App Lab 专用 smoke 作为更贴边界证据。 | + +## P15-H 后的计划更新 + +P15-H 通过后,下一刀不应该直接做 marketplace 或 Cloud 管理台,而是进入 P16:Agent App Manager / Product Entry Gate。P16 已完成实验岛内最小 Manager,P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1-P17.2.4a 已完成,P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 + +P16 的目标是把“单个 Lab fixture 的安装启动流程”收敛成仍在实验岛内的 App 管理面,先验证多 App 生命周期,再评估是否进入正式产品入口: + +```text +P16.0 正式入口前 gate 定义 +→ P16.1 installed apps 列表与状态徽标 +→ P16.2 enable / disable / launch / uninstall 生命周期动作 +→ P16.3 统一复用 P14 guard 的 entry launcher +→ P16.4 cleanup rehearsal 与 evidence export +→ P16.5 flag-off / rollback / boundary regression +``` + +只有 P16-H 进一步证明多 App 管理、权限、清理和证据都能稳定工作,才评估正式 Agent Apps 入口;否则继续保持 Lab-only。 diff --git a/docs/roadmap/agentapp/p15-lab-install-launch-flow.md b/docs/roadmap/agentapp/p15-lab-install-launch-flow.md new file mode 100644 index 000000000..8760b4431 --- /dev/null +++ b/docs/roadmap/agentapp/p15-lab-install-launch-flow.md @@ -0,0 +1,156 @@ +# Agent App P15 Lab Install / Launch Flow + +更新时间:2026-05-15 + +## 一句话目标 + +P15 已在 P14 Entry Runtime Guard / Permission Prompt 稳定后,把 `package source → install review → verify/cache → setup/readiness → permission prompt → run/mount → cleanup` 串成一个 Lab-only 端到端演示流程。P15 仍不是 marketplace,也不进入正式主导航。 + +## 背景 + +P0-P13 已经把客户端需要的底层部件逐层补齐,P14 会补上运行前授权 gate。完成 P14 后,最大的缺口不再是单个能力,而是用户在 Lab 中无法像安装一个小程序一样完成连续流程:看到包、审查包、安装、处理 setup、确认权限、打开 entry、失败后清理。 + +P15 因此只做编排,不新增底层平台能力。 + +## P15 输入 + +| 来源阶段 | 可复用能力 | +|---|---| +| P5 | Cloud bootstrap / local package source 统一输入。 | +| P7 | Schema / snapshot gate。 | +| P8-P9 | `needs-setup` 与 setup state store。 | +| P10-P11 | installed state snapshot 与 local persistence adapter。 | +| P12 | package cache / verify / rollback。 | +| P13 | runtime package loader / UI bundle loader。 | +| P14 | entry runtime guard / permission prompt。 | + +## 当前进入状态 + +| 前置项 | 状态 | 说明 | +|---|---|---| +| P14 guard | 已具备 | `runEntry`、`mountEntry` 和内容工厂 demo 已先过 P14 guard,非 `allow` 不继续调用 CapabilityHost / UI Host。 | +| typecheck | 已恢复 | `npm run typecheck` 已通过;修复点只收窄 deprecated SceneApp 测试输入,未恢复旧 SceneApp 主路径。 | +| Lab 范围 | 保持实验岛 | P15 只能升级 `src/features/agent-app/ui/AgentAppLabPage.tsx` 和 `src/features/agent-app/install/labInstallFlow.ts` 一类 Lab-only 编排。 | +| 主路径边界 | 不变 | 不新增 Tauri command,不接正式主导航,不让 App 直接 `safeInvoke` / `invoke`。 | + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Lab install flow orchestrator | `src/features/agent-app/install/labInstallFlow.ts` 新增 `evaluateAgentAppLabInstallFlow()`,输出状态机、install review、verified cache、installed state、runtime loader、guard、cleanup 与 keep-data / delete-data 分支预览。 | +| Lab setup resolver | `buildAgentAppLabResolvedSetupState()` 只从 projection required 项生成 Lab 示例 setup,用于演示完整闭环;不写真实 workspace。 | +| Lab UI 接线 | `AgentAppLabPage` 新增安装启动流程卡片,展示 package identity、setup、permission、launch readiness、cleanup target、keep-data / delete-data 分支。 | +| P14 guard 复用 | run / mount / 内容工厂 demo 仍先过 `evaluateAgentAppEntryRuntimeGuard()`;P15 不绕过 package verification、setup 或 runtime policy。 | +| 五语言文案 | `src/i18n/resources/*/agent.json` 已补齐 install flow、状态机、setup、permission、卸载分支文案。 | +| 导出入口 | `src/features/agent-app/index.ts` 导出 P15 current API 和类型。 | + +## 架构图 + +```mermaid +flowchart TD + Source[Local Fixture / Cloud Bootstrap Source] --> Review[Install Review] + Review --> Cache[P12 Verify + Cache] + Cache --> State[P11 Installed State Repository] + State --> Readiness[P8/P9 Readiness + Setup] + Readiness --> Guard[P14 Entry Runtime Guard] + Guard --> Prompt[Permission Prompt] + Prompt --> Launch[P13 Loader + P3 UI Host / P4 Workflow Runtime] + Launch --> Result[Run Result / Mount Result] + Result --> Cleanup[Cleanup / Uninstall Preview] +``` + +## Lab 状态机 + +```text +source-selected +→ package-reviewed +→ package-verified +→ installed +→ setup-review +→ permission-review +→ launched +→ cleanup-preview +``` + +失败状态: + +```text +package-invalid +package-mismatch +needs-setup +permission-denied +runtime-blocked +cleanup-required +``` + +## 用户故事 + +| 编号 | 用户故事 | 验收标准 | +|---|---|---| +| US-P15-01 | 作为用户,我可以在 Lab 中看到安装包来源、hash、manifest hash 和 release metadata。 | Install review 展示 package identity,不展示客户数据。 | +| US-P15-02 | 作为用户,我可以先审查再安装,而不是点击 entry 时才知道缺什么。 | 安装前能看到 readiness、setup、permissions 摘要。 | +| US-P15-03 | 作为维护者,我希望升级失败能回滚。 | P12 staging / rollback 结果在 Lab 中可见。 | +| US-P15-04 | 作为用户,我可以在同一流程里打开 App 页面或运行 Lab entry。 | P14 guard allow 后才调用 loader / UI host / workflow host。 | +| US-P15-05 | 作为用户,我可以清楚看到卸载会删除和保留什么。 | Cleanup preview 使用 P10-P12 的 installed/setup/cache/storage/artifact/evidence 目标。 | + +## 分期计划 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P15.0 | 已完成:定义 Lab install flow state machine 和 orchestrator。 | 未新增正式 installer 服务。 | +| P15.1 | 已完成:把 fixture source 映射到 install review;Cloud source 仍复用 P5 metadata 输入,不在 UI 伪装成 ready。 | 未接公开 marketplace。 | +| P15.2 | 已完成:串联 package cache verify、installed state repository 和 readiness。 | 未绕过 hash / schema gate。 | +| P15.3 | 已完成:接入 P14 guard 和 permission prompt,再触发 run / mount。 | 未执行 raw worker。 | +| P15.4 | 已完成:展示 uninstall keep-data / delete-data 分支预览;rollback 能力继续复用 P12 package cache 测试覆盖。 | 未删除非 Agent App 数据。 | +| P15.5 | 已完成:补 Lab E2E-ish component tests 和边界审计。 | 未进入主导航。 | + +## 文件边界 + +| 文件 | 计划改动 | +|---|---| +| `src/features/agent-app/install/labInstallFlow.ts` | 新增 Lab-only orchestrator,组合 package source、cache、repository、readiness、guard。 | +| `src/features/agent-app/install/labInstallFlow.test.ts` | 覆盖 source、verify、install、guard、launch、cleanup 分支。 | +| `src/features/agent-app/ui/AgentAppLabPage.tsx` | 从“能力展示页”升级为 Lab install / launch flow 页面。 | +| `src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 覆盖安装审查、权限确认、阻断、清理预览。 | +| `src/features/agent-app/index.ts` | 导出 Lab flow 必需的 current API。 | +| `src/i18n/resources/*/agent.json` | 补全五语言安装、授权、启动、清理文案。 | + +## 验收标准 + +1. Lab 能从 current fixture 完成 install review、verified cache、installed state、readiness、guard、launch、cleanup preview。 +2. hash mismatch、schema gate failure、needs-setup、permission denied、runtime policy block 都有可解释 UI。 +3. 所有 runtime 行为仍通过 Capability SDK / UI Host / Workflow Runtime,不 import Lime internal。 +4. Cloud bootstrap 仍只是 source / metadata 输入,不能把 App 直接标记为 ready。 +5. 卸载 keep-data / delete-data 分支不删除非 Agent App 数据。 +6. 不新增 Tauri command,不接正式主导航、命令面板或 Chat 主路径。 + +## 最小验证 + +```bash +npm run test -- \ + src/features/agent-app/install/labInstallFlow.test.ts \ + src/features/agent-app/runtime/entryRuntimeGuard.test.ts \ + src/features/agent-app/install/packageCache.test.ts \ + src/features/agent-app/install/installedAppState.test.ts \ + src/features/agent-app/ui/AgentAppLabPage.test.tsx + +npm run typecheck +npm run test:contracts +``` + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/labInstallFlow.test.ts src/features/agent-app/runtime/entryRuntimeGuard.test.ts src/features/agent-app/install/packageCache.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,5 files / 28 tests。 | +| `npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts` | 通过,3 files / 17 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | +| `git diff --check -- docs/roadmap/agentapp src/features/agent-app src/i18n/resources src/vite-env.d.ts src/lib/sceneapp/product.test.ts` | 通过。 | +| `rg` boundary / legacy audit | 通过,`src/features/agent-app` 无 `safeInvoke` / Tauri / raw Worker 越界,未复活旧内容工程化 key。 | +| `npm run smoke:agent-app-lab -- --timeout-ms 180000` | 通过;P15-H 已补 Agent App Lab 专用 GUI smoke,summary / 截图输出到 `.lime/qc/gui-evidence/agent-app-lab/`。 | +| `npm run verify:gui-smoke` | 最新 P16 验证已通过;此前 provider/model 探测失败已不再阻塞当前 Lab 证据。 | + +## P15 之后再评估 + +P15 Lab 端到端流程已经具备代码、组件测试和 P15-H 专用 GUI smoke 证据,P16 已补实验岛内最小 Agent App Manager;P16-H.1 / P16-H.2 / P16-H.3 / P16-H.4 / P16-H.5 已把 Manager 推进到多 App repository list、selected launcher、持久化 lifecycle、cleanup evidence export、residual audit 与 flag-off GUI smoke。P17 Gate 审计、P17.0 Formal Entry Contract 与 P17.1 Formal route / nav / copy hardening 已完成,下一刀应优先做 P17.2 Source / install contract hardening,而不是直接讨论 marketplace、Workspace 内 App pin、命令面板入口、Chat expert entry 或真实 delete-data。 diff --git a/docs/roadmap/agentapp/p16-agent-app-manager-product-entry-gate.md b/docs/roadmap/agentapp/p16-agent-app-manager-product-entry-gate.md new file mode 100644 index 000000000..dcb05a0e9 --- /dev/null +++ b/docs/roadmap/agentapp/p16-agent-app-manager-product-entry-gate.md @@ -0,0 +1,227 @@ +# Agent App P16 Agent App Manager / Product Entry Gate + +更新时间:2026-05-15 + +## 一句话目标 + +P16 不是把 Agent App 直接发布到正式主导航,而是在实验岛内把单个 Lab flow 升级为可管理多个 App 的 Agent App Manager,用 installed state、entry guard、lifecycle action 和 cleanup evidence 证明 Agent App 有资格进入正式产品入口。 + +## 背景 + +P15 / P15-H 已经证明一个 App package 可以在 Lab 中完成安装审查、setup、授权、启动和 cleanup preview。但这仍然只是单个 fixture 的演示流程,不足以支撑正式产品入口。 + +正式入口前还缺四类能力: + +1. 管理面:用户需要看到已安装 App、版本、source、readiness、enabled / disabled 状态。 +2. 生命周期:用户需要可控地 launch、disable、uninstall keep-data、uninstall delete-data。 +3. 统一入口:所有 entry 都必须复用 P14 guard,而不是每个按钮自己拼运行逻辑。 +4. 证据闭环:每次 launch、disable、uninstall、cleanup rehearsal 都需要可追踪 evidence,方便失败后清理。 + +## 当前落地 + +| 项 | 状态 | 证据 | +|---|---|---| +| Manager shell | 已完成最小实现 | `src/features/agent-app/ui/AgentAppManagerPanel.tsx`。 | +| Installed app 状态 | 已完成最小实现 | 基于 P15 `installedState` 展示 appId、version、source、readiness、setup 和 permission 摘要。 | +| Entry launcher | 已完成最小实现 | Manager launch 继续调用 P14 guard;page / panel / settings 走 UI Host,其他 entry 走 CapabilityHost。 | +| Disable / enable | 已完成最小实现 | Lab 内状态禁用后 entry launcher 不可启动,不删除用户数据。 | +| P11 repository list | 已完成最小实现 | Manager 通过 `LocalInstalledAgentAppStateRepository` / browser localStorage driver 保存、恢复并展示 repository list / issue count。 | +| 持久化 disable / enable | 已完成最小实现 | disable / enable 通过 P11 repository `setDisabled()` 更新 installed state,不新增第二套 store。 | +| Uninstall preview | 已完成最小实现 | keep-data / delete-data 复用 P15 uninstall preview,并生成 evidence summary。 | +| GUI smoke | 已通过 | `smoke:agent-app-lab` 断言 `managerVisible`、`managerRepository` 和 `managerEvidence`。 | +| P16-H 计划 | 已完成计划收口 | 详见 [p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)。 | + +## 非目标 + +1. 不做公开 marketplace、审核流、支付、企业分发控制台。 +2. 不接 LimeCore Cloud 管理台,不把 Cloud 变成 runtime。 +3. 不把 App entry 接进正式主导航、命令面板、Chat 主路径或 Artifact 主 schema。 +4. 不执行 raw worker bundle、任意 package JS、native binary 或 npm install。 +5. 不新增 Tauri command,不让 Agent App 直接 `safeInvoke` / `invoke`。 + +## 架构图 + +```mermaid +flowchart TD + Lab[Agent App Lab] --> Manager[Agent App Manager Shell] + Manager --> Installed[Installed App Repository] + Manager --> Source[Package Source / Cache] + Manager --> Readiness[Projection + Readiness + Setup] + Manager --> Launcher[Entry Launcher] + Launcher --> Guard[P14 Entry Runtime Guard] + Guard --> UIHost[P3 UI Host] + Guard --> Workflow[P4 Workflow Runtime] + Manager --> Lifecycle[Lifecycle Actions] + Lifecycle --> Disable[Disable App] + Lifecycle --> Keep[Uninstall Keep Data] + Lifecycle --> Delete[Uninstall Delete Data] + Lifecycle --> Cleanup[Cleanup Preview / Rehearsal] + Cleanup --> Evidence[Evidence Summary] +``` + +关键点: + +- `Manager Shell` 仍在 Agent App 实验岛内,不是正式产品入口。 +- `Entry Launcher` 只编排 entry,不拥有自己的 guard 规则。 +- `Lifecycle Actions` 复用 P10-P15 的 installed state、package cache、setup state 和 cleanup plan。 +- `Evidence Summary` 只记录 Agent App 相关对象,不包含 secret value、客户原文或非 App 数据。 + +## 状态模型 + +```text +discovered +→ installed +→ setup-required +→ launchable +→ running +→ disabled +→ uninstall-preview +→ uninstalled +``` + +阻断状态: + +```text +package-invalid +hash-mismatch +cloud-disabled +permission-denied +runtime-blocked +cleanup-required +``` + +## 时序图:从 Manager 打开 App Entry + +```mermaid +sequenceDiagram + participant User as 用户 + participant Manager as Agent App Manager + participant Repo as Installed Repository + participant Guard as P14 Guard + participant Runtime as UI / Workflow Runtime + participant Evidence as Evidence Summary + + User->>Manager: 选择 App entry + Manager->>Repo: 读取 installed state / setup state + Repo-->>Manager: app status + package identity + Manager->>Guard: evaluate entry launch + Guard-->>Manager: allow / needs-setup / blocked / denied + alt allow + Manager->>Runtime: mount / run entry + Runtime-->>Manager: runtime result + Manager->>Evidence: record launch summary + else blocked + Manager-->>User: 展示阻断原因和可操作下一步 + end +``` + +## 流程图:卸载与清理演练 + +```mermaid +flowchart TD + Start[用户点击卸载] --> Preview[生成 Cleanup Preview] + Preview --> Choice{选择策略} + Choice --> Keep[Uninstall keep data] + Choice --> Delete[Uninstall delete data] + Choice --> Cancel[取消] + Keep --> Evidence[写入保留数据 evidence] + Delete --> Guard[确认只删除 Agent App namespace] + Guard --> Evidence + Evidence --> Verify[残留检查 / summary] + Verify --> Done[Manager 状态刷新] +``` + +## 用户故事 + +| 编号 | 用户故事 | 验收标准 | +|---|---|---| +| US-P16-01 | 作为用户,我可以看到已安装 App、版本、来源、hash 和 readiness。 | Manager 列表展示 installed state,不展示客户数据。 | +| US-P16-02 | 作为用户,我可以从同一个管理面打开 App entry。 | 所有 launch / mount 都先通过 P14 guard。 | +| US-P16-03 | 作为用户,我可以禁用 App 但保留数据。 | disabled 后 entry 不可启动,storage / artifacts 不删除。 | +| US-P16-04 | 作为用户,我可以卸载 App 并选择保留或删除数据。 | keep-data / delete-data preview 与 P15 cleanup 口径一致。 | +| US-P16-05 | 作为维护者,我可以复核每次 cleanup rehearsal 的证据。 | evidence summary 包含 appId、version、packageHash、targets 和执行结果。 | + +## 分期计划 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P16.0 | 已完成:定义正式入口前 gate 和 Manager shell 数据契约。 | 未注册正式主导航。 | +| P16.1 | 已完成最小实现:已安装 App 列表、状态徽标、版本 / source / hash 展示。 | 未做 marketplace。 | +| P16.2 | 已完成:Entry launcher 统一复用 P14 guard。 | 未让 UI 直接调用 runtime。 | +| P16.3 | 已完成最小实现:Enable / disable / uninstall keep-data / delete-data 生命周期动作。 | 未删除非 Agent App 数据。 | +| P16.4 | 已完成最小实现:Cleanup rehearsal evidence summary。 | 未记录 secret value 或客户原文。 | +| P16.5 | 已完成:flag-off、boundary regression 与 Agent App Lab GUI smoke。 | 未扩大到 Cloud 管理台。 | + +## 文件边界 + +| 文件 / 目录 | 计划 | +|---|---| +| `src/features/agent-app/ui/AgentAppLabPage.tsx` | 可以继续承载实验岛入口,但应把 Manager shell 拆成局部组件,避免单文件继续膨胀。 | +| `src/features/agent-app/ui/*` | 新增 Manager list / status / lifecycle panels,仍只在 Lab feature flag 下渲染。 | +| `src/features/agent-app/install/*` | 复用 installed state、package cache、setup state、cleanup plan,不新增第二套 repository。 | +| `src/features/agent-app/runtime/entryRuntimeGuard.ts` | 继续作为所有 entry launch 的唯一 guard。 | +| `scripts/agent-app-lab-smoke.mjs` | 扩展覆盖 Manager list、entry launcher、disable / uninstall preview。 | +| `docs/roadmap/agentapp/*` | 更新 P16 验证证据和进入正式入口的 gate 判定。 | + +## 验收标准 + +1. Manager 能展示至少一个 installed app 的 identity、version、source、readiness、setup 和 enabled 状态。 +2. 所有 entry launch / mount 都复用 P14 guard,不能直接调用 UI Host / CapabilityHost。 +3. Disable 后 entry 不可启动,且不会删除用户数据。 +4. Uninstall keep-data / delete-data preview 能解释将删除 / 保留的目标。 +5. Cleanup rehearsal evidence 能关联 `appId / appVersion / packageHash / manifestHash`。 +6. 关闭 Agent App Lab flags 后 Manager 不出现在正式主路径。 +7. `src/features/agent-app` 仍无 `safeInvoke` / `invoke` / Tauri command / raw Worker 越界入口。 + +## 最小验证 + +```bash +npm run test -- \ + src/features/agent-app/install/labInstallFlow.test.ts \ + src/features/agent-app/runtime/entryRuntimeGuard.test.ts \ + src/features/agent-app/ui/AgentAppLabPage.test.tsx + +npm run smoke:agent-app-lab -- --timeout-ms 180000 +npm run typecheck +npm run test:contracts + +git diff --check -- docs/roadmap/agentapp src/features/agent-app scripts/agent-app-lab-smoke.mjs package.json + +rg -n "safeInvoke|invoke\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\(" src/features/agent-app || true +``` + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/ui/AgentAppLabPage.test.tsx src/features/agent-app/install/labInstallFlow.test.ts src/features/agent-app/runtime/entryRuntimeGuard.test.ts src/features/agent-app/featureFlag.test.ts` | 通过,4 files / 25 tests。 | +| `npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts` | 通过,3 files / 17 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run smoke:agent-app-lab -- --timeout-ms 180000` | 通过;summary 增加 `managerVisible`、`managerRepository` 与 `managerEvidence`。 | +| `npm run verify:gui-smoke` | 通过;覆盖 workspace-ready、browser-runtime、site-adapters、Agent runtime tool surface、Claw Chat ready streaming、Knowledge GUI 与 Design Canvas。 | +| `npm run test:contracts` | 通过。 | +| `git diff --check -- docs/roadmap/agentapp src/features/agent-app scripts/agent-app-lab-smoke.mjs package.json src/i18n/resources src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx` | 通过。 | +| `rg` boundary / legacy audit | 通过,`src/features/agent-app` 无 `safeInvoke` / Tauri / raw Worker 越界,未复活旧内容工程化 key。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| Manager 当前只管理 Lab fixture 形成的单个 installed app。 | P16-H 再接多 package source / 多 App lifecycle,不用 fake marketplace。 | +| Repository list 已接 P11 local adapter,但还没有多 App source。 | P16-H 后续补多 App fixture / local package source 列表,不新增第二套 store。 | +| Uninstall 仍是 preview / rehearsal,没有执行真实 delete-data。 | P16-H 先做 rehearsal evidence 与残留检查,再评估真实删除。 | +| Manager 尚未进入正式主路径。 | 当前正确状态;正式入口仍需 P16-H 后再次评估。 | + +## 进入正式入口前 Gate + +P16 最小实现完成后仍不自动进入正式产品入口。只有同时满足以下条件,才评估 P17 / 正式 Agent Apps 入口: + +1. Agent App Manager 的生命周期动作和 cleanup rehearsal 都有 GUI smoke 证据。 +2. App entry launcher 与 P14 guard 的耦合可通过测试证明,不存在旁路启动。 +3. App 数据导出、保留、删除策略已覆盖异常和 rollback。 +4. flag-off 后 UI、storage、runtime 和 evidence 不影响普通 Chat / Skill / Artifact / Workspace 主流程。 +5. Cloud / LimeCore 仍只提供 catalog / release / tenant metadata,不运行默认 Agent、不渲染 UI、不接管本地 storage。 + +## 下一刀 + +P16 已完成实验岛内最小 Agent App Manager,并已把 list / disable / enable 接到 P11 local persistence adapter。P16-H.1 repository-backed multi-app list、P16-H.2 selected app launcher + persisted lifecycle、P16-H.3 cleanup rehearsal evidence export、P16-H.4 residual audit、P16-H.5 Agent App Lab GUI smoke + flag-off regression、P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate 已完成;P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。仍不做 marketplace、Cloud 管理台、真实 delete-data 或完整内容工厂业务系统。 diff --git a/docs/roadmap/agentapp/p16-h-multi-app-repository-lifecycle-hardening.md b/docs/roadmap/agentapp/p16-h-multi-app-repository-lifecycle-hardening.md new file mode 100644 index 000000000..0389cfc6a --- /dev/null +++ b/docs/roadmap/agentapp/p16-h-multi-app-repository-lifecycle-hardening.md @@ -0,0 +1,332 @@ +# Agent App P16-H Multi-app Repository / Lifecycle Hardening + +更新时间:2026-05-15 + +## 一句话目标 + +P16-H 的目标不是把 Agent App 直接发布到正式主入口,而是在实验岛内把 P16 单 App Manager 升级为可管理多个 installed app 的可靠生命周期面:同一套 P11 repository、P14 guard、P15 cleanup preview 和 GUI smoke 证据必须同时覆盖多 App list、持久化 enable / disable、uninstall rehearsal、cleanup evidence export 与 residual audit。 + +## 背景 + +P16 已证明最小 Agent App Manager 可以展示一个 Lab fixture 形成的 installed app,并通过 P14 guard 打开 entry、执行 disable / enable、生成 keep-data / delete-data preview。但这个状态仍不够支撑正式 Agent Apps 入口: + +1. 单 fixture 不能证明 Manager 是平台能力,只能证明 demo 可用。 +2. disable / enable 如果只停留在 UI state,失败后无法恢复真实生命周期。 +3. uninstall preview 如果没有可导出 evidence 和 residual audit,失败后无法清理。 +4. 多 App 场景下如果绕过 P11 repository,很容易长出第二套 install store。 +5. 如果此时接主导航或 marketplace,会把实验岛风险带进主产品。 + +因此 P16-H 先补“平台最小可靠性”,不补“生态规模化”。 + +## 当前落地 + +| 项 | 状态 | 证据 | +|---|---|---| +| P16-H.0 计划收口 | 已完成 | README、implementation plan、P16 文档与本文已指向同一 P16-H 主链。 | +| Repository-backed multi-app list | 已完成最小实现 | `AgentAppManagerPanel` 从 repository states 渲染 App list,`AgentAppLabPage` 通过 P11 repository seed 当前 App 与本地 companion fixture。 | +| Selected app launcher | 已完成最小实现 | 选中 App 后 identity、source、readiness、entry launcher、P14 guard、UI Host 和 lifecycle action 使用 selected state。 | +| 持久化 lifecycle | 已完成最小实现 | disable / enable 已按 selected appId 调用 P11 repository,并通过组件 / Lab 测试覆盖 remount 恢复和 disabled blocker。 | +| Cleanup evidence export | 已完成最小实现 | `cleanupRehearsalEvidence` 生成 selected app JSON summary,Manager / Lab / smoke 覆盖 selected appId、strategy、targets、blockedTargets。 | +| Residual audit | 已完成最小实现 | `cleanupResidualAudit` 消费 selected app cleanup evidence 与 repository issues,Manager / Lab / smoke 覆盖 pending deletion、out-of-scope blocker 和 repository issue summary。 | +| GUI smoke + flag-off regression | 已完成最小实现 | `smoke:agent-app-lab` 已覆盖 multi-app、selected app、disable blocker、cleanup evidence、residual audit 和 flag-off Lab 隔离。 | +| P17 gate audit | 已完成 | 详见 [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md):对照正式入口前 Gate 映射 P16-H 证据,结论是可进入正式入口设计,但不能发布 marketplace / Cloud 管理台 / 真实 delete-data。 | +| P17.0 Formal Entry Contract | 已完成计划收口 | 详见 [p17-formal-entry-contract.md](./p17-formal-entry-contract.md):固定 `agent-apps`、`agent-app-lab` 与 runtime surface 的职责边界;P17.1、P17.2.1、P17.2.2、P17.2.3、P17.2.4a、P17.2.4b-1、P17.2.4b-2 与 P17.2.5 已完成,下一刀进入 [P17.3 lifecycle / cleanup contract hardening](./p17-source-install-contract-hardening.md)。 | + +## 目的与收益 + +| 目的 | 收益 | +|---|---| +| 让 Manager 从单 App demo 变成多 App repository view。 | 后续正式入口可以复用同一数据事实源,不必重写 App 列表。 | +| 生命周期动作全部持久化。 | 重启、失败恢复、flag-off 回归都有一致状态。 | +| cleanup rehearsal 产出可复核 evidence。 | 未来真实 delete-data 前可以先证明不会误删非 Agent App 数据。 | +| residual audit 明确“还剩什么”。 | 失败后可以快速定位 cache、storage、artifact、evidence、setup state 的残留。 | +| 保持 Lab-only。 | 产品可继续探索,不污染 Chat、Skill、Artifact、Workspace 主路径。 | + +## 当前输入事实源 + +| 分类 | 对象 | 说明 | +|---|---|---| +| current | `src/features/agent-app/install/installedAppState.ts` | P10 / P11 installed state、repository 与 persistence adapter 事实源。 | +| current | `src/features/agent-app/ui/AgentAppManagerPanel.tsx` | P16 Manager shell current 入口。 | +| current | `src/features/agent-app/runtime/entryRuntimeGuard.ts` | 所有 entry launch / mount 的唯一运行前 gate。 | +| current | `src/features/agent-app/install/labInstallFlow.ts` | Lab-only install / launch / cleanup preview 编排。 | +| current | `scripts/agent-app-lab-smoke.mjs` | Agent App Lab 专用 GUI smoke 与证据输出。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | Cloud / LimeCore 只提供 catalog、release、tenant metadata,不运行 Agent App。 | +| dead | 旧 `SceneAppsPage`、`sceneapp_*` 命令、`contentEngineering*` 旧命名 | 不作为 P16-H 兼容目标,不复活。 | + +## 非目标 + +1. 不做 marketplace、审核流、支付、企业控制台或 Cloud 管理台。 +2. 不把 Agent App 接入正式主导航、命令面板、Chat expert 入口或 Artifact 主 schema。 +3. 不新增 Tauri command,不让 Agent App 直接 `safeInvoke` / `invoke`。 +4. 不执行 raw worker bundle、任意 package JS、native binary、npm install 或外部网络调用。 +5. 不做完整 内容工厂 SaaS,不进入真实平台抓取、批量生产、质量评分和外部数据复盘。 +6. 不实现真实 delete-data;本阶段只做 rehearsal evidence 与 residual audit,真实删除需单独 gate。 + +## 架构图 + +```mermaid +flowchart TD + Lab[Agent App Lab] --> Manager[Agent App Manager] + Manager --> Repo[P11 Installed Repository] + Repo --> Persist[Browser / Local Persistence Driver] + Manager --> Source[Package Source List] + Source --> Preview[P15 Install Preview] + Preview --> Repo + + Manager --> Selected[Selected Installed App] + Selected --> Launcher[Entry Launcher] + Launcher --> Guard[P14 Entry Runtime Guard] + Guard --> Runtime[UI Host / Workflow Runtime / CapabilityHost] + + Selected --> Lifecycle[Lifecycle Actions] + Lifecycle --> Disable[Persist disable / enable] + Lifecycle --> Uninstall[Uninstall Rehearsal] + Uninstall --> Cleanup[P15 Cleanup Preview] + Cleanup --> Evidence[Cleanup Evidence Export] + Evidence --> Audit[Residual Audit] + + Audit --> Manager +``` + +关键约束: + +- `Repo` 是多 App installed state 的唯一事实源;React state 只能作为展示缓存。 +- `Launcher` 只负责选 entry 和显示结果;guard 规则仍由 P14 统一决定。 +- `Cleanup Evidence Export` 只导出 summary,不导出 secret value、客户原文或非 App 数据。 +- `Residual Audit` 只检查 Agent App namespace,不扫描或删除 Lime 主业务数据。 + +## 时序图:多 App 启动恢复 + +```mermaid +sequenceDiagram + participant UI as Agent App Lab + participant Manager as Agent App Manager + participant Repo as P11 Repository + participant Source as Package Source List + participant Guard as P14 Guard + + UI->>Manager: mount + Manager->>Repo: listInstalledApps() + Repo-->>Manager: installed states + restore issues + Manager->>Source: list available package sources + Source-->>Manager: local fixture / cloud bootstrap metadata + Manager-->>UI: render app list + issue count + UI->>Manager: select app + launch entry + Manager->>Repo: get latest installed state + Manager->>Guard: evaluate(app, entry, setup, package) + Guard-->>Manager: allow / needs-setup / blocked / denied + Manager-->>UI: mount / run or show blocker +``` + +## 流程图:卸载演练与残留检查 + +```mermaid +flowchart TD + Start[选择 App] --> Preview[生成 cleanup preview] + Preview --> Strategy{用户选择} + Strategy --> Keep[keep-data rehearsal] + Strategy --> Delete[delete-data rehearsal] + Strategy --> Cancel[取消] + Keep --> Evidence[导出 keep-data evidence] + Delete --> NamespaceGuard{仅包含 Agent App namespace?} + NamespaceGuard -- 否 --> Block[阻断并显示越界 target] + NamespaceGuard -- 是 --> Evidence + Evidence --> Audit[residual audit] + Audit --> Report[展示 cache / storage / artifact / evidence / setup 残留] + Report --> Refresh[刷新 repository list] +``` + +## 用户故事 + +| 编号 | 用户故事 | 验收标准 | +|---|---|---| +| US-P16H-01 | 作为用户,我可以看到多个已安装 App 及其状态。 | Manager list 从 P11 repository 读取,展示 appId、version、source、readiness、enabled 和 issue count。 | +| US-P16H-02 | 作为用户,我可以选择某个 App 再打开它的 entry。 | 当前选中 App 的 entry launcher 复用 P14 guard;disabled / blocked App 不可旁路启动。 | +| US-P16H-03 | 作为用户,我可以禁用 / 启用某个 App,并在重启后保持状态。 | disable / enable 调用 repository lifecycle API,不只改 React state。 | +| US-P16H-04 | 作为维护者,我可以导出 cleanup rehearsal evidence。 | summary 包含 appId、version、packageHash、manifestHash、strategy、targets、blockedTargets、timestamp。 | +| US-P16H-05 | 作为维护者,我可以看到残留检查结果。 | residual audit 区分 expected retained、pending deletion、blocked out-of-scope 和 repository issue。 | + +## 典型用例 + +### 用例 1:多 App 列表恢复 + +1. Lab 启动后从 P11 repository 读取 installed states。 +2. Manager 展示 内容工厂和一个本地测试 App source 的状态。 +3. 如果某个 state 损坏,坏记录进入 issue count,不影响其他 App 展示。 +4. 用户选择 App 后,右侧展示 entries、readiness、setup、permissions 和 lifecycle actions。 + +### 用例 2:禁用后阻断启动 + +1. 用户点击某 App 的 disable。 +2. Manager 调用 repository lifecycle API 持久化 disabled 状态。 +3. entry launcher 重新评估 P14 guard。 +4. 再次点击 launch 时展示 disabled blocker,不调用 UI Host 或 CapabilityHost。 + +### 用例 3:卸载演练和 evidence 导出 + +1. 用户选择 uninstall delete-data preview。 +2. Cleanup preview 列出 storage、artifact、evidence、setup state、package cache target。 +3. Namespace guard 过滤并阻断非 Agent App target。 +4. Manager 展示可导出 JSON summary 与 residual audit。 +5. 状态刷新后仍保持 rehearsal,不执行真实删除。 + +## 分期计划 + +| 阶段 | 目标 | 不做什么 | +|---|---|---| +| P16-H.0 | 已完成:新增本计划并收敛 README / implementation plan / P16 文档索引。 | 不改 runtime 行为。 | +| P16-H.1 | 已完成最小实现:Manager list 从 P11 repository 渲染多个 installed app,并支持选中 App。 | 不做 marketplace 搜索或远程分页。 | +| P16-H.2 | 已完成最小实现:生命周期动作全部走 repository:enable / disable / launch state refresh。 | 不新增第二套 lifecycle store。 | +| P16-H.3 | 已完成最小实现:cleanup rehearsal evidence export:生成 summary、blocked target、timestamp。 | 不保存 secret value,不执行真实 delete-data。 | +| P16-H.4 | 已完成最小实现:residual audit:检查 Agent App namespace 内 cache / storage / artifact / evidence / setup 残留。 | 不扫描 Lime 主业务目录。 | +| P16-H.5 | 已完成最小实现:GUI smoke 与 flag-off 回归:覆盖多 App list、选中、disable、cleanup evidence、residual audit 和关闭实验岛后的 Lab 隔离。 | 不接正式主导航。 | +| P17 Gate | 已完成:正式入口前完成度审计。 | P17.0 已完成契约收口,P17.1 已完成正式入口 route / nav / copy 最小硬化;P17.2 前仍不新增 marketplace、真实 delete-data 或 Cloud 管理台。 | + +### P16-H.3 实施拆解 + +P16-H.3 只把卸载演练从“UI preview”升级为“可复核 evidence summary”,不执行真实删除,也不新增任何持久化删除入口: + +| 切片 | 实施内容 | 验收 | +|---|---|---| +| Evidence builder | 在 `install/cleanupPlan.ts` 或独立 `install/cleanupRehearsalEvidence.ts` 增加纯函数,输入 selected installed state、cleanup plan、strategy、generatedAt。 | 输出只含 appId、appVersion、packageHash、manifestHash、strategy、targets、blockedTargets、generatedAt 与 counts。 | +| Target 分类 | 将 package cache、installed state、setup state、storage namespace、artifact / evidence / task refs、log / export path 按 App namespace 分类。 | `delete-data` 只把 Agent App namespace 内 target 标为可删除;越界或不可判定 target 进入 blockedTargets。 | +| Secret / 原文保护 | `secretRefs` 只保留 ref id / capability key / reason,不记录 secret value;artifact / evidence 只保留 ref 元数据,不记录客户原文。 | JSON summary 中不能出现 secret value、客户原文、外部业务数据。 | +| Manager 展示 | `AgentAppManagerPanel` 展示 selected app 的 latest rehearsal summary,并提供 Lab-only JSON 预览。 | 选择 companion app 后 summary appId 跟随 selected app,不回落到默认 fixture。 | +| Lab 编排 | `AgentAppLabPage` 的 keep-data / delete-data preview 复用 P15 cleanup preview 与 selected state。 | 不新增 store,不直接调用 Tauri,不绕过 P11 repository。 | +| 专用验证 | 扩展组件测试、Lab 测试和 `smoke:agent-app-lab` summary。 | smoke summary 至少覆盖 cleanup evidence JSON、strategy、selected appId、blocked target count。 | + +P16-H.3 已按最小实现完成;P16-H.4 已继续把 evidence summary 推进到 residual audit。后续仍不要在 P16-H 内实现真实 `delete-data`、全盘扫描、正式下载文件或 Cloud 回写。 + +### P16-H.4 实施拆解 + +P16-H.4 只把 cleanup rehearsal evidence 转换为可复核 residual audit,不扫描真实文件系统,也不执行真实删除: + +| 切片 | 实施内容 | 验收 | +|---|---|---| +| Audit builder | 新增 `install/cleanupResidualAudit.ts` 纯函数,输入 selected installed state、cleanup evidence、repository issues。 | 输出 `retainedTargets`、`pendingDeletionTargets`、`blockedOutOfScopeTargets`、`repositoryIssues` 四类 summary 与 count。 | +| Namespace 保守边界 | 只消费 P16-H.3 evidence 中已分类的 target,不新增全盘扫描器。 | `delete-data` 只产生 pending deletion;`keep-data` 区分 retained 与 pending deletion。 | +| Repository issue 映射 | 只保留当前 appId 或无 appId 的 repository issue。 | 不把其他 App 的损坏记录泄漏到当前 App audit。 | +| Manager 展示 | `AgentAppManagerPanel` 在 cleanup JSON 下展示 residual audit summary。 | UI 显示 retained、pending deletion、blocked out-of-scope、repository issue 四类计数。 | +| Lab 编排 | `AgentAppLabPage` 在 keep-data / delete-data preview 后基于 selected app 生成 audit。 | companion app delete-data preview 后 audit 与 selected app 对齐。 | +| 专用验证 | 扩展纯函数测试、组件测试、Lab 测试和 `smoke:agent-app-lab` summary。 | smoke summary 覆盖 `residualAuditVisible`、`residualAuditPending` 与 selected app cleanup evidence。 | + +## 文件边界 + +| 文件 / 目录 | 计划 | +|---|---| +| `src/features/agent-app/ui/AgentAppManagerPanel.tsx` | 拆出或扩展 list / selected app / lifecycle / evidence 子面板,避免把多 App 状态继续堆在 Lab page。 | +| `src/features/agent-app/ui/AgentAppManagerPanel.test.tsx` | 新增组件级测试,覆盖多 App list、选择、disabled blocker、evidence summary。 | +| `src/features/agent-app/ui/AgentAppLabPage.tsx` | 只负责编排实验岛与 repository driver,不承载多 App 业务规则。 | +| `src/features/agent-app/install/installedAppState.ts` | 复用 P11 repository;如需新增方法,只添加通用 lifecycle API,不添加 UI 专用 store。 | +| `src/features/agent-app/install/cleanupRehearsalEvidence.ts` / `cleanupResidualAudit.ts` | 补 rehearsal evidence 与 residual audit 的纯函数,保持无 Tauri command。 | +| `scripts/agent-app-lab-smoke.mjs` | 已扩展断言多 App list、selected app、disable blocker、cleanup evidence export、residual audit 与 flag-off。 | +| `src/i18n/resources/*/agent.json` | 新增用户可见文案时必须覆盖五语言。 | +| `docs/roadmap/agentapp/*` | 记录 P16-H 验证证据和正式入口 gate 结论。 | + +## 验收标准 + +1. Manager 能展示至少两个 installed app 或一个 installed app 加一个 repository issue,不依赖 hard-coded 单 fixture UI。 +2. 用户选择 App 后,entry launcher、readiness、setup、permission、lifecycle action 都绑定当前 selected app。 +3. Disable / enable 通过 P11 repository 持久化;刷新或 remount 后状态不丢。 +4. Disabled / blocked entry 无法绕过 P14 guard 启动。 +5. Uninstall keep-data / delete-data rehearsal 生成可导出 evidence summary。 +6. residual audit 只报告 Agent App namespace 内对象,并明确 out-of-scope target。 +7. `src/features/agent-app` 仍无 `safeInvoke` / `invoke` / Tauri command / raw Worker 越界入口。 +8. flag-off 后 Manager 不出现在正式主路径,普通 Chat / Skill / Artifact / Workspace 不受影响。 + +## 最小验证 + +```bash +npm run test -- \ + src/features/agent-app/ui/AgentAppManagerPanel.test.tsx \ + src/features/agent-app/ui/AgentAppLabPage.test.tsx \ + src/features/agent-app/install/cleanupRehearsalEvidence.test.ts \ + src/features/agent-app/install/cleanupResidualAudit.test.ts \ + src/features/agent-app/install/installedAppState.test.ts + +npm run test -- \ + src/i18n/__tests__/translation-coverage.test.ts \ + src/i18n/__tests__/loadNamespace.test.ts \ + src/i18n/__tests__/types.test.ts + +npm run typecheck +npm run smoke:agent-app-lab -- --timeout-ms 180000 +npm run test:contracts + +git diff --check -- docs/roadmap/agentapp src/features/agent-app scripts/agent-app-lab-smoke.mjs package.json src/i18n/resources + +rg -n "safeInvoke|invoke\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\(" src/features/agent-app || true +rg -n "contentEngineering|ContentEngineering|content_engineering|scene_exhaustion|shenlan-content-engineering|AI 内容工程化|content-engineering-app" src/features/agent-app src/i18n/resources || true +``` + +## 验证记录 + +更新时间:2026-05-15 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/cleanupRehearsalEvidence.test.ts src/features/agent-app/install/cleanupResidualAudit.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/ui/AgentAppManagerPanel.test.tsx src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,5 files / 26 tests;覆盖 P16-H.3 evidence builder、P16-H.4 residual audit、secret redaction、blocked target、Manager JSON 和 selected companion App delete-data preview。 | +| `npm run test -- src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/features/agent-app/ui/AgentAppsPage.test.tsx src/lib/navigation/sidebarNav.test.ts` | 通过,3 files / 10 tests;覆盖正式 Agent Apps 入口、runtime surface、Cloud registration、installed App lifecycle 和 Lab flag sidebar gate。 | +| `npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts` | 通过,3 files / 17 tests;覆盖新增 Manager evidence / residual audit / Agent Apps runtime 文案五语言资源。 | +| `npm run smoke:agent-app-lab -- --timeout-ms 180000` | 通过;DevBridge 88658ms ready,summary 包含 `managerMultiApp`、`managerSelectedApp`、`managerDisableBlocked`、`managerEnableAvailable`、`managerReenabled`、`selectedRuntimeApp`、`cleanupEvidenceJson`、`residualAuditVisible`、`residualAuditPending`、`flagOffLabNavHidden`、`flagOffLabPageHidden`、`flagOffAgentAppsNavVisible`、`flagOffNoConsoleErrors`。 | +| `npm run typecheck` | 通过;`tsc --noEmit` 已覆盖 Agent Apps runtime page、display helper 和新增 i18n key 类型。 | +| `node --check scripts/agent-app-lab-smoke.mjs` | 通过;P16-H.5 smoke 脚本语法有效。 | +| `npm run test:contracts` | 通过;frontend commands 399、Rust registered commands 555、mock priority commands 48、default mock commands 379,命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。 | +| `git diff --check -- docs/roadmap/agentapp src/features/agent-app scripts/agent-app-lab-smoke.mjs package.json src/i18n/resources` | 通过。 | +| `rg` boundary / legacy audit | 通过,`src/features/agent-app` 无 `safeInvoke` / Tauri / raw Worker 越界,未复活旧内容工程化 key。 | + +如果 P16-H 触及 GUI 主路径或正式入口候选,再补: + +```bash +npm run verify:gui-smoke +npm run verify:local +``` + +## 进入 P17 / 正式入口前 Gate + +P16-H 完成后也不自动发布正式入口。P17 Gate 审计结论详见 [p17-formal-entry-gate-audit.md](./p17-formal-entry-gate-audit.md);只有同时满足以下条件,才进入 P17 正式 Agent Apps 入口设计: + +1. 多 App repository、生命周期动作、cleanup rehearsal、residual audit 都有组件测试和 GUI smoke 证据。 +2. Manager 没有第二套 store;所有 installed state 都能追溯到 P11 repository。 +3. App entry launcher 与 P14 guard 的耦合可测试证明,不存在直接调用 runtime 的旁路。 +4. flag-off 后 Agent App Lab、storage、runtime、evidence 不影响普通主路径。 +5. Cloud / LimeCore 仍只提供 catalog / release / tenant metadata,不运行 Agent、不渲染 UI、不接管本地 storage。 +6. 失败退出方案可执行:删除实验岛、清理 Agent App namespace,不影响非 Agent App 数据。 + +## Current / Compat / Deprecated / Dead 分类 + +| 分类 | 对象 | P16-H 处理 | +|---|---|---| +| current | `src/features/agent-app/**` | 唯一可继续演进的客户端 Agent App 实验岛。 | +| current | P11 repository、P14 guard、P15 cleanup preview、P16 Manager | P16-H 必须复用这些事实源,不新增平行实现。 | +| compat | 无新增 | 如果出现兼容层,只允许委托到 current,并必须写退出条件。 | +| deprecated | 旧 SceneApp 只读执行摘要 / 历史回看 | 不新增业务能力,不作为 Agent App 启动入口。 | +| dead | `sceneapp_*` command、旧 SceneApps 页面、旧 `contentEngineering*` / `shenlan-content-engineering` | 不复活,不补包装层,不作为测试 fixture。 | + +## 下一刀 + +P16-H 的实现顺序已经完成最小闭环,P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening、P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。不能因为 smoke 已通过就直接发布 marketplace、Cloud 管理台或真实 delete-data: + +```text +P16-H.1 repository-backed multi-app list(已完成最小实现) +→ P16-H.2 selected app launcher + persisted lifecycle(已完成最小实现) +→ P16-H.3 cleanup rehearsal evidence export(已完成最小实现) +→ P16-H.4 residual audit(已完成最小实现) +→ P16-H.5 Agent App Lab GUI smoke + flag-off regression(已完成最小实现) +→ P17 正式入口前 Gate 审计(已完成) +→ P17.0 Formal Entry Contract(已完成计划收口) +→ P17.1 Formal route / nav / copy hardening(已完成最小实现) +→ P17.2.1 Source state model(已完成) +→ P17.2.2 Install review descriptor(已完成) +→ P17.2.3 Registration hardening(已完成最小实现) +→ P17.2.4a Cloud release descriptor / verification gate(已完成) +→ P17.2.4b-1 acquisition seam / verified cache source(已完成) +→ P17.2.4b-2 packageUrl fetch / staging(已完成) +→ P17.2.5 Schema / reference CLI / example package cross-check(已完成) +→ P17.3 Lifecycle / cleanup contract hardening(已完成) +→ P17.4 Runtime surface production hardening(已完成,含 P17.4.5 GUI smoke) +→ P17.5 Formal entry GUI smoke(下一刀) +``` + +完成 P17.3 lifecycle / cleanup contract hardening 前,不讨论 marketplace、Workspace pin、命令面板入口、Chat expert entry、真实 delete-data 或 Cloud 管理台。 diff --git a/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md b/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md new file mode 100644 index 000000000..83fe5842a --- /dev/null +++ b/docs/roadmap/agentapp/p17-4-host-bridge-runtime.md @@ -0,0 +1,388 @@ +# Agent App P17.4-H Host Bridge Runtime + +更新时间:2026-05-16 + +## 一句话目标 + +在正式 `agent-apps` runtime surface 中建立标准 Host Bridge:App UI 运行在受控 iframe / sandbox 内,通过 `lime.agentApp.bridge` 与 Lime Host 同步主题、语言、入口上下文、导航、通知、下载和 capability 请求;App 不再拥有私有 `postMessage` 协议,也不能直接访问 Tauri / Node / 宿主 DOM。 + +P17.4 的产品验收不止“App UI 能打开”。它必须证明用户可在 App 业务界面内启动、观察、取消、重试、确认 Agent task,并把结构化结果写回 App storage / Artifact / Evidence;通用 Chat 不能成为核心流程的强制回跳点。 + +## 标准来源 + +上游事实源是 `/Users/coso/Documents/dev/ai/limecloud/agentapp` 的 Agent App v0.3 Host Bridge v1 文档。Lime 客户端只做标准实现,不重新定义协议。 + +## 范围 + +| 范围 | 做 | 不做 | +| --------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Bridge lifecycle | iframe load 后推 `host:snapshot`,App `app:ready` 后可重拉。 | 不让 App 读取外层 DOM。 | +| Theme sync | 订阅 Lime theme / color scheme / system mode,广播 `theme:update`。 | 不让 App 自己猜 Lime 主题。 | +| Host actions | 支持 `host:toast`、`host:navigate`、`host:openExternal`、`host:download` 的受控请求。 | 不开放 raw Tauri / Node API。 | +| Capability invoke | 用 `capability:invoke` 承载 SDK 请求,并继续走 readiness / permission / policy。 | 不用 mock 结果伪装真实能力成功。 | +| Agent task | 通过 `lime.agent.startTask` / `lime.workflow.start` 在 App 内运行任务、流式事件和结构化结果。 | 不跳转通用 Chat 完成核心工作,不让 App 自建模型网关。 | +| Structured write-back | App 确认后通过 `lime.storage` / `lime.artifacts` / `lime.evidence` 写回业务对象和证据。 | 不让聊天文本成为唯一结果,不要求用户手工复制。 | +| Security | 校验 `event.source`、`event.origin`、`protocol`、`version`、`appId`、`entryKey`。 | 不接收任意 window 消息。 | + +## 消息信封 + +```ts +interface LimeAgentAppBridgeMessage { + protocol: "lime.agentApp.bridge"; + version: 1; + type: string; + requestId?: string; + appId: string; + entryKey?: string; + payload?: unknown; +} +``` + +## 首批事件 + +Host -> App: + +- `host:snapshot` +- `theme:update` +- `host:response` +- `host:error` +- `host:visibility` + +App -> Host: + +- `app:ready` +- `host:getSnapshot` +- `host:navigate` +- `host:toast` +- `host:openExternal` +- `host:download` +- `capability:invoke` + +Agent task 事件通过 `capability:invoke` 的标准请求 / 响应承载,不新增私有协议。`lime.agent.startTask` 返回 `taskId` / `traceId` 后,后续 stream 至少要表达: + +- `task:status` +- `task:progress` +- `task:toolCall` +- `task:citation` +- `task:partialArtifact` +- `task:blocked` +- `task:error` +- `task:cancelled` +- `task:completed` + +## 主题同步 + +Host 只发送当前已生效的主题 token,不发送设计推导逻辑: + +- `themeMode` +- `effectiveThemeMode` +- `colorSchemeId` +- `--lime-*` +- `--app-*` + +App 收到后只写入自身 `document.documentElement.style`,并设置 `data-lime-theme`、`data-lime-theme-effective`、`data-lime-color-scheme`。主题 token 不是业务数据,不进入 App storage、Artifact 或 Evidence。 + +## 架构图 + +```mermaid +flowchart TD + Runtime[AgentAppRuntimePage] --> Frame[App iframe] + Runtime --> Bridge[AgentAppHostBridge] + Bridge --> Theme[Lime Theme / Color Scheme] + Bridge --> Actions[Toast / Navigate / Download / OpenExternal] + Bridge --> SDK[Capability SDK Dispatcher] + SDK --> Guard[P14 Entry Guard + Readiness + Policy] + Guard --> HostCaps[Lime Host Capabilities] + HostCaps --> AgentTask[App-scoped Agent Task Runtime] + AgentTask --> WriteBack[Storage / Artifact / Evidence Write-back] + Frame -->|app:ready / capability:invoke| Bridge + Bridge -->|host:snapshot / theme:update / response| Frame +``` + +## 实现边界 + +| 模块 | 责任 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `AgentAppRuntimePage` | 持有 iframe ref、runtime origin、selected app / entry,上线 bridge lifecycle。 | +| `AgentAppHostBridge` | 构建快照、收发消息、校验来源、派发 Host action。 | +| Appearance helpers | 读取当前 Lime CSS variables,产出 theme payload。 | +| Capability dispatcher | 把 `capability:invoke` 转成现有 SDK / adapter 调用或 blocked error。 | +| Agent task dispatcher | 把 `lime.agent.startTask` / stream / cancel / retry 绑定到 App 作用域 task,附加 appId / entryKey / traceId。 | +| Structured write-back guard | 只允许 App 通过声明过的 storage table、artifact type 和 evidence subject 写回。 | +| App bootstrap | 内容工厂先作为样板接入 `host-bridge.js`。 | + +## 验收 + +- 切换 Lime 主题 / 配色后,已打开的 App iframe 内颜色同步变化。 +- App 首次打开即收到 `host:snapshot`,刷新 / 慢加载时 `host:getSnapshot` 可补偿。 +- 非当前 iframe source、非 runtime origin、错误 protocol / version 的消息被忽略。 +- `host:toast` / `host:navigate` 可用;`host:openExternal` / `host:download` 只允许安全 URL。 +- 未开放 capability 返回 `host:error`,不写假数据。 +- 内容工厂至少一条主链能在 App 内完成 `startTask -> stream -> human review -> storage/artifact/evidence write-back`,不回跳通用 Chat。 +- Expert Chat 可以嵌入同一 App 上下文,但不能替代业务页面和 workflow 状态。 +- `npm run test:contracts`、受影响前端测试和 GUI smoke 覆盖 runtime bridge 主路径。 + +## 与 P17.4 的关系 + +Host Bridge 是 P17.4 Runtime surface production hardening 的前置子项。没有 Host Bridge,App UI 即使能打开,也无法证明主题、语言、导航、下载和能力调用都在 Host policy 下运行。 + +## 实施拆解 + +| 阶段 | 交付 | 范围 | 验收 | +| ------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| P17.4.0 | Host Bridge baseline。 | `hostBridge`、`capabilityDispatcher`、`AgentAppRuntimePage` iframe bridge lifecycle。 | snapshot / theme / trusted message / host actions / capability invoke 定向测试通过。 | +| P17.4.1 | 已完成:runtime surface guard-before-start。 | `AgentAppRuntimePage` 启动 UI runtime 前先执行 P14 guard。 | needs-setup / blocked App 不调用 `agent_app_start_ui_runtime`。 | +| P17.4.2 | 已完成:Agent task stream contract hardening。 | `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask` 与 Host Bridge response 证据。 | App 内可 start / stream / get / cancel / retry,不回跳通用 Chat。 | +| P17.4.3 | 已完成:Structured write-back guard。 | storage namespace / artifact kind / evidence subject 写回声明校验。 | 未声明 storage / artifact / evidence 写回被拒绝;声明项写回保留 provenance。 | +| P17.4.4 | App bootstrap sample。 | content factory runtime package / host-bridge.js 样板。 | 示例 App 通过标准 bridge 取 snapshot、发 task、写回结果。 | +| P17.4.5 | 已完成:Security and smoke。 | hostBridge 安全、RuntimePage UI、schema / contract 回归。 | feature island 无直接 Tauri / raw Worker,完整 GUI smoke 已通过。 | + +## 当前下一刀 + +P17.4 已完成当前 production hardening 闭环:`AgentAppHostBridge`、`capabilityDispatcher`、`AgentAppRuntimePage` 已覆盖 snapshot、theme update、trusted message、host action、safe URL、capability invoke 和结构化错误码透传。P17.4.1 已补 runtime surface guard-before-start。P17.4.2 已补 RuntimePage Host Bridge 层 `startTask -> streamTask -> getTask -> cancelTask -> retryTask` 回归证据。P17.4.3 已补 storage namespace / artifact kind / evidence subject 写回声明 guard。P17.4.4 内容工厂样板已接入 promise-based Host Bridge,并在确认链后完成 `startTask -> streamTask -> storage/artifacts/evidence write-back` 的 App 内证据。P17.4.5 已在健康 DevBridge 下完成完整 GUI smoke。下一刀进入 P17.5 formal entry GUI smoke,仍不能用 Lab smoke 或全局 GUI smoke 替代正式 `agent-apps` 专用证据。 + +## 2026-05-15 P17.4.1 实施记录 + +协作分工: + +1. 本轮只触碰客户端 runtime surface、对应 UI 回归和本 P17.4 roadmap。 +2. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不改变 Cloud / LimeCore control-plane 边界。 +3. 不触碰 agentknowledge、上游 `agentapp` 标准仓库或真实 delete-data。 + +本轮完成内容: + +1. `AgentAppRuntimePage` 在调用 `startAgentAppUiRuntime()` 前先构建 installed preview,并执行 `evaluateAgentAppEntryRuntimeGuard()`。 +2. guard 输入包含 runtime package load、P14 UI mount operation、accepted permission decision 和 installed state disabled lifecycle。 +3. 当 guard 返回 `needs-setup / blocked / denied` 时,页面进入 runtime error 态,不启动 UI runtime、不创建 iframe、不上线 Host Bridge。 +4. `AgentAppRuntimePage.test.tsx` 新增 needs-setup 回归,证明 runtime surface 不能绕过 P14 guard 直接打开 App UI。 + +验证记录: + +| 命令 / 证据 | 结果 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | +| `npm run test -- src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/features/agent-app/runtime/entryRuntimeGuard.test.ts` | 通过,2 files / 13 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| `git diff --no-index --check /dev/null ` | 通过,覆盖本轮触碰文件。 | +| `nice -n 10 npm run typecheck` | 通过。 | + +未完成: + +1. P17.4.3 structured write-back guard 仍未收口声明校验。 +2. P17.4.4 content factory runtime package / host-bridge.js bootstrap 样板仍未接入。 +3. P17.4.5 security and smoke 仍需补 GUI smoke 或可替代 runtime 证据。 + +## 2026-05-15 P17.4.2 实施记录(局部) + +协作分工: + +1. 本轮只认领客户端 Host Bridge runtime 任务流回归,不触碰 `agentknowledge`、Lime Cloud / LimeCore、上游标准仓库或 AgentApp 其他 roadmap 资料。 +2. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不改变桌面端 Agent 本地运行边界。 +3. P17.4.2 暂不声明完整完成;本轮只证明 App UI 内 start / stream / cancel 可以通过标准 `capability:invoke` 闭环。 + +本轮完成内容: + +1. `AgentAppRuntimePage.test.tsx` 增加 Host Bridge 集成回归,模拟同一 iframe / origin / appId / entryKey 下连续发起 `lime.agent.startTask`、`lime.agent.streamTask`、`lime.agent.cancelTask`。 +2. 回归断言 Host 返回 `host:response`,并保留 `taskId`、`traceId`、`status`、`humanReview` 和 `task:cancelled` 事件。 +3. 该证据证明内容工厂类 App 不需要回跳通用 Chat,也不需要私有模型网关,就能在 App 业务 UI 内控制 App-scoped Agent task。 + +验证记录: + +| 命令 / 证据 | 结果 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `nice -n 10 npm run test -- src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/hostBridge.test.ts` | 通过,3 files / 18 tests。 | + +未完成: + +1. `getTask` 当时仍只在 dispatcher / adapter 层有能力,尚未补 RuntimePage Host Bridge 集成证据;已在 2026-05-16 记录中补齐。 +2. `retry` 语义当时尚未进入 SDK / dispatcher / Host Bridge 明确契约;已在 2026-05-16 记录中补齐。 +3. P17.4.3 structured write-back guard 仍未开始。 + +## 2026-05-16 P17.4.2 实施记录(完成) + +协作分工: + +1. 本轮只认领 P17.4.2 Host Bridge task contract,不触碰 AgentApp 其他 roadmap 总纲资料、`agentknowledge`、Lime Cloud / LimeCore 或上游标准仓库。 +2. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不绕过 `src/features/agent-app` feature island。 +3. retry 语义先做 SDK / adapter / mock / dispatcher / Host Bridge response 的最小闭环,不提前做真实模型重试、成本策略或后台队列。 + +本轮完成内容: + +1. `LimeAgentCapability` 增加 `retryTask(taskId)`;`AgentAppTaskRecord` 增加 `retryOfTaskId` 和 `retryAttempt`,用于在 App 内审计重试来源。 +2. `buildRetryAgentAppTaskRecord()` 统一复用源任务的 title、prompt、taskKind、input、expectedOutput、knowledge、tools、files、secrets 和 humanReview,并生成新的 retry idempotency key。 +3. `AdapterCapabilityHost` 和 `MockCapabilityHost` 支持 `retryTask`,缺失任务返回 `TASK_NOT_FOUND`,成功时创建新的 App-scoped running task。 +4. `capabilityDispatcher` 支持 `lime.agent.retryTask`,继续只通过 Host Capability SDK 路径调用,不新增私有 bridge method。 +5. `AgentAppRuntimePage.test.tsx` 的 Host Bridge 集成回归覆盖 `startTask -> streamTask -> getTask -> cancelTask -> retryTask`,证明 App UI 可在同一 iframe / origin / appId / entryKey 内管理 Agent task。 + +验证记录: + +| 命令 / 证据 | 结果 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `nice -n 10 npm run test -- src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx` | 通过,5 files / 30 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| touched-file whitespace check | 通过,覆盖 P17.4.2 触碰文件。 | +| `nice -n 10 npm run typecheck` | 通过。 | + +未完成: + +1. P17.4.3 structured write-back guard 仍未开始。 +2. P17.4.4 content factory runtime package / host-bridge.js bootstrap 样板仍未接入。 +3. P17.4.5 security and smoke 仍需补 runtime bridge 主路径 smoke 或可替代证据。 + +## 2026-05-16 P17.4.4 实施记录(完成) + +协作分工: + +1. 本轮只认领内容工厂 App bootstrap 和 Host Bridge request / response 样板,不触碰 SceneApp 清理、安装生命周期和 Cloud / LimeCore 控制面。 +2. 内容工厂继续保持非技术用户界面;Host 同步发生在用户确认链后,不把业务主链回跳到通用 Chat。 +3. 不新增私有 postMessage 协议,不新增 Tauri command,不绕过 Host capability readiness。 + +本轮完成内容: + +1. `content-factory-app/src/ui/host-bridge.js` 新增 requestId / pending request 管理,处理 `host:response` 与 `host:error`,并暴露 `invokeCapability`、`startHostTask`、`streamHostTask`、`cancelHostTask`、`syncHostConfirmation`。 +2. `content-factory-app/src/ui/app.js` 在 `confirmCurrentStep()` 成功后调用 Host Bridge,同步执行 `lime.agent.startTask -> lime.agent.streamTask -> lime.storage.set -> lime.artifacts.create -> lime.evidence.record`。 +3. 内容工厂写回 artifact kind 映射到 APP.md 已声明类型:`knowledge_pack`、`content_batch`、`strategy_report`、`review_report`,样板不写入未声明 `content_factory_asset_pack`。 +4. `dist/ui/host-bridge.js` 与 `dist/ui/app.js` 已通过 `npm run build` 重新生成。 +5. `AgentAppHostBridge` 对 dispatcher 的结构化错误 `code` 做透传,App 侧不再只能收到泛化 `HOST_ACTION_FAILED`。 + +验证记录: + +| 命令 / 证据 | 结果 | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `/Users/coso/Documents/dev/ai/limecloud/content-factory-app: npm run verify` | 通过,build、41 tests、validate、readiness 均 OK;readiness 在未绑定 host setup 时保持 `needs-setup`。 | +| `npx eslint "src/features/agent-app/runtime/hostBridge.ts" "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.tsx" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/experts/expertAgentInstances.test.ts"` | 通过。 | +| `npx vitest run "src/features/agent-app/runtime/hostBridge.test.ts" "src/features/agent-app/runtime/capabilityDispatcher.test.ts" "src/features/agent-app/ui/AgentAppRuntimePage.test.tsx" "src/features/experts/expertAgentInstances.test.ts"` | 通过,4 files / 23 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过,command / harness / modality / cleanup contracts 均 OK。 | +| `npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000` | 通过,DevBridge、workspace、browser runtime、site adapters、agent runtime tool surface、chat streaming、knowledge GUI、design canvas 均 OK。 | + +未完成: + +1. P17.4.3 的 Host 侧 artifact / evidence 声明级拒绝负例在当时仍未补齐;已在 2026-05-16 P17.4.3 记录中补齐。 + +## 2026-05-16 P17.4.3 实施记录(完成) + +协作分工: + +1. 本轮只认领客户端 Host 侧 structured write-back guard,不触碰上游 `content-factory-app`、Lime Cloud / LimeCore 或其他 AgentApp roadmap 总纲资料。 +2. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,App UI 仍只能通过 `capability:invoke` 进入 Host Capability SDK。 +3. storage 暂按 manifest storage namespace 约束,不扩展到表级 schema 校验;artifact / evidence 做声明级强制校验。 + +本轮完成内容: + +1. `createAgentAppCapabilityDispatcher()` 增加 `projection` 输入,生产路径由 `AgentAppRuntimePage` 传入当前 installed App projection。 +2. `lime.storage.set/delete` 写回前要求 manifest 声明 storage namespace;没有 storage 声明时返回 `WRITEBACK_NOT_DECLARED`。 +3. `lime.artifacts.create` 写回前校验 `input.kind` 必须命中 `projection.artifactTypes[].key`,未声明 artifact kind 返回 `WRITEBACK_NOT_DECLARED`。 +4. `lime.evidence.record` 写回前校验 `input.kind` 必须命中 `projection.evals[].key`,未声明 evidence subject 返回 `WRITEBACK_NOT_DECLARED`。 +5. RuntimePage Host Bridge 集成回归覆盖声明内 artifact / evidence 写回成功,以及未声明 artifact / evidence 被 `host:error` 以结构化错误码拒绝。 + +验证记录: + +| 命令 / 证据 | 结果 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `nice -n 10 npm run test -- src/features/agent-app/runtime/capabilityDispatcher.test.ts src/features/agent-app/runtime/hostBridge.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/ui/AgentAppRuntimePage.test.tsx` | 通过,5 files / 32 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| touched-file whitespace check | 通过,覆盖 P17.4.3 触碰文件。 | +| `nice -n 10 npm run typecheck` | 通过。 | + +未完成: + +1. P17.4.5 security and smoke 仍需结合最新 write-back guard 复核 feature island 边界、contracts、typecheck 和 runtime smoke。 +2. 上游内容工厂若继续使用 `content_factory_confirmation` 作为 evidence kind,需要在 APP.md 中声明对应 eval,或改用已声明 eval key;本轮不跨仓库改样板。 + +## 2026-05-16 P17.4.5 验证记录(阻塞) + +协作分工: + +1. 本轮只做验证收口,不新增 Agent App runtime 功能。 +2. 不结束或重启隔壁正在运行的 Tauri / Cargo 任务,避免破坏并行工作。 +3. P17.4.5 暂不标记完成,直到 GUI smoke 在健康 DevBridge 下完整通过。 + +已完成验证: + +| 命令 / 证据 | 结果 | +| ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `nice -n 10 npm run test:contracts` | 通过,command / harness / modality / cleanup contracts 均 OK。 | +| `nice -n 10 npm run smoke:agent-service-skill-entry` | 通过,Skill Forge 前端 metadata、8 个 Rust 定向测试、服务技能入口路由、Agent 对话 A2UI 挂起主链均通过,并生成 `.lime/qc/skill-forge-runtime-transcript-current.json`。 | +| `nice -n 10 npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000` | 未通过:第一次在 `smoke:agent-service-skill-entry` 因 Cargo artifact lock / 编译超时;第二次在 `smoke:claw-chat-ready-streaming` 阶段出现 provider detail `fetch failed`,随后 DevBridge 不可用。 | +| `nice -n 10 npm run verify:gui-smoke -- --timeout-ms 300000` | 未通过:检测到已有前端 dev server,尝试拉起独立 headless Tauri;Rust 编译完成后 headless Tauri 父进程 exitCode=0,但 DevBridge 在 30s 内仍未就绪。 | +| `curl http://127.0.0.1:3030/health` / `lsof -nP -iTCP:3030 -sTCP:LISTEN` / `lsof -nP -iTCP:1420 -sTCP:LISTEN` / `ps ... tauri dev` | 3030 无监听,1420 前端仍在;存在外部 `pnpm run tauri dev` 进程但没有健康 DevBridge 后端。 | +| `rg -n "port\|3030\|1420\|health-url\|invoke-url" scripts/verify-gui-smoke.mjs src-tauri` | 已确认 smoke 脚本可替换 health / invoke URL,但当前 DevBridge 端口仍由 Tauri 后端固定为 3030;没有不碰现有 Tauri dev 的隔离端口路径。 | + +阻塞判断: + +1. 最新 Host Bridge / dispatcher 代码层验证已通过,但 GUI smoke 不能作为 P17.4.5 完成证据。 +2. 当前 blocker 是运行环境:已有 Tauri dev / single-instance 状态不健康,且本轮不应擅自结束隔壁进程。 +3. 下一步需要在确认可重启现有 Tauri dev 后,重新执行 `npm run verify:gui-smoke -- --timeout-ms 300000`;通过后才能把 P17.4.5 标为完成。 + +## 2026-05-16 P17.4.5 复测记录(仍阻塞) + +协作分工: + +1. 本轮只复用现有运行会话,不杀进程、不重启 Tauri / Vite / Cargo。 +2. 只验证 P17.4.5 smoke 缺口,不新增 runtime 功能。 +3. 发现隔壁仍有 Vitest / Tauri 相关任务时,继续保持阻塞记录,不抢占运行环境。 + +复测前状态: + +| 命令 / 证据 | 结果 | +|---|---| +| `curl -fsS http://127.0.0.1:3030/health` | 通过,返回 `{"service":"DevBridge","status":"ok","version":"1.0.0"}`。 | +| `lsof -nP -iTCP:3030 -sTCP:LISTEN` | `target/debug/lime` 正在监听 3030。 | +| `lsof -nP -iTCP:1420 -sTCP:LISTEN` | Vite dev server 正在监听 1420。 | + +复测结果: + +| 命令 / 证据 | 结果 | +|---|---| +| `nice -n 10 npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000` | 未通过:脚本选择复用已运行 headless Tauri,但 `bridge:health` 持续 `fetch failed`,300s 后超时。 | +| `curl -fsS http://127.0.0.1:3030/health` | 复测后失败,3030 无法连接。 | +| `lsof -nP -iTCP:3030 -sTCP:LISTEN` | 复测后无监听。 | +| `lsof -nP -iTCP:1420 -sTCP:LISTEN` | 复测后 1420 仍由 Vite dev server 监听。 | +| `ps ... tauri / vite / cargo / vitest` | 仍存在外部 `pnpm run tauri dev`、Vite dev server 与 Vitest 任务;本轮未结束这些进程。 | + +阻塞判断: + +1. P17.4.5 仍不能标记完成;完整 GUI smoke 没有通过。 +2. 失败点仍是 DevBridge 运行环境,而不是 Host Bridge / dispatcher 的新增代码层断言。 +3. 下一步仍需要在确认可接管运行环境后,重启到健康 Tauri / DevBridge 会话,再执行完整 `npm run verify:gui-smoke -- --timeout-ms 300000`。 + +## 2026-05-16 P17.4.5 复测记录(完成) + +协作分工: + +1. 等隔壁 Vitest 任务结束后再复测,避免争抢 Cargo / Vitest 资源。 +2. 继续复用现有 headless Tauri / DevBridge 会话,不杀进程、不手动重启。 +3. 本轮只收口 P17.4.5 验证,不新增 runtime 功能、不改 Cloud / LimeCore。 + +复测前状态: + +| 命令 / 证据 | 结果 | +|---|---| +| `ps ... vitest / tauri / vite` | 隔壁 Vitest 已结束,仅保留现有 Tauri / Vite 会话。 | +| `curl -fsS http://127.0.0.1:3030/health` | 通过,返回 `{"service":"DevBridge","status":"ok","version":"1.0.0"}`。 | +| `lsof -nP -iTCP:3030 -sTCP:LISTEN` | `target/debug/lime` 正在监听 3030。 | + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `nice -n 10 npm run verify:gui-smoke -- --reuse-running --timeout-ms 300000` | 通过。 | + +覆盖范围: + +1. `smoke:workspace-ready` 通过。 +2. `smoke:browser-runtime` 通过。 +3. `smoke:site-adapters` 通过。 +4. `smoke:agent-service-skill-entry` 通过。 +5. `smoke:agent-runtime-tool-surface` 通过。 +6. `smoke:agent-runtime-tool-surface-page` 通过。 +7. `smoke:at-command-registry` 通过。 +8. `smoke:claw-chat-ready-streaming` 通过。 +9. `smoke:knowledge-gui` 通过,i18n Patch metrics 为 `no-hit`。 +10. `smoke:design-canvas` 通过。 + +结论: + +1. P17.4.5 已完成;完整 GUI smoke 通过,可以把 P17.4 runtime surface production hardening 标记为当前闭环完成。 +2. P17.5 仍未开始;下一刀需要新增或扩展正式 `agent-apps` 专用 smoke,覆盖 install / registration / launch / disable / uninstall rehearsal / runtime surface / flag-off。 +3. P17.5 之前仍不得发布 marketplace、Cloud 管理台或真实 delete-data。 diff --git a/docs/roadmap/agentapp/p17-formal-entry-contract.md b/docs/roadmap/agentapp/p17-formal-entry-contract.md new file mode 100644 index 000000000..a5c22eaa4 --- /dev/null +++ b/docs/roadmap/agentapp/p17-formal-entry-contract.md @@ -0,0 +1,265 @@ +# Agent App P17.0 正式入口契约 + +更新时间:2026-05-15 + +## 一句话结论 + +P17.0 不发布 marketplace,也不扩展 Cloud 管理台;它只把 Lime Desktop 现有 `agent-apps` 受控入口定成 current 主链:用户在这里安装、注册、启动、禁用、卸载演练和进入 runtime;`agent-app-lab` 继续作为研发验证入口,负责 fixture、smoke、flag-off 和实验断言。 + +## 背景 + +P16-H 已完成多 App repository、selected launcher、持久化 lifecycle、cleanup evidence、residual audit、Agent App Lab GUI smoke、flag-off 回归和 P17 Gate 审计。结合 `/Users/coso/Documents/dev/ai/limecloud/agentapp` v0.3 标准,客户端下一步不能继续停留在 Lab,也不能跳到公开市场,而是要先固定正式入口契约。 + +这次计划更新的关键判断: + +1. Agent App 是完整可安装应用,不是专家卡片、prompt 包或 Markdown 目录。 +2. Lime Desktop 是运行宿主,负责安装、cache、projection、readiness、SDK 注入、UI / workflow runtime、storage namespace 和 cleanup。 +3. Lime Cloud / LimeCore 只负责 catalog、release、license、tenant enablement、gateway 和 ToolHub metadata,不运行默认 Agent、不渲染 App UI、不接管本地 storage。 +4. `agent-apps` 可以成为正式受控入口,但必须继续复用 P11 repository、P14 guard、P15 cleanup preview、P16-H evidence / audit。 +5. `agent-app-lab` 不能继续承担用户入口职责;它只保留研发、验证、fixture 和失败退出证明。 + +## 目标 + +| 目标 | 说明 | +|---|---| +| 固定正式入口 | `agent-apps` 成为用户可见 App 管理和启动入口,不再依赖 Lab 心智解释核心流程。 | +| 固定研发入口 | `agent-app-lab` 继续用于 fixture、smoke、cleanup rehearsal、flag-off 和实验断言。 | +| 复用 current 主链 | P17 不新增第二套 installer、store、scanner、guard、runtime loader 或 cleanup 机制。 | +| 明确能力边界 | App 只能通过 Capability SDK 使用 Lime 能力,不能 import Lime 内部模块或直接调用 Tauri command。 | +| 保持失败可清理 | disable、uninstall rehearsal、cleanup evidence、residual audit 和 flag-off 仍是正式入口前硬门槛。 | +| 分离后续 gate | 真实 delete-data、public catalog、企业控制台和完整内容工厂业务系统分别另立计划。 | + +## 非目标 + +1. 不做公开 marketplace、审核流、支付、排名、推荐或分发运营。 +2. 不做 Cloud 管理台、企业租户控制台或远程 Agent Runtime。 +3. 不做真实 delete-data 执行器;P17 仍只做 rehearsal / evidence / audit。 +4. 不把 内容工厂扩成完整行业 SaaS。 +5. 不恢复 `SceneApp`、`contentEngineering*`、`sceneapp_*` 或 `shenlan-content-engineering` 旧路线。 +6. 不新增 raw Worker 执行或任意 App package JS 执行。 +7. 不允许 Agent App 绕过 Capability SDK 直接使用 Lime internal store、`safeInvoke` 或 `invoke`。 + +## 入口契约 + +| 入口 | 面向对象 | 可以做 | 不可以做 | 事实源 | +|---|---|---|---|---| +| `agent-apps` | 普通用户 / 团队管理员 | 安装 local / cloud release、提交注册码、查看 installed apps、启动 entry、disable / enable、uninstall rehearsal、查看 runtime surface。 | 公开市场、审核流、真实删除、Lab fixture 调试、smoke 控制、Cloud 管理台。 | `AgentAppsPage`、`AgentAppRuntimePage`、`src/lib/api/agentApps.ts`、P11-P16-H current 链。 | +| `agent-app-lab` | 开发者 / QA / 路线图验证 | fixture 验证、package review、guard 断言、cleanup rehearsal、residual audit、GUI smoke、flag-off regression。 | 作为正式用户入口、承载 marketplace、替代正式 runtime page、写入 Cloud metadata。 | `AgentAppLabPage`、`AgentAppManagerPanel`、`scripts/agent-app-lab-smoke.mjs`。 | +| `agent-app` runtime | 从正式入口或深链进入的 App 运行面 | 展示 App entries、触发 P14 guard、mount UI entry、run workflow / entry、展示 readiness / artifact / knowledge 摘要。 | 绕过 P14 guard、加载 disabled App、直接执行未验证 package、直接访问宿主内部 store。 | `AgentAppRuntimePage`、P13 loader、P14 guard、Capability SDK host。 | + +## 架构图 + +```mermaid +flowchart TD + Sidebar[Sidebar Footer: Agent Apps] --> Apps[agent-apps / AgentAppsPage] + Apps --> Api[agentApps API Gateway] + Apps --> RuntimeLink[Open Runtime Surface] + + Api --> Source[Local / Cloud Package Source] + Api --> Catalog[Cloud Catalog Metadata] + Api --> Registration[Registration Code] + Api --> Repository[P11 Installed State Repository] + Api --> Uninstall[P15 Uninstall Preview] + + Source --> Cache[P12 Package Cache + Verify] + Cache --> Loader[P13 Runtime Package Loader] + Repository --> Guard[P14 Entry Runtime Guard] + RuntimeLink --> Runtime[agent-app / AgentAppRuntimePage] + Runtime --> Guard + + Guard --> SDK[Capability SDK Facade] + SDK --> UI[UI Extension Host] + SDK --> Workflow[Workflow Runtime Host] + SDK --> Storage[App Storage Namespace] + SDK --> Evidence[Artifact + Evidence] + + Lab[agent-app-lab] --> Smoke[GUI Smoke + Flag-off] + Lab --> Fixture[Fixture / Debug Source] + Lab --> Audit[P16-H Evidence + Residual Audit] + Smoke -. validates .-> Apps + Audit -. feeds .-> Api +``` + +## 安装时序 + +```mermaid +sequenceDiagram + participant User as User + participant Apps as agent-apps + participant Api as agentApps API + participant Source as Package Source + participant Cache as Package Cache + participant Review as Install Review + participant Repo as Installed State Repository + participant Guard as Readiness / Guard + + User->>Apps: 选择 local / cloud release + Apps->>Api: resolve source / release descriptor + Api->>Source: 读取 manifest / release metadata + Api->>Cache: verify hash / manifest / lock + Cache-->>Api: verified package descriptor + Api->>Guard: project readiness snapshot + Guard-->>Api: ready / needs-setup / blocked + Api->>Review: build install review + Review-->>Apps: identity / permissions / data boundary + Apps-->>User: 展示安装审查 + User->>Apps: confirm install + Apps->>Api: save reviewed package + Api->>Repo: persist installed state + Api-->>Apps: installed state + issues + Apps-->>User: 展示状态与可启动 entry +``` + +## 启动时序 + +```mermaid +sequenceDiagram + participant User as User + participant Apps as agent-apps + participant Runtime as agent-app runtime + participant Repo as Installed State Repository + participant Guard as P14 Guard + participant SDK as Capability SDK Host + participant UI as UI / Workflow Runtime + + User->>Apps: 点击 App entry + Apps->>Runtime: navigate appId / entryKey / launchRequestKey + Runtime->>Repo: list installed apps + Repo-->>Runtime: selected installed state + Runtime->>Guard: evaluate entry + readiness + permissions + package load + alt allow + Guard-->>Runtime: allow + Runtime->>SDK: create authorized capability handles + SDK->>UI: mount UI entry or run workflow + UI-->>Runtime: mount result / run evidence + Runtime-->>User: 展示 App runtime surface + else denied / blocked / needs-setup + Guard-->>Runtime: blocked status + Runtime-->>User: 展示阻断原因和下一步 + end +``` + +## 主流程 + +```mermaid +flowchart TD + Start[进入 Agent Apps] --> HasApps{已有 installed apps?} + HasApps -->|No| ChooseSource[选择 local / cloud source] + HasApps -->|Yes| SelectApp[选择 installed app] + ChooseSource --> Registration{需要注册码?} + Registration -->|Yes| SubmitCode[提交注册码] + Registration -->|No| Verify[verify package + readiness preview] + SubmitCode --> Verify + Verify --> Review[安装审查] + Review --> Install[persist installed state] + Install --> SelectApp + SelectApp --> Disabled{App disabled?} + Disabled -->|Yes| Enable[enable 后重新检查] + Disabled -->|No| Entry[选择 entry] + Enable --> Entry + Entry --> Guard{P14 guard allow?} + Guard -->|No| Block[展示 blocked / needs-setup / denied] + Guard -->|Yes| Runtime[进入 runtime surface] + SelectApp --> Lifecycle[disable / uninstall rehearsal] + Lifecycle --> Evidence[cleanup evidence + residual audit] +``` + +## 用户故事 + +| ID | 用户故事 | 验收口径 | +|---|---|---| +| P17-US1 | 作为用户,我可以从侧栏 footer 的 Agent Apps 进入受控 App 管理页。 | `agent-apps` 不依赖 Lab flag;Lab 关闭时仍可见。 | +| P17-US2 | 作为用户,我可以安装一个本地或 Cloud metadata 指向的 App release。 | 安装走 package source、cache verify、installed state repository,不写第二套状态。 | +| P17-US3 | 作为企业用户,我可以为需要授权的 App 输入注册码后再安装。 | registration 只改变 source enablement,不把凭证写入 App package。 | +| P17-US4 | 作为用户,我可以启动某个 App entry,并进入 App 自己的 runtime surface。 | 启动必须经过 P14 guard;disabled / blocked / needs-setup 不能绕过。 | +| P17-US5 | 作为用户,我可以 disable / enable App。 | 状态持久化到 repository,刷新后不丢,disabled App 不可启动。 | +| P17-US6 | 作为用户,我可以预览卸载和删除数据影响。 | P17 只输出 rehearsal evidence / residual audit,不执行真实 delete-data。 | +| P17-US7 | 作为开发者,我仍能在 Lab 里跑 fixture 和 smoke。 | `agent-app-lab` 保留但不承担正式入口职责。 | +| P17-US8 | 作为维护者,我能证明 App 没有绕过 SDK。 | `src/features/agent-app` 无直接 `safeInvoke` / `invoke` / raw Worker / Tauri command 扩散。 | + +## 分阶段计划 + +| 阶段 | 交付 | 范围 | 验收 | +|---|---|---|---| +| P17.0 | Formal Entry Contract。 | 本文档、README、implementation plan 和 P17 Gate 审计口径同步。 | 文档固定 `agent-apps` / `agent-app-lab` / runtime 三层职责,明确禁区和后续 gates。 | +| P17.1 | 已完成最小实现:Formal route / nav / copy hardening。 | 固定 `agent-apps` 为用户入口,`agent-app-lab` 为实验入口;补足五语言正式入口文案;UI entry 从正式入口进入独立 runtime surface。 | `AgentAppsPage`、`AppPageContent`、`sidebarNav` 定向测试通过;Lab flag-off 不影响 `agent-apps`;不再展示本机硬编码 local path。 | +| P17.2 | 已完成最小实现:Source / install contract hardening。 | P17.2.1 / P17.2.2 / P17.2.3 已完成 source state model、install review descriptor 与 registration hardening;P17.2.4a 已完成 Cloud release descriptor、package / manifest verification gate、cached fallback exact-hash 判断;P17.2.4b-1 已完成 acquisition seam / verified cache source;P17.2.4b-2 已完成 packageUrl fetch / staging / manifest extraction;P17.2.5 已对齐上游 schema / reference CLI / 示例包。 | install / registration 仍复用 `src/lib/api/agentApps.ts`、P12 cache、P11 repository,不新增 store;Cloud card / Lab fixture 不能作为正式 review 来源;详见 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md)。 | +| P17.3 | 已完成最小实现:Lifecycle / cleanup contract hardening。 | 详见 [p17-lifecycle-cleanup-contract-hardening.md](./p17-lifecycle-cleanup-contract-hardening.md):formal 页面展示 disable / enable、uninstall rehearsal、cleanup evidence 和 residual audit。 | disabled / cleanup-blocked 不能启动;uninstall rehearsal 不真实删除;audit 只查 Agent App namespace。 | +| P17.4 | 已完成:Runtime surface contract hardening。 | P17.4.1-P17.4.5 已完成 guard-before-start、Host Bridge task contract、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。 | 所有 launch 都经 P14 guard;生产路径只加载 verified runtime package,dev resolver 不作为正式 fallback;runtime 不加载 disabled / unverified package;App UI 只能通过 `lime.agentApp.bridge` 和 injected SDK handles 使用 Lime 能力。 | +| P17.5 | Formal entry GUI smoke。 | 增加或扩展 `agent-apps` 专用 smoke,和 Lab smoke 分离。 | summary 覆盖 install、registration、launch、disable、uninstall rehearsal、runtime surface、flag-off。 | + +## 后续 Gate + +| Gate | 进入条件 | 不满足时 | +|---|---|---| +| P18 Real Delete Data Gate | cleanup plan targets 可枚举、dry-run 可复核、namespace 删除可回滚、用户二次确认。 | 继续 rehearsal,不执行真实删除。 | +| P19 Public Catalog Gate | release channel、审核状态、签名、版本兼容、风险标签和安装说明完整。 | Cloud 继续只提供 seeded metadata / enterprise allowlist。 | +| P20 Enterprise Console Gate | tenant enablement、license、audit metadata、policy overlay 和管理员操作记录明确。 | 不做 Cloud 管理台,只保留客户端 registration / metadata 输入。 | +| P21 Content Factory Business Expansion Gate | 平台入口稳定、SDK 能表达完整业务、storage / artifact / eval 版本化。 | 内容工厂继续作为示例,不扩成行业 SaaS。 | + +## 文件边界 + +| 分类 | 路径 / 对象 | 状态 | +|---|---|---| +| current | `src/features/agent-app/**` | Agent App 客户端 current 功能岛。 | +| current | `src/features/agent-app/ui/AgentAppsPage.tsx` | 正式受控入口候选,P17 hardening 只在此基础上推进。 | +| current | `src/features/agent-app/ui/AgentAppRuntimePage.tsx` | App runtime surface,必须复用 P14 guard 和 Capability SDK。 | +| current | `src/lib/api/agentApps.ts` | Agent Apps API gateway / bridge 事实源;如新增命令必须同步 contracts。 | +| current | `docs/roadmap/agentapp/p17-formal-entry-gate-audit.md` | P17 Gate 审计证据。 | +| current | `docs/roadmap/agentapp/p17-formal-entry-contract.md` | P17.0 正式入口契约。 | +| current | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 source / install contract hardening 当前计划。 | +| current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 lifecycle / cleanup contract hardening 已完成计划。 | +| current | `docs/roadmap/agentapp/p17-4-host-bridge-runtime.md` | P17.4-H Host Bridge Runtime 当前计划。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.3 标准事实源。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 / Cloud 控制面路线图。 | +| deprecated | `agent-app-lab` 用户入口心智 | Lab 仍 current 于研发验证,但不再作为用户正式入口。 | +| dead | `SceneApp` / `contentEngineering*` / `sceneapp_*` | 不恢复、不迁移、不兼容。 | + +## 验收标准 + +1. P17 文档必须清楚说明 `agent-apps`、`agent-app-lab`、runtime surface 的职责边界。 +2. 正式入口实现不得新增第二套 repository、installer、cache、guard、runtime loader 或 cleanup scanner。 +3. `agent-apps` 可在 Lab flag-off 后保持可见,`agent-app-lab` 在 flag-off 后不可见。 +4. 所有用户可见文案覆盖 `zh-CN / zh-TW / en-US / ja-JP / ko-KR`。 +5. `src/features/agent-app` 不直接出现 `safeInvoke`、`invoke(`、`tauri::`、`generate_handler`、`new Worker`。 +6. 如修改 Tauri command / bridge,必须同步 frontend gateway、Rust registration、command catalog、mock priority / default mocks,并执行 `npm run test:contracts`。 +7. GUI 证据必须区分 Agent Apps formal smoke 与 Agent App Lab smoke;不能用 Lab 证据冒充正式入口可交付。 + +## 最小验证 + +P17.1 已执行: + +```bash +npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/components/AppPageContent.test.tsx src/lib/navigation/sidebarNav.test.ts +npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts +rg -n "safeInvoke|invoke\\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\\(" src/features/agent-app || true +git diff --check -- src/features/agent-app src/components/AppPageContent.tsx src/components/AppPageContent.test.tsx src/i18n/resources docs/roadmap/agentapp +``` + +P17.2 及之后每一刀至少执行: + +```bash +npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/lib/navigation/sidebarNav.test.ts +npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts +git diff --check -- docs/roadmap/agentapp src/features/agent-app src/lib/api/agentApps.ts src/i18n/resources +``` + +若改动 command / bridge: + +```bash +npm run test:contracts +``` + +若改动正式入口 GUI 主流程: + +```bash +npm run smoke:agent-app-lab -- --timeout-ms 180000 +``` + +后续如果新增 `smoke:agent-apps`,P17.5 起用正式入口 smoke 替代 Lab smoke 作为主证据,Lab smoke 只保留研发回归证据。 + +## 当前下一刀 + +P17.1 已完成正式入口 route / nav / copy 最小硬化;P17.2.1-P17.2.5 已完成 source / install / schema cross-check;P17.3 已完成 lifecycle / cleanup contract hardening;P17.4.1-P17.4.5 已完成 runtime guard-before-start、App 内 Agent task、structured write-back guard、content factory bootstrap sample 与完整 GUI smoke。当前下一刀进入 P17.5 formal entry GUI smoke。 + +P17.2 只处理正式入口的 package source、install review、registration、source state 和剩余 Lab-only 语义,不做 public catalog、真实 delete-data、Cloud 管理台或完整内容工厂业务扩展。 diff --git a/docs/roadmap/agentapp/p17-formal-entry-gate-audit.md b/docs/roadmap/agentapp/p17-formal-entry-gate-audit.md new file mode 100644 index 000000000..a1e5cc04f --- /dev/null +++ b/docs/roadmap/agentapp/p17-formal-entry-gate-audit.md @@ -0,0 +1,74 @@ +# Agent App P17 正式入口前 Gate 审计 + +更新时间:2026-05-15 + +## 一句话结论 + +P16-H 已完成进入 P17 设计前的最小技术证据:多 App repository、selected launcher、持久化 lifecycle、cleanup evidence、residual audit、Agent App Lab 专用 GUI smoke、flag-off 回归、typecheck、i18n、command contracts 与边界审计均有当前证据。P17 可以进入“正式 Agent Apps 入口设计 / hardening”阶段,但不能直接升级为 marketplace、Cloud 管理台、真实 delete-data 或完整内容工厂 SaaS。 + +## 审计口径 + +本审计只回答一个问题:Lime Desktop 的 Agent App current 主链是否具备进入 P17 正式入口设计的条件。 + +它不等于: + +1. 已经发布公开 marketplace。 +2. 已经实现真实 delete-data。 +3. 已经让 Cloud / LimeCore 运行 Agent 或渲染 App UI。 +4. 已经完成 内容工厂完整业务系统。 +5. 已经允许 Agent App 绕过 Capability SDK 调用 Lime 内部模块。 + +## Prompt-to-artifact checklist + +| 要求 / Gate | 证据 | 状态 | 结论 | +|---|---|---|---| +| 多 App repository list | `AgentAppManagerPanel` / `AgentAppLabPage` 测试;`smoke:agent-app-lab` summary `managerRepositoryList=true`、`managerMultiApp=true`。 | 通过 | 可作为 P17 list 事实源,不需要第二套 store。 | +| selected app launcher | `AgentAppLabPage.test.tsx`;smoke summary `managerSelectedApp=true`、`selectedRuntimeApp=true`。 | 通过 | 选中 App 后 entry launcher 与 runtime appId 对齐。 | +| P14 guard 不可旁路 | `AgentAppLabPage.test.tsx`;smoke summary `guardAllowed=true`;`AgentAppRuntimePage.test.tsx`。 | 通过 | P17 entry launch 继续复用 P14 guard。 | +| disable / enable lifecycle | `installedAppState.test.ts`;smoke summary `managerDisableBlocked=true`、`managerEnableAvailable=true`、`managerReenabled=true`。 | 通过 | UI 层能证明 disabled App 不可启动并可恢复。 | +| cleanup rehearsal evidence | `cleanupRehearsalEvidence.test.ts`;smoke summary `cleanupEvidenceJson=true`、`cleanupEvidenceSelectedApp=true`、`cleanupEvidenceStrategy=true`、`cleanupEvidenceBlockedCount=true`。 | 通过 | delete-data 仍是 rehearsal,不执行真实删除。 | +| residual audit | `cleanupResidualAudit.test.ts`;smoke summary `residualAuditVisible=true`、`residualAuditPending=true`。 | 通过 | 能区分 pending / retained / blocked / repository issue。 | +| flag-off regression | `scripts/agent-app-lab-smoke.mjs`;smoke summary `flagOffLabNavHidden=true`、`flagOffLabPageHidden=true`、`flagOffAgentAppsNavVisible=true`、`flagOffNoConsoleErrors=true`。 | 通过 | 关闭 Lab flags 后实验 Lab 不进主路径;受控 Agent Apps 入口仍可见。 | +| 正式 Agent Apps 入口基本可用 | `AgentAppsPage.test.tsx`、`AgentAppRuntimePage.test.tsx`、`sidebarNav.test.ts`。 | 通过 | P17 可以基于现有 `agent-apps` 受控入口 harden,不另起页面事实源。 | +| 五语言文案覆盖 | `translation-coverage.test.ts`、`loadNamespace.test.ts`、`types.test.ts`。 | 通过 | 新增 runtime loading / empty 文案已覆盖五语言。 | +| TypeScript 类型正确 | `npm run typecheck`。 | 通过 | Agent App runtime / Agent Apps page / i18n key 类型已通过 `tsc --noEmit`。 | +| 命令契约同步 | `npm run test:contracts`。 | 通过 | frontend 399、Rust registered 555、mock priority 48、default mock 379;命令契约通过。 | +| Feature island 无直接 Tauri / raw Worker | `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` 无命中。 | 通过 | Agent App feature island 不直接扩展命令边界。 | +| 旧内容工程化 / SceneApp key 不复活 | `rg` legacy audit 无命中。 | 通过 | 不把旧 `contentEngineering*` / `sceneapp_*` 当 current。 | +| Cloud / LimeCore 边界 | `docs/roadmap/agentapp/README.md`、本文与 P16-H 均约束 Cloud 只做 catalog / release / tenant metadata。 | 通过 | P17 仍不能让 Cloud 运行 Agent、渲染 UI 或接管本地 storage。 | +| 失败退出方案 | cleanup rehearsal evidence + residual audit + flag-off smoke。 | 部分通过 | 已证明 rehearsal 和 Lab flag-off;真实 delete-data / namespace 清理执行器仍是 P17 之后单独 gate。 | + +## 当前验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/cleanupRehearsalEvidence.test.ts src/features/agent-app/install/cleanupResidualAudit.test.ts src/features/agent-app/install/installedAppState.test.ts src/features/agent-app/ui/AgentAppManagerPanel.test.tsx src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,5 files / 26 tests。 | +| `npm run test -- src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/features/agent-app/ui/AgentAppsPage.test.tsx src/lib/navigation/sidebarNav.test.ts` | 通过,3 files / 10 tests。 | +| `npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts` | 通过,3 files / 17 tests。 | +| `npm run smoke:agent-app-lab -- --timeout-ms 180000` | 通过;summary: `.lime/qc/gui-evidence/agent-app-lab/agent-app-lab-smoke-summary.json`。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | +| `node --check scripts/agent-app-lab-smoke.mjs` | 通过。 | +| `git diff --check -- docs/roadmap/agentapp src/features/agent-app scripts/agent-app-lab-smoke.mjs package.json src/i18n/resources` | 通过。 | +| boundary / legacy `rg` audit | 通过。 | + +## P17 进入条件判定 + +| 条件 | 判定 | 说明 | +|---|---|---| +| 可以进入 P17 正式入口设计 | 可以 | 现有 `agent-apps` 入口已有基础页面、runtime surface、registration、install、launch、disable、uninstall rehearsal 测试。 | +| 可以发布 marketplace | 不可以 | 还没有审核、版本渠道、支付、企业分发或公开 catalog UX。 | +| 可以执行真实 delete-data | 不可以 | P16-H 明确只做 rehearsal;真实删除必须另立 gate。 | +| 可以让 Cloud 运行 Agent App | 不可以 | Cloud / LimeCore 仍只做控制面与 metadata。 | +| 可以把 内容工厂扩成完整行业系统 | 不可以 | P17 只处理平台正式入口,不扩业务 SaaS。 | + +## 计划落点 + +P17 第一刀已沉淀为 [p17-formal-entry-contract.md](./p17-formal-entry-contract.md)。`P17.0 Formal Entry Contract` 的目标是把已有 `agent-apps` 受控入口的产品边界写清楚: + +1. 定义正式入口允许做什么:install、launch、disable、registration、uninstall rehearsal、runtime surface。 +2. 定义仍禁止做什么:marketplace、Cloud 管理台、真实 delete-data、raw worker、绕过 SDK。 +3. 把 `agent-apps` 与 `agent-app-lab` 的边界固定:正式入口服务用户,Lab 继续服务研发验证。 +4. 为真实 delete-data、public catalog、企业控制台分别建立后续 gate,不混进 P17.0。 + +P17.0 完成后已继续完成 `P17.1 Formal route / nav / copy hardening`、P17.2.1 source state model、P17.2.2 install review descriptor、P17.2.3 registration hardening、P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction、P17.2.5 schema / reference CLI / example package cross-check 与 [P17.3 lifecycle / cleanup contract hardening](./p17-lifecycle-cleanup-contract-hardening.md);当前下一刀进入 [P17.5 formal entry GUI smoke](./implementation-plan.md),仍不提前扩 marketplace、Cloud 管理台或真实 delete-data。 diff --git a/docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md b/docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md new file mode 100644 index 000000000..7684c6d36 --- /dev/null +++ b/docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md @@ -0,0 +1,451 @@ +# P17.3 Lifecycle / Cleanup Contract Hardening + +更新时间:2026-05-15 + +## 一句话结论 + +P17.3 的目标不是做真实卸载器、市场页或 Cloud 管理台,而是在 P17.2 source / install / schema contract 已完成后,把正式 `agent-apps` 入口的生命周期、失败退出、清理演练、残留审计和 namespace 归属固定成可测试契约。 + +P17.3 结束后,用户在正式入口里应该能明确知道:一个 App 是否已启用、能否启动、卸载会影响哪些本地 namespace、哪些数据会保留、哪些清理动作只是 rehearsal、失败后如何恢复,以及 Cloud / LimeCore 不会接管本地删除或运行时状态。 + +## 背景 + +P17.2 已完成以下主链: + +1. `agent-apps` 正式入口不再直接使用 Lab fixture 作为安装证据。 +2. local / cloud 安装都先生成 install review,用户确认后才写入 P11 installed state repository。 +3. Cloud release metadata 已落成 release descriptor,缺少 verified package source 会被 blocker 阻断。 +4. `packageUrl` fetch / staging / manifest extraction 已通过 `agent_app_fetch_cloud_package` 集中命令进入 P12 verified cache。 +5. P17.2.5 已用上游 public schema、reference CLI 和标准 `content-factory-app` 示例包做 projection / readiness / review descriptor cross-check。 + +剩余问题不是“能不能装”,而是“装上之后怎么失败、停用、卸载、清理、审计且不留下不可解释状态”。如果这一层没有契约,后续 P17.4 runtime surface production hardening 会被迫同时解决 lifecycle、storage、overlay、secret、evidence 和 runtime package 多个问题,成本和风险都会放大。 + +## 目标 + +1. 固定正式入口生命周期状态:`enabled / disabled / uninstall-rehearsal / cleanup-blocked / residual-warning` 等状态必须来自同一 installed state / cleanup contract。 +2. 保证 disabled App 不能被 `agent-apps` 启动,也不能被 runtime surface 通过 fallback 绕过。 +3. 复用 P15 cleanup preview 与 P16-H residual audit,不新增第二套 scanner、repository 或 evidence store。 +4. 把 package code、setup state、overlay、secret binding、artifact、evidence、app storage 分成可解释 namespace。 +5. 支持 keep-data uninstall rehearsal 和 delete-data rehearsal,但本阶段仍不执行真实 delete-data。 +6. 为 P17.4 提供前置条件:runtime 只需要相信 lifecycle contract,不再自己判断禁用、残留或清理状态。 + +## 非目标 + +- 不发布 marketplace。 +- 不做 Cloud 管理台。 +- 不做真实 delete-data。 +- 不做完整行业内容系统。 +- 不把 `agent-app-lab` 的 smoke 当正式入口证据。 +- 不让 App 直接调用 Lime internal store、Tauri command 或 raw Worker。 +- 不解决 P17.4 runtime production hardening;P17.4 只在 P17.3 状态契约稳定后继续。 + +## 客户端 / 服务端边界 + +| 边界 | P17.3 客户端职责 | 非客户端职责 | +|---|---|---| +| Lifecycle | 持久化 enable / disable、uninstall rehearsal、cleanup review、residual audit summary。 | Cloud 不远程修改本地 lifecycle state。 | +| Cleanup | 生成 namespace 级 cleanup plan、export-before-delete preview、evidence。 | Cloud 不执行本地文件删除。 | +| Package | 复用 P12 verified cache 和 P17.2 release descriptor。 | P17.3 不重新下载或重验 package。 | +| Overlay / setup | 只记录绑定与清理归属,不把 overlay 写入 package hash。 | LimeCore 只提供 tenant / release metadata,不保管用户 overlay 值。 | +| Secret binding | 只记录 binding key / provider / scope,不导出 secret value。 | 不把 secret 明文写入 evidence。 | +| Runtime | 只消费 lifecycle allow / block 结果。 | P17.3 不启动生产 runtime hardening。 | + +## 当前事实源分类 + +| 分类 | 对象 | P17.3 规则 | +|---|---|---| +| current | `src/features/agent-app/install/*` | installed state、setup state、cleanup preview、package cache、install review 的客户端事实源。 | +| current | `src/lib/api/agentApps.ts` | 允许集中调用 Tauri command 的 API gateway;feature island 不直接 `safeInvoke` / `invoke`。 | +| current | `src/features/agent-app/ui/AgentAppsPage.tsx` | 正式用户入口,展示 lifecycle / cleanup review / residual audit。 | +| current | `src/features/agent-app/runtime/entryRuntimeGuard.ts` | 启动前唯一 guard;P17.3 要让它消费 disabled / cleanup-blocked 状态。 | +| current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 当前执行计划。 | +| reference | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 source / install / schema 已完成记录。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | 标准、public schema、reference CLI 和示例包。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | 服务端 control-plane;不写客户端 runtime / cleanup 实现。 | +| compat | `agent-app-lab` lifecycle / cleanup panel | 仍可作为研发验证入口,但不能作为正式入口验收证据。 | +| dead | `SceneApp` / `contentEngineering*` / `sceneapp_*` | 不恢复、不迁移、不兼容。 | + +## 用户故事 + +| ID | 用户故事 | 验收要点 | +|---|---|---| +| P17.3-US1 | 作为用户,我能在正式 App 列表里禁用某个 App。 | disabled 状态持久化;刷新后仍禁用;启动按钮不可用;runtime guard 返回 blocked。 | +| P17.3-US2 | 作为用户,我能重新启用被禁用的 App。 | enabled 状态持久化;readiness / setup 重新参与启动判断;不重新安装 package。 | +| P17.3-US3 | 作为用户,我能在卸载前看到会清理哪些数据。 | cleanup rehearsal 列出 package cache、setup state、overlay binding、storage、artifact、evidence;不执行真实删除。 | +| P17.3-US4 | 作为用户,我能选择保留数据卸载演练。 | keep-data rehearsal 只标记 package / lifecycle action;storage / artifact / evidence 保留原因清晰。 | +| P17.3-US5 | 作为维护者,我能导出 cleanup evidence。 | evidence 不含 secret value;包含 appId、version、namespace、action、blocked reason、timestamp。 | +| P17.3-US6 | 作为维护者,我能看到 residual audit。 | audit 只检查 Agent App namespace;残留按 blocking / warning / informational 分类。 | +| P17.3-US7 | 作为开发者,我能证明 App 无法绕过 lifecycle。 | `src/features/agent-app` 无直接 Tauri / raw Worker;launch 必经 P14 guard 和 P17.3 lifecycle gate。 | + +## 状态模型 + +```mermaid +stateDiagram-v2 + [*] --> Installed + Installed --> Enabled: install confirmed + Enabled --> Disabled: disable + Disabled --> Enabled: enable + Enabled --> UninstallRehearsal: uninstall preview + Disabled --> UninstallRehearsal: uninstall preview + UninstallRehearsal --> CleanupBlocked: blocker found + UninstallRehearsal --> CleanupReady: rehearsal complete + CleanupBlocked --> Disabled: keep installed state + CleanupReady --> Disabled: keep-data rehearsal + CleanupReady --> RemovedPreview: delete-data rehearsal only + RemovedPreview --> Disabled: no real delete in P17.3 +``` + +状态口径: + +- `Enabled`:允许进入 P14 guard,但仍需 readiness / permission / policy 通过。 +- `Disabled`:不能启动;不删除 package 或数据。 +- `UninstallRehearsal`:只生成预览与 evidence,不删除。 +- `CleanupBlocked`:存在不可解释 namespace、secret value 泄露风险或 hash / identity 不匹配。 +- `CleanupReady`:清理计划可解释,但 P17.3 仍不真实执行 delete-data。 +- `RemovedPreview`:只表示 rehearsal 结果,不能从 repository 删除真实 installed state。 + +## 架构图 + +```mermaid +flowchart TD + Page[AgentAppsPage formal entry] --> Lifecycle[Lifecycle action descriptor] + Lifecycle --> Repository[P11 installed state repository] + Lifecycle --> Cleanup[P15 cleanup preview] + Cleanup --> Evidence[P16-H cleanup evidence] + Cleanup --> Audit[P16-H residual audit] + Repository --> Guard[P14 entry runtime guard] + Guard --> Runtime[P17.4 runtime surface] + Cleanup --> Namespace[Namespace classifier] + Namespace --> Package[P12 verified package cache] + Namespace --> Setup[setup state] + Namespace --> Overlay[tenant/workspace/user overlay binding] + Namespace --> Secrets[secret binding metadata] + Namespace --> Storage[app storage namespace] + Namespace --> Artifacts[artifact/evidence namespace] +``` + +## 时序图 + +### Disable / Enable + +```mermaid +sequenceDiagram + participant User + participant Page as AgentAppsPage + participant Lifecycle as lifecycle contract + participant Repo as installed state repository + participant Guard as entry runtime guard + + User->>Page: disable app + Page->>Lifecycle: build disable review + Lifecycle->>Repo: persist disabled state + Repo-->>Page: refreshed installed app + User->>Page: launch disabled app + Page->>Guard: check launch + Guard-->>Page: blocked(disabled) + User->>Page: enable app + Page->>Lifecycle: build enable review + Lifecycle->>Repo: persist enabled state +``` + +### Uninstall Rehearsal + +```mermaid +sequenceDiagram + participant User + participant Page as AgentAppsPage + participant Cleanup as cleanup planner + participant Evidence as evidence builder + participant Audit as residual audit + + User->>Page: open uninstall rehearsal + Page->>Cleanup: build namespace cleanup plan + Cleanup-->>Page: keep-data/delete-data preview + User->>Page: export evidence + Page->>Evidence: build cleanup evidence summary + Evidence-->>Page: downloadable JSON preview + Page->>Audit: run namespace residual audit + Audit-->>Page: blocking/warning/info summary +``` + +## 流程图 + +```mermaid +flowchart TD + Start[用户选择 lifecycle action] --> Kind{Action kind} + Kind -->|disable| Disable[持久化 disabled] + Kind -->|enable| Enable[持久化 enabled] + Kind -->|uninstall rehearsal| Plan[生成 cleanup plan] + Plan --> Check{是否有 blocker} + Check -->|yes| Blocked[展示 cleanup-blocked] + Check -->|no| Preview[展示 keep-data / delete-data rehearsal] + Preview --> Evidence[导出 evidence JSON] + Evidence --> Audit[运行 residual audit] + Disable --> GuardBlock[launch guard blocked] + Enable --> GuardReady[回到 readiness / permission / policy] +``` + +## 用例 + +### UC1:禁用后启动被阻断 + +1. 用户在 `agent-apps` 中点击禁用。 +2. 系统生成 disable review,写回 installed state。 +3. 页面刷新后 App 显示 disabled。 +4. 用户尝试启动,P14 guard 读取 disabled 状态并返回 blocker。 +5. 验收:不会进入 runtime surface,不会启动 App UI。 + +### UC2:保留数据卸载演练 + +1. 用户选择 uninstall rehearsal。 +2. 系统生成 cleanup plan,列出 package cache、setup state、overlay binding、storage、artifact、evidence。 +3. 用户选择 keep-data rehearsal。 +4. 系统导出 evidence,但不删除 installed state 或 namespace 数据。 +5. 验收:evidence 能说明哪些数据保留及原因。 + +### UC3:delete-data 演练发现 blocker + +1. 用户选择 delete-data rehearsal。 +2. cleanup planner 发现 secret binding 含不可导出 value 或 namespace 不可归属。 +3. 页面显示 cleanup-blocked。 +4. residual audit 输出 blocking issue。 +5. 验收:不执行真实删除,不把 App 从 repository 移除。 + +## 实施拆解 + +| 阶段 | 交付 | 范围 | 验收 | +|---|---|---|---| +| P17.3.0 | 计划收口。 | README、implementation plan、P17 contract、P17 source doc。 | current 文档下一刀统一指向 P17.3,不再指向 P17.2.5。 | +| P17.3.1 | 已完成最小实现:Lifecycle action descriptor。 | `install/lifecycleAction.ts` 纯函数:disable / enable / uninstall rehearsal descriptor;`agent_app_uninstall` 收口为 rehearsal-only。 | 单测覆盖 enabled / disabled / out-of-scope blocker / no-op;mock 与 Rust 命令均不执行真实删除。 | +| P17.3.2 | 已完成最小实现:Formal page lifecycle UI。 | `AgentAppsPage` disable / enable / uninstall preview / confirm / launch flow 消费 lifecycle descriptor。 | UI test 覆盖 disable、enable、disabled launch blocked。 | +| P17.3.3 | 已完成最小实现:Cleanup namespace classifier。 | 复用 cleanup plan,补 package / setup / overlay / secret / storage / artifact / evidence 分类。 | 单测覆盖 keep-data 与 delete-data rehearsal 差异。 | +| P17.3.4 | 已完成最小实现:Evidence export / residual audit formalize。 | 复用 P16-H evidence / audit,正式入口展示 JSON preview。 | evidence 不含 secret value;audit 只查 Agent App namespace。 | +| P17.3.5 | 已完成最小实现:Guard integration。 | P14 guard 消费 lifecycle disabled / cleanup-blocked。 | disabled / cleanup-blocked 都不能启动;enabled 仍走 readiness。 | +| P17.3.6 | 已完成:Boundary and regression。 | feature island boundary rg、UI tests、schema cross-check 回归。 | 不新增 Tauri command;不直接 `safeInvoke`;P17.2.5 cross-check 继续通过。 | + +## 验收标准 + +1. 禁用状态持久化,刷新后仍禁用。 +2. disabled / cleanup-blocked App 无法从正式入口启动。 +3. enable 不重新安装 package、不改 package hash。 +4. uninstall rehearsal 不执行真实 delete-data。 +5. cleanup evidence 不含 secret value,不写入 App package。 +6. residual audit 只检查 Agent App namespace,不扫描全局用户目录。 +7. keep-data 与 delete-data rehearsal 的数据影响可解释。 +8. `agent-app-lab` 仍只是研发验证入口,不能替代正式入口证据。 +9. `src/features/agent-app` 不直接 `safeInvoke` / `invoke` / Tauri command / raw Worker。 +10. Cloud / LimeCore 不运行 Agent、不渲染 UI、不接管本地 storage 或 delete-data。 + +## 最小验证 + +P17.3 文档收口: + +```bash +rg -n "P17\\.2\\.5|P17\\.3" docs/roadmap/agentapp +git diff --check -- docs/roadmap/agentapp +``` + +P17.3 实现后至少执行: + +```bash +npm run test -- src/features/agent-app/install/cleanupPlan.test.ts src/features/agent-app/install/cleanupResidualAudit.test.ts src/features/agent-app/install/cleanupRehearsalEvidence.test.ts +npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +npm run test -- src/features/agent-app/schema/referenceCliCrossCheck.test.ts +rg -n "safeInvoke|invoke\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\(" src/features/agent-app || true +git diff --check -- docs/roadmap/agentapp src/features/agent-app src/lib/api/agentApps.ts +``` + +如改动 Tauri command / bridge,必须追加: + +```bash +npm run test:contracts +``` + +P17.3 不要求 `verify:gui-smoke` 全量通过;若改动正式入口 GUI 主路径,则至少补 `AgentAppsPage` / `AgentAppRuntimePage` 定向 UI 测试。P17.5 才新增正式入口 smoke,不能用 Lab smoke 冒充。 + +## 当前下一刀 + +P17.3.0 到 P17.3.6 已完成阶段闭环:lifecycle descriptor、正式页面 UI、cleanup namespace classifier、evidence / residual audit、guard integration 与 boundary regression 均已收口。下一刀进入 P17.4 runtime surface production hardening:继续只做客户端 runtime surface,不碰真实删除、不新增 Cloud Agent、不改 LimeCore control-plane 职责。 + +## 2026-05-15 P17.3.1 实施记录 + +本轮完成内容: + +1. 新增 `src/features/agent-app/install/lifecycleAction.ts`,作为 P17.3 lifecycle action descriptor 的纯函数事实源。 +2. `buildAgentAppLifecycleToggleDescriptor()` 生成 disable / enable request,并能识别 no-op。 +3. `buildAgentAppLifecycleUninstallRehearsalDescriptor()` 复用 cleanup evidence 与 residual audit,固定 `realDeleteAllowed: false` 和 `completionEffect: rehearsal-only`。 +4. `buildAgentAppLifecycleLaunchGate()` 明确 disabled App 的启动阻断语义,为后续 P14 guard 接线做准备。 +5. `agent_app_uninstall` Rust 命令与浏览器 mock 已收口为 rehearsal-only:返回演练摘要和 installed list,但不删除 installed state、不删除本地文件 / 目录。 +6. `docs/aiprompts/commands.md` 已补 Agent App uninstall 命令边界:P17.3 之前禁止真实 delete-data。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/lifecycleAction.test.ts src/lib/tauri-mock/core.test.ts src/features/agent-app/ui/AgentAppsPage.test.tsx` | 通过,3 files / 36 tests。 | + +当时未完成: + +1. P17.3.2 formal page lifecycle UI 仍未消费 descriptor;当前页面已有 disable / enable / rehearsal 操作,但还没把 descriptor 作为统一 view model。该项已在后续 P17.3.2 记录中收口。 +2. P17.3.5 guard integration 仍未完成;`buildAgentAppLifecycleLaunchGate()` 已新增,但 P14 guard 尚未直接消费。 +3. P17.4 runtime production hardening 与 P17.5 formal smoke 仍保持后续。 + +## 2026-05-15 P17.3.2 实施记录 + +协作分工: + +1. 本轮只触碰客户端正式入口 `AgentAppsPage`、对应 UI 回归和本 P17.3 roadmap 记录。 +2. 不触碰 `/Users/coso/Documents/dev/ai/limecloud/limecore`、`agentknowledge`、Cloud control-plane 文档和上游 `agentapp` 标准仓库。 +3. 不继续改 Tauri command / mock / API gateway;P17.3.1 已完成的 rehearsal-only 命令边界保持原样。 + +本轮完成内容: + +1. `AgentAppsPage` 的 disable / enable 操作改为先生成 `buildAgentAppLifecycleToggleDescriptor()`,再把 descriptor request 交给 `setAgentAppDisabled()`;no-op 不再触发持久化请求。 +2. uninstall preview 改为先用 installed state 构建 cleanup plan,再生成 `buildAgentAppLifecycleUninstallRehearsalDescriptor()`;blocked descriptor 不继续调用远端 preview。 +3. uninstall confirm 改为复用 preview 阶段生成的 descriptor request,保持 P17.3 `rehearsal-only` 语义,不从页面散拼请求。 +4. 正式入口 launch 改为先经过 `buildAgentAppLifecycleLaunchGate()`;disabled App 即使从导航参数触发自动启动,也不会进入 runtime surface。 +5. UI 回归补充 disabled 自动启动阻断断言,并把 disable / enable 请求断言扩展到 descriptor 生成的 `updatedAt`。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/lifecycleAction.test.ts src/features/agent-app/ui/AgentAppsPage.test.tsx` | 通过,2 files / 16 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| `git diff --no-index --check /dev/null ` | 通过,覆盖本轮触碰的 roadmap、`AgentAppsPage.tsx`、`AgentAppsPage.test.tsx`。 | +| `npm run typecheck` | 未完成;运行约 3 分钟仍在编译,为避免与隔壁任务抢资源已终止,不计为通过。 | + +未完成: + +1. P17.3.3 cleanup namespace classifier 仍未完成;当前仍复用 P15 cleanup plan 的既有分类。该项已在后续 P17.3.3 记录中收口。 +2. P17.3.4 evidence export / residual audit formalize 仍未接入正式入口展示。 +3. P17.3.5 guard integration 仍未把 P14 guard 内部直接改成消费 lifecycle descriptor;本轮只封住了正式页面启动入口。 + +## 2026-05-15 P17.3.3 实施记录 + +协作分工: + +1. 本轮只触碰客户端 cleanup contract、Agent App feature island 内部 adapter/mock uninstall projection、对应测试和本 P17.3 roadmap。 +2. 不触碰 `/Users/coso/Documents/dev/ai/limecloud/limecore`、`agentknowledge`、Cloud control-plane 文档和上游 `agentapp` 标准仓库。 +3. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不改变 P17.3 rehearsal-only 卸载边界。 + +本轮完成内容: + +1. 新增 `src/features/agent-app/install/cleanupNamespaceClassifier.ts`,作为 cleanup target 分类的纯函数事实源。 +2. `listAgentAppCleanupNamespaceGroups()` 把 cleanup plan 分成 `lifecycle / package / setup / overlay / storage / artifact / evidence / task / secret / log / export`。 +3. `classifyAgentAppCleanupNamespaceTargets()` 统一生成 keep-data / delete-data 的 delete / retain 结果,并保留 `UNSAFE_TARGET / OUT_OF_SCOPE` blocker。 +4. `buildCleanupPlan()` 补齐 overlay refs、secret refs、artifact refs、evidence refs、task refs;secret 仍只记录 binding ref,不记录 secret value。 +5. `buildAgentAppCleanupRehearsalEvidence()` 改为消费 namespace classifier,不再私有维护第二套 target group / blocker / secret sanitization 逻辑。 +6. Lab install flow、install review cleanup count、adapter/mock uninstall projection 已改为消费同一分类口径,避免 keep-data / delete-data 统计分叉。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/cleanupNamespaceClassifier.test.ts src/features/agent-app/install/cleanupRehearsalEvidence.test.ts src/features/agent-app/install/cleanupResidualAudit.test.ts src/features/agent-app/install/lifecycleAction.test.ts` | 通过,4 files / 13 tests。 | +| `npm run test -- src/features/agent-app/install/installReview.test.ts src/features/agent-app/install/labInstallFlow.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/ui/AgentAppsPage.test.tsx` | 通过,4 files / 23 tests。 | +| `npm run test -- src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts` | 通过,2 files / 11 tests。 | +| `npm run test -- src/features/agent-app/ui/AgentAppManagerPanel.test.tsx` | 通过,1 file / 3 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| `git diff --no-index --check /dev/null ` | 通过,覆盖本轮触碰文件。 | +| `nice -n 10 npm run typecheck` | 未通过;Agent App 相关新增类型错误已修复,剩余失败位于 `src/features/experts/expertAgentInstances.test.ts:117`,不属于本轮改动范围,未处理以避免和隔壁任务打架。 | + +未完成: + +1. P17.3.4 evidence export / residual audit formalize 仍未接入正式入口展示。该项已在后续 P17.3.4 记录中收口。 +2. P17.3.5 guard integration 仍未把 P14 guard 内部直接改成消费 lifecycle descriptor。 +3. P17.3.6 boundary and regression 仍需在 P17.3 收尾时集中回归 schema cross-check 与 feature island boundary。 + +## 2026-05-15 P17.3.4 实施记录 + +协作分工: + +1. 本轮只触碰正式客户端入口 `AgentAppsPage`、对应 UI 回归和本 P17.3 roadmap。 +2. 不新增 i18n key;正式入口复用已覆盖五语言的 cleanup evidence / residual audit 展示 key,避免新增未本地化文案。 +3. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不改变 P17.3 rehearsal-only 卸载边界。 + +本轮完成内容: + +1. `AgentAppsPage` 在 uninstall preview 中展示 descriptor 生成的 cleanup evidence JSON preview。 +2. 同一预览区展示 residual audit 四类摘要:retained、pending deletion、blocked out-of-scope、repository issue。 +3. evidence JSON 来自 `buildAgentAppLifecycleUninstallRehearsalDescriptor()`,因此继承 P17.3.3 namespace classifier,并继续隐藏 secret value。 +4. UI 回归断言正式入口能看到 cleanup evidence、residual audit、overlay namespace、secret-ref 分类,且不会泄露原始 secret value。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/install/cleanupNamespaceClassifier.test.ts src/features/agent-app/install/cleanupRehearsalEvidence.test.ts src/features/agent-app/install/cleanupResidualAudit.test.ts` | 通过,4 files / 21 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| `git diff --no-index --check /dev/null ` | 通过,覆盖本轮触碰文件。 | +| `nice -n 10 npm run typecheck` | 未重跑;上一次仍因 `src/features/experts/expertAgentInstances.test.ts:117` 失败,该失败不属于 Agent App 本轮改动。 | + +未完成: + +1. P17.3.5 guard integration 仍未把 P14 guard 内部直接改成消费 lifecycle descriptor。该项已在后续 P17.3.5 记录中收口。 +2. P17.3.6 boundary and regression 仍需集中回归 schema cross-check 与 feature island boundary。 + +## 2026-05-15 P17.3.5 实施记录 + +协作分工: + +1. 本轮只触碰 P14 runtime guard、正式入口 guard 调用、Lab guard 调用、对应 guard / UI 回归和本 P17.3 roadmap。 +2. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不改变 P17.3 rehearsal-only 卸载边界。 +3. 不触碰 Cloud / LimeCore / agentknowledge / 上游 `agentapp` 标准仓库。 + +本轮完成内容: + +1. `EvaluateAgentAppEntryRuntimeGuardParams` 新增 `lifecycle` 输入,允许调用方传入 disabled / cleanup-blocked 状态。 +2. `evaluateAgentAppEntryRuntimeGuard()` 新增 `AGENT_APP_DISABLED` 与 `CLEANUP_BLOCKED` blocker;这两个 blocker 在权限确认、runtime package、readiness 之外独立生效。 +3. `AgentAppsPage` 在调用 P14 guard 时传入 installed state disabled 状态,以及当前 uninstall descriptor 的 cleanup-blocked 状态。 +4. `AgentAppLabPage` 的 manager guard 调用也传入 selected state disabled 状态,避免 Lab manager 与正式入口口径分叉。 +5. `entryRuntimeGuard.test.ts` 新增 disabled / cleanup-blocked guard 单测,证明不能只依赖页面按钮状态。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/runtime/entryRuntimeGuard.test.ts src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,3 files / 28 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| `git diff --no-index --check /dev/null ` | 通过,覆盖本轮触碰文件。 | +| `nice -n 10 npm run typecheck` | 未重跑;上一次仍因 `src/features/experts/expertAgentInstances.test.ts:117` 失败,该失败不属于 Agent App 本轮改动。 | + +待补验证: + +1. P17.3.6 仍需集中执行 schema cross-check 和阶段汇总回归。 + +未完成: + +1. P17.3.6 boundary and regression 仍未收口。该项已在后续 P17.3.6 记录中收口。 +2. P17.4 runtime production hardening 与 P17.5 formal smoke 仍保持后续。 + +## 2026-05-15 P17.3.6 实施记录 + +协作分工: + +1. 本轮只做 P17.3 阶段回归与边界验证,不继续扩大实现范围。 +2. 不新增 Tauri command,不改 `src/lib/api/agentApps.ts`,不改变 P17.3 rehearsal-only 卸载边界。 +3. 不触碰 Cloud / LimeCore / agentknowledge / 上游 `agentapp` 标准仓库。 + +本轮验证内容: + +1. reference CLI / public schema cross-check 继续通过,证明 P17.2.5 标准对齐未被 P17.3 lifecycle / cleanup 改动破坏。 +2. 正式入口 `AgentAppsPage`、runtime surface `AgentAppRuntimePage`、P14 guard、cleanup classifier / evidence / audit、Lab manager、adapter/mock uninstall projection 均完成定向回归。 +3. feature island boundary 扫描无命中:`src/features/agent-app` 仍不直接 `safeInvoke` / `invoke` / Tauri command / raw Worker。 +4. touched-file whitespace check 通过。 +5. `nice -n 10 npm run typecheck` 通过;上轮外部 experts test 类型错误已不再出现。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/schema/referenceCliCrossCheck.test.ts src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/features/agent-app/runtime/entryRuntimeGuard.test.ts` | 通过,4 files / 27 tests。 | +| `npm run test -- src/features/agent-app/install/cleanupNamespaceClassifier.test.ts src/features/agent-app/install/cleanupRehearsalEvidence.test.ts src/features/agent-app/install/cleanupResidualAudit.test.ts src/features/agent-app/install/lifecycleAction.test.ts src/features/agent-app/install/installReview.test.ts src/features/agent-app/install/labInstallFlow.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/ui/AgentAppManagerPanel.test.tsx` | 通过,9 files / 36 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 通过,无命中。 | +| `git diff --no-index --check /dev/null ` | 通过,覆盖本轮触碰文件。 | +| `nice -n 10 npm run typecheck` | 通过。 | + +P17.3 阶段剩余: + +1. 不再声明 P17.3 内部剩余实现项;P17.3 已完成当前规划闭环。 +2. P17.4 runtime surface production hardening 与 P17.5 formal smoke 仍是后续阶段,不混入 P17.3。 diff --git a/docs/roadmap/agentapp/p17-source-install-contract-hardening.md b/docs/roadmap/agentapp/p17-source-install-contract-hardening.md new file mode 100644 index 000000000..f8baa6667 --- /dev/null +++ b/docs/roadmap/agentapp/p17-source-install-contract-hardening.md @@ -0,0 +1,605 @@ +# Agent App P17.2 Source / Install Contract Hardening + +更新时间:2026-05-15 + +## 一句话结论 + +P17.2 不做 marketplace,也不做 Cloud 管理台。它只把 P17.1 已经进入正式入口的 `agent-apps` 页面继续硬化成可审查、可取消、可复核、可刷新、可回滚的安装入口:所有 local folder、Cloud release metadata、registration、offline fallback 都必须先收敛成同一份 source / install review 契约,再写入 P11 installed state repository。 + +```text +Source discovery + -> Package verify + -> Projection / readiness preview + -> Install review + -> Installed state repository + -> Entry guard + -> Runtime surface +``` + +## 背景 + +P17.1 已经完成正式入口的 route / nav / copy 最小硬化:`agent-apps` 是用户入口,`agent-app-lab` 是研发验证入口,UI entry 会进入独立 `agent-app` runtime surface。 + +但正式入口还不能继续停留在 Lab 语义上: + +1. local folder、Cloud catalog、bootstrap payload、seeded demo 现在容易在 UI 上被看成同一种“可安装 App”。 +2. Cloud registration 现在只是按钮前置条件,还没有成为 install review 的正式状态输入。 +3. 安装前缺少统一 review descriptor,用户无法在写入 repository 前看到 appId、version、source、hash、manifest、capability 和数据边界摘要。 +4. Cloud / LimeCore 的 release metadata 只应作为 source 输入,不能让客户端绕过 package verify、readiness、permission guard 或 local storage boundary。 +5. Lab fixture 可以继续服务研发验证,但不能继续泄漏成正式入口的默认安装路径。 + +上游 Agent App v0.3 标准已经明确:宿主应从包含 `APP.md` 的本地目录、registry catalog item、tenant bootstrap payload、private package URL 或 development fixture 发现 App;所有来源都应收敛到同一个 package identity 和 manifest parser。LimeCore 服务端路线图进一步明确:Cloud 只提供 catalog / release / tenant enablement / policy metadata,未激活注册码时不下发 `packageUrl / packageHash / manifestHash`,真实 release 必须 pin 到 `https` package URL 和完整 `sha256:<64 hex>` hash。Lime Desktop P17.2 的任务就是把这个原则落到正式入口。 + +## 标准升级影响 + +上游 `/Users/coso/Documents/dev/ai/limecloud/agentapp` 已补齐宿主实现视角的 v0.3 文档:discovery / installation、release and distribution、runtime model、security model、overlay resolver、readiness runner、public JSON Schema、reference CLI 与完整 `content-factory-app` 示例包。P17.2 不需要改方向,但要把“Cloud install hardening”从一个 UI 状态补丁升级为可机械验证的安装契约: + +| 上游标准约束 | P17.2 对应动作 | +|---|---| +| Release 必须 pin 到具体 package,而不是只给 channel 或 catalog card。 | P17.2.4 新增 Cloud release descriptor 归一化,至少包含 `appId`、`version`、`sourceUri`、`packageUrl`、`packageHash`、`manifestHash`、`compatibility`、`releaseChannel`、`tenantEnablementRef`、可选 `signatureRef`。 | +| 安装审查要基于 projection,且 projection 不执行 App code。 | P17.2.4 的 review 来源必须是 verified package 解析后的 manifest / projection / readiness summary,不能继续把 seeded demo、Cloud card 或 Lab fixture 当成 production review。 | +| cached fallback 只服务已安装版本。 | P17.2.4 允许离线启动已安装同 hash package,但禁止在离线时从 Cloud metadata 生成新安装态。 | +| 未激活 tenant registration 不暴露 package metadata。 | P17.2.4b 必须把 `registration-required / expired / revoked` 停在 source state,不能从 seeded fixture、本地开发目录或历史 cache 伪造新安装 review。 | +| seeded catalog 不能携带假 release。 | P17.2.4b 只承认可验证的 `packageUrl / packageHash / manifestHash` 或已安装同 hash cache,缺 source 时展示 blocker。 | +| Public schema / reference CLI 是机械契约。 | P17.2.5 使用上游 `docs/public/schemas/*`、`agentapp-ref` 与 `docs/examples/content-factory-app` 做客户端 projection / readiness cross-check。 | +| Overlay 不进入 package hash。 | P17.3 lifecycle / cleanup hardening 要把 tenant / workspace / user overlay、secret binding、setup state 和 package code 分离。 | +| Runtime 必须通过 injected SDK handles。 | P17.4 继续硬化 runtime surface;当前 dev resolver 只保留开发态,生产路径必须来自 verified package cache。 | + +## 目标 + +| 目标 | 说明 | +|---|---| +| 统一 source 模型 | local folder、cloud release metadata、bootstrap catalog 和 offline cached release 都编译成同一类 source ref。 | +| 增加 install review | 写入 installed state 前展示 identity、hash、manifest、capability、permission、storage、cleanup 和 readiness 摘要。 | +| 收紧 registration 语义 | registration 只改变 tenant enablement / source availability,不写入 App package,不绕过 verify。 | +| 固定 release descriptor | Cloud metadata 必须落成具体 release descriptor,不能只以 channel、注册码或 catalog card 代表可安装 package。 | +| 接入标准校验 | P17.2.5 以后,客户端 projection / readiness 要能和上游 schema、reference CLI、示例包做 cross-check。 | +| 收掉 Lab-only 泄漏 | 正式入口不再把 development fixture 当作真实 source;fixture 只留在 `agent-app-lab`。 | +| 保持客户端边界 | Cloud / LimeCore 只提供 catalog / release / tenant metadata;客户端仍负责 install、verify、projection、readiness、runtime 和 cleanup。 | + +## 非目标 + +1. 不发布 public marketplace。 +2. 不做 Cloud 管理台、审核后台、支付分账或完整企业控制台。 +3. 不执行真实 delete-data;仍只做 uninstall rehearsal / residual audit。 +4. 不新增完整远程下载器或 raw worker sandbox。 +5. 不把 内容工厂扩成行业 SaaS。 +6. 不新增第二套 store、installer、package cache、readiness runner 或 cleanup scanner。 +7. 不让 `src/features/agent-app` 直接 `safeInvoke` / `invoke` 或调用 Tauri command。 + +## 架构图 + +```mermaid +flowchart TD + Apps[agent-apps / AgentAppsPage] --> SourceState[Source State Presenter] + Apps --> Review[Install Review Descriptor] + Apps --> Api[agentApps API Gateway] + + Api --> Local[Local APP.md Inspector] + Api --> Cloud[Cloud Catalog / Release Metadata] + Api --> Registration[Registration Enablement] + Api --> Cache[P12 Package Cache + Verify] + Api --> Repo[P11 Installed State Repository] + + Local --> Cache + Cloud --> Cache + Registration --> Cloud + Cache --> Review + Review --> Repo + Repo --> Guard[P14 Entry Runtime Guard] + Guard --> Runtime[agent-app Runtime Surface] + + Lab[agent-app-lab] -. development fixture only .-> Local + LimeCore[LimeCore / Cloud] -. metadata only .-> Cloud +``` + +## Source State 契约 + +正式入口只暴露可解释的 source state,不暴露内部实现细节。 + +| State | 触发条件 | UI 行为 | 可否安装 | +|---|---|---|---| +| `local-selected` | 用户选择包含 `APP.md` 的本地目录;企业定制 manifest 仍必须先有 Cloud `registration-active`。 | 展示本地路径摘要、manifest hash、package hash、capability summary;未注册企业定制包直接阻断 sideload。 | 普通包需要通过 install review;企业定制包还需要注册码已激活。 | +| `local-cancelled` | 用户取消目录选择。 | 只提示取消,不写入状态。 | 否。 | +| `local-invalid` | 缺 `APP.md`、frontmatter 非法、manifest schema 不通过或 hash 缺失。 | 展示 blocker,不保留半安装态。 | 否。 | +| `cloud-discovered` | Cloud / bootstrap 返回 release metadata。 | 展示 appId、version、channel、license、hash、registration 状态。 | 取决于 enabled / registration。 | +| `registration-required` | `registrationRequired=true` 且 `registrationState!=active`。 | 展示注册码输入和原因。 | 否。 | +| `registration-active` | Cloud 返回 active enablement。 | 可以进入 install review,但仍需 package verify。 | 是。 | +| `cloud-disabled` | release 或 tenant enablement disabled。 | 展示 disabledReason。 | 否。 | +| `offline-cached` | 远端不可用但本地已有 verified package / installed state。 | 允许启动已安装版本,不能伪装成新安装。 | 仅已安装版本可用。 | +| `installed` | repository 中已有同 appId + version 或同 release。 | 展示已安装状态和 refresh 时间。 | 不重复安装,允许 refresh / launch。 | + +## Install Review Descriptor + +P17.2 应新增一个前端可测试的 review descriptor,而不是把 review 文案散在 JSX 中。 + +```text +AgentAppInstallReview + identity: appId / version / manifestVersion + source: sourceKind / sourceUri / releaseId / channel / catalogSource / tenantEnablementRef + integrity: packageUrl / packageHash / manifestHash / signatureRef + manifestSummary: title / entries / runtimePackage / compatibility + capabilitySummary: required capabilities / tools / secrets / knowledge slots + dataBoundary: storage namespace / artifact namespace / cleanup targets + readinessPreview: status / blockers / setup tasks + policyPreview: permissions / tenant defaults / disabled reason + provenance: loadedAt / checkedAt / generatedAt +``` + +最低要求: + +1. review descriptor 是纯数据,可被单测 snapshot 或字段断言覆盖。 +2. local 和 Cloud release 使用同一个 descriptor。 +3. Cloud registration 只影响 `policyPreview` / source availability,不改 package manifest。 +4. descriptor 进入 installed state 前生成;repository 只保存 verify 通过后的结果。 +5. descriptor 的 hash、manifest、capability、namespace 会进入 evidence / cleanup plan 的 provenance。 + +## 安装时序 + +```mermaid +sequenceDiagram + participant User as User + participant Apps as agent-apps + participant Api as agentApps API + participant Source as Source Resolver + participant Review as Install Review + participant Cache as Package Cache + participant Repo as Installed Repository + + User->>Apps: 选择 local folder 或 cloud release + Apps->>Api: resolve source + Api->>Source: inspect APP.md / read release metadata + Source-->>Api: source ref / release descriptor + Api-->>Apps: source state + Apps->>Api: build reviewed package + Api->>Cache: fetch / read / verify packageHash + manifestHash + Cache-->>Api: verified package descriptor + Api->>Review: build projection + readiness preview + Review-->>User: 展示安装审查 + User->>Apps: confirm install + Apps->>Api: install reviewed package + Api->>Repo: save installed state + Repo-->>Apps: refreshed installed list +``` + +## Registration 时序 + +```mermaid +sequenceDiagram + participant User as User + participant Apps as agent-apps + participant Api as agentApps API + participant Cloud as LimeCore / Cloud + participant Source as Source State + + Apps-->>User: 展示 registration-required + User->>Apps: 输入注册码 + Apps->>Api: submit registration code + Api->>Cloud: tenant enablement request + Cloud-->>Api: updated catalog metadata + Api-->>Apps: cloud catalog result + Apps->>Source: recompute source state + Source-->>User: registration-active 或 blocked +``` + +## 主流程 + +```mermaid +flowchart TD + Start[进入 Agent Apps] --> Choose{选择来源} + Choose --> Local[Local folder] + Choose --> Cloud[Cloud release] + Local --> Inspect{APP.md valid?} + Inspect -->|No| Invalid[local-invalid] + Inspect -->|Yes| Review[Install review] + Cloud --> Reg{registration active?} + Reg -->|No| Registration[registration-required] + Registration --> Submit[提交注册码] + Submit --> Reg + Reg -->|Yes| Verify{hash / manifest verify} + Verify -->|No| Block[blocked] + Verify -->|Yes| Review + Review --> Confirm{用户确认?} + Confirm -->|No| Cancel[不写 repository] + Confirm -->|Yes| Save[save installed state] + Save --> Refresh[refresh installed list] + Refresh --> Launch[launch via P14 guard] +``` + +## 用户故事 + +| ID | 用户故事 | 验收口径 | +|---|---|---| +| P17.2-US1 | 作为用户,我选择本地目录后可以先看到安装审查,而不是直接写入安装状态。 | cancel / invalid / review / confirm 状态都有测试。 | +| P17.2-US2 | 作为团队管理员,我能区分 Cloud release 是未注册、已注册、禁用还是可安装。 | source state 不只用一个 disabled 按钮表达。 | +| P17.2-US3 | 作为用户,我能看到安装包的 appId、version、hash、capability、storage 和 cleanup 摘要。 | review descriptor 字段完整且五语言展示。 | +| P17.2-US4 | 作为维护者,我能证明 registration 没有绕过 package verify。 | registration active 后仍走 P12 cache / P11 repository。 | +| P17.2-US5 | 作为维护者,我能证明 Lab fixture 没有进入正式入口。 | `development_fixture` 只允许在 Lab 测试和 Lab UI 中出现。 | + +## 分阶段计划 + +| 阶段 | 交付 | 范围 | 验收 | +|---|---|---|---| +| P17.2.0 | 已完成:计划收口。 | 本文、README、implementation plan、P17 contract、P17 gate audit。 | 当时所有 current 文档的下一刀已统一进入 P17.2;后续 P17.3 与 P17.4 已完成,当前进入 P17.5 formal entry GUI smoke。 | +| P17.2.1 | Source state model。 | `src/features/agent-app` 纯函数 / 类型;正式入口 source state copy。 | local / cloud / registration / disabled / installed 状态单测。 | +| P17.2.2 | Install review descriptor。 | `src/lib/api/agentApps.ts` 或 feature 内 builder;`AgentAppsPage` review UI。 | local confirm 前不写 repository;cancel / invalid 稳定。 | +| P17.2.3 | 已完成最小实现:Registration hardening。 | Cloud registration 结果刷新 source state;install button 与 review gate 分离。 | registration-required / active / hash-missing 流程测试。 | +| P17.2.4a | 已完成最小实现:Cloud release descriptor / verification gate。 | Cloud release metadata 归一为 release descriptor;Cloud review 必须显式传入 package manifest;package / manifest mismatch 阻断;cached fallback 必须同 hash。 | `buildCloudReleaseDescriptor()`、`buildVerifiedCloudReleasePackage()`、`reviewCloudAgentAppRelease()`、source state exact-hash tests 通过。 | +| P17.2.4b | 已完成最小实现:Cloud package acquisition hardening。 | 已完成 acquisition seam、verified cache source、`packageUrl` fetch / staging / manifest extraction,并写入 P12 verified package cache。 | Cloud review 可从 explicit verified manifest、verified cache 或 fetched package 生成 projection / readiness;下载 / 验证失败不写 repository;Tauri fetch command 已同步命令契约事实源。 | +| P17.2.5 | 已完成最小实现:Schema / reference CLI / example package cross-check。 | 使用上游 public schema、`agentapp-ref`、`docs/examples/content-factory-app` 校验客户端 projection / readiness / review descriptor 字段。 | reference CLI validate / project / readiness、本地 projection/readiness/review descriptor 字段映射和 accepted divergence 均有测试覆盖;后续 P17.3 lifecycle / cleanup hardening 已完成。 | + +### P17.2.4 实施拆解 + +1. 已完成 P17.2.4a:定义 release descriptor,从 Cloud / bootstrap metadata 归一 `appId`、`version`、`sourceUri`、`packageUrl`、`packageHash`、`manifestHash`、`compatibility`、`releaseChannel`、`tenantEnablementRef`、可选 `signatureRef`。 +2. 已完成 P17.2.4a:统一 Cloud review 来源,`reviewCloudAgentAppRelease()` 不再直接从 `contentFactoryFixture` 生成 production review;调用方必须显式传入 package manifest,并通过 verification gate 才能生成 review。 +3. 已完成 P17.2.4a:强化 blocker,缺 package manifest、hash mismatch、manifest mismatch、registration inactive、cloud disabled 都不能写入 repository。 +4. 已完成 P17.2.4a:固定 cached fallback,`offline-cached` 只允许 repository 中已有且 hash / manifest hash 匹配的版本。 +5. 已完成 P17.2.4b-1:在 `src/lib/api/agentApps.ts` 加 package acquisition seam,统一处理 release descriptor、explicit manifest、verified cache lookup 和 missing source blocker;feature island 只消费 review / source state。 +6. 已完成 P17.2.4b-2:复用 P12 cache 完成 `packageUrl` 下载 / 读取 / staging / hash verify / manifest extraction / rollback,不新增第二套 downloader / installer / repository。 +7. P17.2.4b 当前 UI 仍通过 source state / review blocker 区分“可进入 verified review”和“缺 verified package source”;P17.2.5 已只做标准 cross-check,不扩 UI 入口。 +8. P17.2.4b 已把 fetched package source kind、release descriptor、package identity、manifest hash 带回 review 构建链路;更细的 evidence / support diagnostic 字段进入 P17.3 cleanup / lifecycle hardening。 +9. P17.2.4b 已固定 registration inactive 时不得用 seeded catalog、Lab fixture 或 dev resolver 补 package manifest;expired / revoked 的字段级 schema 对齐已纳入 P17.2.5 cross-check 证据。 + +### P17.2.4b 实施顺序 + +```text +Cloud release descriptor + -> package acquisition seam + -> verified cache lookup + -> optional fetch / staging bridge + -> manifest extraction + -> P12 verification + -> install review + -> user confirm + -> P11 repository save +``` + +1. 已完成:`reviewCloudAgentAppRelease()` 的 production 路径先走 `resolveCloudReleasePackageManifest()`;显式传入 `packageManifest` 只作为测试 / 已验证 source 注入 seam。 +2. 已完成:无 verified cache 且测试 / 离线路径显式禁用 fetch 能力时,返回 `missing a verified package source` blocker;正式路径会继续走集中 fetch command,失败也不能写 repository。 +3. 已完成:真实下载只通过 `src/lib/api/agentApps.ts -> agent_app_fetch_cloud_package` 集中 Tauri command;`AgentAppsPage`、`cloudBootstrap.ts` 和 Cloud control-plane API 客户端不各自下载。 +4. 下载 / staging 必须先验证 packageHash 和 manifestHash;verify 通过前不得生成 production install review。 +5. 已安装同 appId + version + packageHash + manifestHash 的 cache 只允许作为 offline / refresh review 来源,不允许在不同 hash 时伪装命中。 +6. 所有 blocker 必须带 source provenance:catalog source、releaseId、tenantEnablementRef、packageUrl、expected hash、actual hash。 + +### P17.2.5 实施拆解 + +1. 已完成:以 `/Users/coso/Documents/dev/ai/limecloud/agentapp/docs/public/schemas` 为 reference,给客户端 projection / readiness / review descriptor 增加字段映射表和 accepted divergence 检查。 +2. 已完成:以 `/Users/coso/Documents/dev/ai/limecloud/agentapp/bin/agentapp-ref.mjs` 的 `validate / project / readiness` 输出为对照,测试客户端 projection、readiness setup checks、capability keys 与 install review descriptor。 +3. 已完成:用上游 `docs/examples/content-factory-app` 作为标准示例包,替代只依赖 Lime 内部 fixture 的单一路径验证。 +4. cross-check 只验证 package contract,不运行 Agent、不启动 worker、不接 Cloud 管理台。 + +## 文件边界 + +| 分类 | 路径 / 对象 | 规则 | +|---|---|---| +| current | `src/features/agent-app/ui/AgentAppsPage.tsx` | 正式入口 UI,只消费 source state / review descriptor,不直接拼 Cloud / Lab 内部状态。 | +| current | `src/lib/api/agentApps.ts` | 前端 API gateway,允许集中 `safeInvoke`;feature island 不直接调用 Tauri。 | +| current | `src/features/agent-app/install/*` | package identity、cache、installed state、setup state、cleanup preview 的事实源。 | +| current | `src/features/agent-app/runtime/entryRuntimeGuard.ts` | launch 前唯一 guard,不因 install review 通过而绕过。 | +| current | `docs/roadmap/agentapp/p17-source-install-contract-hardening.md` | P17.2 已完成执行记录。 | +| current | `docs/roadmap/agentapp/p17-lifecycle-cleanup-contract-hardening.md` | P17.3 已完成执行计划。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/agentapp` | Agent App v0.3 标准、发现安装、发布分发、安全模型、public schema、reference CLI 和示例包。 | +| reference | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/agentapp` | Cloud / LimeCore 控制面,不写客户端 runtime。 | +| deprecated | `agent-app-lab` source 语义进入正式入口 | Lab 仍 current 于研发验证,但 fixture / smoke 不能成为正式 install 证据。 | +| dead | `SceneApp` / `contentEngineering*` / `sceneapp_*` | 不恢复、不迁移、不兼容。 | + +## 验收标准 + +1. `agent-apps` 对 local folder 和 Cloud release 展示同一类 install review。 +2. 用户取消本地目录选择时不写入 repository、不留下半安装态。 +3. invalid package / hash mismatch / manifest mismatch 必须阻断安装。 +4. registration required 只能解锁 source availability,不能写 package、不能绕过 verify;本地 sideload 的企业定制包同样必须先确认 Cloud `registrationState=active`。 +5. Cloud release 必须先归一成带 `packageUrl / packageHash / manifestHash / compatibility` 的 release descriptor;只返回 channel、license 或 registration 状态不能被视为可安装 package。 +6. Cloud / LimeCore 只作为 metadata source;Desktop 仍负责 install、projection、readiness、guard、runtime、cleanup。 +7. cached fallback 只允许启动已安装且 hash 匹配的 package;不能用离线缓存创建新安装态。 +8. P17.2.5 已对齐上游 public schema、reference CLI 和 `content-factory-app` 示例包;projection `permissions` 仍作为明确 accepted divergence,当前由 install review `permissionCount` 覆盖,完整 permission projection 后移到 P18。 +9. 正式入口不直接使用 Lab fixture source;Lab smoke 不能替代 formal smoke。 +10. 用户可见文案覆盖 `zh-CN / zh-TW / en-US / ja-JP / ko-KR`。 +11. `src/features/agent-app` 不直接出现 `safeInvoke`、`invoke(`、`tauri::`、`generate_handler`、`new Worker`。 + +## 最小验证 + +P17.2 文档收口: + +```bash +git diff --check -- docs/roadmap/agentapp +rg -n "下一刀进入 P17\\.1|P17\\.1 Formal route / nav / copy hardening(下一刀)" docs/roadmap/agentapp +``` + +P17.2 实现后至少执行: + +```bash +npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/installReview.test.ts src/features/agent-app/install/packageCache.test.ts src/lib/api/agentApps.test.ts +npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx +npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts +rg -n "safeInvoke|invoke\\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\\(" src/features/agent-app || true +git diff --check -- docs/roadmap/agentapp src/features/agent-app src/lib/api/agentApps.ts src/i18n/resources +``` + +P17.2.5 标准 cross-check: + +```bash +node /Users/coso/Documents/dev/ai/limecloud/agentapp/bin/agentapp-ref.mjs validate /Users/coso/Documents/dev/ai/limecloud/agentapp/docs/examples/content-factory-app +node /Users/coso/Documents/dev/ai/limecloud/agentapp/bin/agentapp-ref.mjs project /Users/coso/Documents/dev/ai/limecloud/agentapp/docs/examples/content-factory-app +node /Users/coso/Documents/dev/ai/limecloud/agentapp/bin/agentapp-ref.mjs readiness /Users/coso/Documents/dev/ai/limecloud/agentapp/docs/examples/content-factory-app +``` + +如改动 Tauri command / bridge: + +```bash +npm run test:contracts +``` + +如改动正式入口 GUI 主流程: + +```bash +npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/components/AppPageContent.test.tsx src/lib/navigation/sidebarNav.test.ts +``` + +P17.5 前仍可用 Lab smoke 做辅助回归,但不能把 `smoke:agent-app-lab` 当作正式入口 smoke。 + +## 当前下一刀 + +P17.2.1、P17.2.2 与 P17.2.3 已完成最小实现:正式入口已补 source state model、install review descriptor 和 registration-active refresh;local / cloud 安装都改为先生成审查卡片,用户确认后才写入 P11 installed state repository。P17.2.4a 已完成 release descriptor、verification gate、hash mismatch blocker 和 exact-hash cached fallback 的最小实现。P17.2.4b-1 已完成 package acquisition seam、verified cache source 和 missing source blocker。P17.2.4b-2 已把 `packageUrl` 下载 / staging / manifest extraction 接入 P12 verified cache。P17.2.5 已完成 public schema / reference CLI / standard example package cross-check。后续 P17.3 lifecycle / cleanup contract hardening 与 P17.4 runtime surface production hardening 已完成;当前进入 P17.5 formal entry GUI smoke。 + +## 2026-05-15 P17.2.1 / P17.2.2 / P17.2.3 实施记录 + +本轮完成内容: + +1. 新增 `src/features/agent-app/install/installReview.ts`,作为正式入口 source state 与 install review descriptor 的纯函数事实源。 +2. `src/lib/api/agentApps.ts` 新增 `reviewLocalAgentAppPackage()` 与 `reviewCloudAgentAppRelease()`;原 `installLocalAgentAppPackage()` / `installCloudAgentAppRelease()` 继续保留为兼容 API,但内部先 review 再 save。 +3. `AgentAppsPage` 的 local folder 和 Cloud release 安装不再点击即写入 repository,而是先展示 `agent-apps-install-review`,确认后才调用 `saveInstalledAgentAppState()`。 +4. Cloud source card 展示 `registration-required / registration-active / cloud-disabled / hash-missing / installed / offline-cached` 等 source state;不可审查的 source 禁用安装按钮。 +5. 五语言补齐 source state 与 install review 文案。 +6. registration submit 后先刷新 installed list,再保留 submit 返回的 active catalog,避免 UI 被旧 catalog 重新冲回 `registration-required`。 +7. 单测覆盖 source state、local cancel、invalid package、Cloud review、registration-active refresh、hash-missing blocker、confirm install 和 API review no-save。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/installReview.test.ts src/features/agent-app/ui/AgentAppsPage.test.tsx src/lib/api/agentApps.test.ts` | 通过,3 files / 24 tests。 | +| `npm run test -- src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts` | 通过,3 files / 17 tests。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 无命中,feature island 未直接扩展命令边界。 | +| `git diff --check -- src/features/agent-app src/lib/api/agentApps.ts src/lib/api/agentApps.test.ts src/i18n/resources docs/roadmap/agentapp` | 通过。 | + +未完成: + +1. P17.2.4b-1 之前的 Cloud review 已不再默认使用 fixture,但当时仍依赖调用方显式传入 package manifest;该缺口已由 P17.2.4b-1 的 acquisition seam / verified cache source 部分收口。 +2. P17.2.4b-2 packageUrl fetch / staging / manifest extraction 已完成;当前没有 explicit verified manifest 或 verified cache 时,会尝试集中 fetch command,下载或验证失败仍安全阻断。 +3. P17.2.5 schema / reference CLI cross-check 已新增;客户端 projection / readiness 已和上游 public schema、`agentapp-ref`、标准 `content-factory-app` 示例包形成机械对照。 +4. P17.5 formal entry GUI smoke 仍未新增;当前仍不能用 Lab smoke 代替正式入口 smoke。 + +## 2026-05-15 P17.2.4a 实施记录 + +本轮完成内容: + +1. `cloudBootstrap.ts` 新增 `buildCloudReleaseDescriptor()`,把 Cloud metadata 归一为带 `packageUrl / packageHash / manifestHash / compatibility / tenantEnablementRef / signatureRef` 的 release descriptor。 +2. `cloudBootstrap.ts` 新增 `buildVerifiedCloudReleasePackage()`,复用 P12 package cache entry 与 verification 结果,支持 package / manifest hash mismatch blocker。 +3. `reviewCloudAgentAppRelease()` 不再默认使用 `contentFactoryFixture` 生成 production review;缺少 package manifest 时抛出 `AgentAppCloudBootstrapError`,避免 Cloud card / Lab fixture 伪装成 verified package。 +4. `AgentAppInstallReview` 增加 release provenance 字段:`packageUrl / releaseId / releaseChannel / tenantEnablementRef / signatureRef / packageVerificationStatus`。 +5. `buildCloudAgentAppSourceState()` 的 installed / offline-cached 判断改为 appId + version + packageHash + manifestHash 全部匹配,避免同版本不同 hash 伪装成 cached fallback。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/installReview.test.ts src/lib/api/agentApps.test.ts` | 通过,3 files / 30 tests。 | +| `npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/lib/api/agentApps.test.ts` | 通过,3 files / 25 tests。 | +| `npm run typecheck` | 通过。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 无命中,feature island 未直接扩展命令边界。 | +| `git diff --check -- src/features/agent-app src/lib/api/agentApps.ts src/lib/api/agentApps.test.ts docs/roadmap/agentapp` | 通过。 | + +## 2026-05-15 P17.2.4b-1 实施记录 + +本轮完成内容: + +1. `src/lib/api/agentApps.ts` 新增 `resolveCloudReleasePackageManifest()`,作为 Cloud package acquisition 的 API gateway seam。 +2. `reviewCloudAgentAppRelease()` 不再在入口处强制调用方传入 `packageManifest`;现在先尝试 explicit verified manifest,再尝试 verified cache source。 +3. 新增 `packageCacheEntry / resolveCachedPackage` 注入点,允许 P12 verified cache 命中后直接生成 production review。 +4. 缺 verified source 时抛出明确 blocker,不写 P11 repository;cache hash / manifest hash mismatch 仍阻断。 +5. `src/features/agent-app` 仍无直接 `safeInvoke` / Tauri / raw Worker;本轮未新增 Tauri command。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/lib/api/agentApps.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/installReview.test.ts` | 通过,3 files / 32 tests。 | +| `npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/lib/api/agentApps.test.ts` | 通过,3 files / 27 tests。 | +| `npm run typecheck` | 通过。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 无命中,feature island 未直接扩展命令边界。 | +| `git diff --check -- src/lib/api/agentApps.ts src/lib/api/agentApps.test.ts docs/roadmap/agentapp` | 通过。 | + +后续阶段: + +1. P17.2.4b-2 已接真实 `packageUrl` fetch / staging / manifest extraction;当前没有 verified cache 时会走集中 fetch command,下载或验证失败仍安全阻断。 +2. 新增 Tauri fetch command 已同步 frontend gateway、Rust registration、DevBridge dispatcher、command catalog、mock priority / default mocks,并执行 `npm run test:contracts`。 +3. P17.2.5 schema / reference CLI cross-check 已新增并通过定向验证。 + +## 2026-05-15 P17.2.4b-2 实施记录 + +本轮完成内容: + +1. `src/lib/api/agentApps.ts` 新增 `fetchCloudAgentAppPackage()`,`resolveCloudReleasePackageManifest()` 在 explicit manifest、verified cache 未命中后,会通过集中命令 fetch package 并把结果标记为 `fetched_package`。 +2. `agent_app_fetch_cloud_package` 新增 Rust current 命令:只接受 `https` `packageUrl`、`sha256:<64 hex>` package / manifest hash,且要求 `sourceUri == packageUrl`。 +3. Rust 命令完成 `packageUrl -> bytes -> packageHash verify -> staging unzip -> unique APP.md root -> manifestHash verify -> frontmatter app/version match -> packages/`,验证失败只返回 blocker,不写 installed state。 +4. 命令同步 `runner.rs`、DevBridge dispatcher、`agentCommandCatalog.json`、`mockPriorityCommands.ts` 与 `agentAppMocks.ts`;feature island 仍不直接 `safeInvoke` / `invoke`。 +5. `docs/aiprompts/commands.md` 已登记 Agent App current 命令主链,并明确 `agent_app_fetch_cloud_package` 不做 projection、不写 installed state、不绕过 install review。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/lib/api/agentApps.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/installReview.test.ts` | 通过,3 files / 33 tests。 | +| `npm run test -- src/features/agent-app/ui/AgentAppsPage.test.tsx src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/lib/api/agentApps.test.ts` | 通过,3 files / 28 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | +| `cargo test --manifest-path src-tauri/Cargo.toml -p lime agent_app_cmd --lib --target-dir /tmp/lime-cargo-test-agent-app-fetch` | 通过,2 tests;首次独立 target 编译下载了 `sherpa-onnx` 预编译依赖。 | +| `rg -n "safeInvoke\|invoke\\(\|tauri::\|generate_handler\|mockPriorityCommands\|defaultMocks\|new Worker\|Worker\\(" src/features/agent-app \|\| true` | 无命中,feature island 未直接扩展命令边界。 | +| `npm run verify:local` | 未通过;smart 模式触发全仓 lint,阻塞于 `src/components/agent/chat/experts/ExpertInfoPanel.tsx` 的 `react-hooks/exhaustive-deps` warning,非本轮 Agent App P17.2.4b-2 命令 / roadmap 文件。 | + +未完成: + +1. P17.3 lifecycle / cleanup contract hardening 已完成:formal page、guard integration、cleanup namespace、evidence 和 residual audit 均已收口,overlay、secret binding、setup state、package code 与 cleanup evidence 已按 namespace 分离。 +2. P17.4 production hardening 已完成 P17.4.1-P17.4.5,包含健康 DevBridge 下的完整 GUI smoke 证据。 +3. P17.5 formal entry GUI smoke 仍未新增;Lab smoke 不能替代正式入口 smoke。 + +## 2026-05-15 P17.2.5 实施记录 + +本轮完成内容: + +1. 新增 `src/features/agent-app/schema/referenceCliCrossCheck.test.ts`,本地存在上游标准仓库时运行 `/Users/coso/Documents/dev/ai/limecloud/agentapp/bin/agentapp-ref.mjs validate / project / readiness`。 +2. cross-check 读取上游 `docs/public/schemas/app-projection.schema.json` 与 `app-readiness.schema.json`,把 reference projection 字段映射到 Lime `AgentAppProjection`,未映射字段必须进入 accepted divergence。 +3. cross-check 使用上游标准示例包 `docs/examples/content-factory-app/APP.md`,而不是 Lime 内部 fixture,生成客户端 projection、readiness 和 install review descriptor。 +4. 断言 reference CLI 的 entry / capability / knowledge / tool / artifact / eval / service / workflow / secret / overlay keys 能映射到 Lime projection / readiness setup checks。 +5. 明确 accepted divergence:reference projection 的 `permissions` 当前不进入 Lime projection,而由 P17.2 install review 的 `permissionCount` 覆盖;完整 permission projection 后移到 P17.3 / P18。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/schema/referenceCliCrossCheck.test.ts` | 通过,1 file / 3 tests;本机标准仓库存在,测试实际运行未 skip。 | +| `npm run typecheck` | 通过。 | + +未完成: + +1. P17.3 lifecycle / cleanup contract hardening 已完成,formal page / guard integration 已收口。 +2. P17.4 runtime surface production hardening 已完成 P17.4.1-P17.4.5,GUI smoke 已通过。 +3. P17.5 formal entry GUI smoke 仍未新增。 + +## 2026-05-15 Formal Runtime Surface 补丁 + +本轮针对正式左侧 App 入口补了最小可用闭环,避免已安装 App 进入一个 Host 伪页面或停在 `cloud_release` 假启动状态: + +1. `agent_app_start_ui_runtime` 仍走 current Tauri 命令边界,但 runtime 目录解析改为通用 resolver:优先 `local_folder`,再查 package cache / staging,再读 `LIME_AGENT_APP_LOCAL_ROOTS`,最后在开发态按 `limecloud/` 约定发现本地 App;所有候选目录都必须包含 `APP.md` / `package.json`,且 `APP.md` 的 `name/appId` 必须匹配 installed state。 +2. `AgentAppRuntimePage` 不再渲染 Host 自己的“运行条件 / 交付物 / 知识绑定 / entry 卡片”,只负责启动 App runtime 并用 iframe 打开 App 自己的 `entryUrl`。 +3. `内容工厂` 的正式入口现在打开的是 App 包自己的 `/dashboard` 页面;Host 只提供安装状态、导航和 runtime 容器。 +4. 这不是 marketplace / Cloud 执行器;`cloud_release` 仍必须最终落到本地 verified runtime package。当前 dev resolver 只用于本地开发与已存在源码包的可用闭环,P17.2 后续仍要补正式 install review / package verify。 +5. 计划分类:该补丁属于 P17.4 runtime surface pre-work,不改变 P17.2 主线。P17.2.4b-2 已把 `packageUrl` 下载 / staging / manifest extraction 接入 P12;P17.4 仍需继续证明 `cloud_release` runtime 不依赖本地源码目录或开发态 resolver。 +6. 后续约束:P17.4 production hardening 必须证明 runtime 只加载 verified cache / staging,disabled / unverified / missing package 无法启动,App UI 只能通过 injected SDK handles 使用 Lime 能力。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/ui/AgentAppRuntimePage.test.tsx src/lib/api/agentApps.test.ts src/lib/dev-bridge/mockPriorityCommands.test.ts src/lib/tauri-mock/core.test.ts` | 通过,4 files / 34 tests。 | +| `npx eslint ...agent-app/runtime/api/mock files... --max-warnings 0` | 通过。 | +| `cargo check --manifest-path src-tauri/Cargo.toml -p lime --lib --target-dir /tmp/lime-cargo-check-agent-app` | 通过。 | +| `npm run test:contracts` | 通过。 | +| `npm run bridge:health` + DevBridge `agent_app_start_ui_runtime` | 通过,返回 `entryUrl=http://127.0.0.1:/dashboard`,runtime 目录为本地 `limecloud/content-factory-app`。 | +| Playwright 打开 runtime `/dashboard` | 通过,页面标题 `内容工厂`,App 内导航包含 `项目中心 / 资料整理 / 场景规划 / 写文案 / 做脚本 / 交付文件 / 看数据 / 团队设置`。 | +| `npm run verify:gui-smoke` | 通过。 | + +## 2026-05-15 内容工厂端到端复测 + +本轮从正式 Agent App runtime 打开 `内容工厂` App 自己的页面,并按业务用户路径重走一遍,不再接受 Host hard code 或 Host 仿页面作为通过标准。 + +复测结论: + +1. 正式 runtime 可启动并打开 App 页面:DevBridge `agent_app_start_ui_runtime` 返回 `http://127.0.0.1:65290/dashboard`,页面标题和 App 内导航均为 `内容工厂` 自身 UI。 +2. 已在真实 runtime 中完成:创建项目 `E2E 内容工厂项目 20260515`、粘贴资料整理、确认资料版本、进入场景规划、写文案、做脚本、交付文件、看数据、团队设置并保存规则。 +3. 当前真实 runtime 未配置 `LIME_MODEL / LIME_ACCESS_TOKEN`,所以场景、文案、脚本、交付结论生成按钮按设计禁用,并显示“需要先在 Lime 中连接内容生成能力”;这属于宿主能力接入缺口,不再用前端假数据伪造成可生成。 +4. 完整生成链用 App 自带 fake-model 产品 E2E 验证通过,覆盖空项目、新建项目、资料导入、确认链、120 场景、20 条文案、6 条脚本、交付结论、复盘、团队设置和 dashboard 持久化。 +5. 复测中发现并修复了 App 自身三个问题:粘贴资料未选文件会被当成不支持文件、`用户痛点 / 竞品与差评` 显式标签被分错区、项目资产页泄露内部 `content_batch_* / scene_table_* / knowledge_*` ID。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| Playwright MCP `http://127.0.0.1:65290/dashboard` | 通过,当前停留 dashboard,控制台 error 为 0。 | +| Playwright MCP 真实 runtime 业务流 | 通过到“生成能力未连接”的受控门禁;资料整理 / 确认 / 复盘 / 设置均成功,场景 / 文案 / 脚本 / 交付生成按钮按缺能力禁用。 | +| `npm run build && npm test` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 通过,34 tests。 | +| `npm run e2e:user-flow` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 通过,验证未连接模型时的受控门禁,证据输出到 `.local/e2e/content-factory-disconnected-2026-05-15T13-40-55-658Z/`,包含 7 张页面截图。 | +| `npm run e2e:user-flow:fake-model` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 通过,验证完整生成链,证据输出到 `.local/e2e/content-factory-fake-2026-05-15T13-36-10-918Z/`,包含 8 张页面截图。 | + +## 2026-05-15 内容工厂 Host 能力注入补丁 + +本轮把“已安装但生成能力不可用”的缺口收敛到正式 Agent App runtime 主链:Host 启动 App UI runtime 时不再只注入 `PORT`,而是按 Lime 当前模型配置和本机 Gateway 生成受控运行环境,让 App 通过 Lime Gateway 使用内容生成能力。 + +实现内容: + +1. `agent_app_start_ui_runtime` 继续沿用 current Agent App 命令,不新增平行命令;启动子进程时额外注入 `LIME_GATEWAY_BASE / LIME_GATEWAY_PROTOCOL / LIME_GATEWAY_PROVIDER / LIME_MODEL / LIME_ACCESS_TOKEN`。 +2. 注入的访问凭证是 Lime 本机 Gateway API key,不把上游 Provider API Key 直接下发给 App 子进程;上游 Provider 协议和真实凭证仍由 Lime Gateway 负责。 +3. 模型选择优先读取 `workspace_preferences.service_models.generation_topic.preferredModelId`,否则读取所选 / 首个可用 API Key Provider 的 `custom_models`;没有模型或没有可用 Provider 凭证时不伪造 ready。 +4. 内容工厂 App 的模型适配器支持 `responses / openai_chat / anthropic_messages` 三种协议,并优先读取 Host 注入的 `LIME_*`,同时兼容标准 OpenAI / Anthropic / Ollama 环境变量。 +5. 内容工厂设置页只展示业务可理解的连接状态,不暴露 API key、内部 Provider token 或 Host 调试字段。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `cargo test -p lime agent_app_` in `src-tauri` | 通过,Agent App 命令相关 4 tests。 | +| `npm run test:contracts` | 通过,命令契约、Harness 契约和 modality contract 均通过。 | +| `npm run test -- src/lib/api/agentApps.test.ts src/lib/dev-bridge/mockPriorityCommands.test.ts src/lib/tauri-mock/core.test.ts` | 通过,3 files / 40 tests。 | +| `npm test` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 通过,39 tests。 | +| `npm run e2e:user-flow` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 通过,未连接模式证据:`.local/e2e/content-factory-disconnected-2026-05-15T14-19-29-652Z/`。 | +| `npm run e2e:user-flow:fake-model` in `/Users/coso/Documents/dev/ai/limecloud/content-factory-app` | 通过,完整生成链证据:`.local/e2e/content-factory-fake-2026-05-15T14-19-46-127Z/`。 | +| DevBridge `agent_app_start_ui_runtime` + Playwright `http://127.0.0.1:55396/settings` | 通过,内容工厂设置页显示“已连接”,当前模型 `deepseek-v4-pro`,控制台新增 error 为 0。 | +| Playwright `http://127.0.0.1:55396/scenario-planning` | 通过,`更新场景和图片需求` 按钮在模型 ready 后可点击。 | + +未执行: + +1. 未点击真实模型生成按钮;这会通过本机 Gateway 调用真实上游模型并消耗用户 Provider 配额。完整生成链已由 fake-model E2E 覆盖,真实模型调用需用户明确允许后再跑一轮最小样本。 +2. 本节当时注入仍是进程级环境变量,不是 per-app scoped secret capability;后续已在“内容工厂 scoped Gateway token 补丁”和“model-generation scope 收窄补丁”中继续收紧,P17.4 / P18 仍需补审计事件和 revoke。 + +## 2026-05-15 内容工厂 scoped Gateway token 补丁 + +本轮把上一节遗留的“App 子进程拿到全局 `server.api_key`”收窄为短期 Agent App scoped token,继续落在 `agent_app_start_ui_runtime` current 主链,不新增平行启动命令。 + +实现内容: + +1. 新增 `lime_core::agent_app_runtime_token`,用 `server.api_key` 作为本机签名 secret 签发短期 token,token 默认 12 小时过期。 +2. Host 启动 App UI runtime 时注入 scoped token 到 `LIME_ACCESS_TOKEN / OPENAI_API_KEY`,不再把全局 `server.api_key` 原样下发给 App。 +3. 启动子进程前清理常见上游 Provider API Key 环境变量,避免父进程环境里的 `OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY` 等透传给 App。 +4. Lime Gateway 的 OpenAI / Anthropic HTTP 鉴权继续接受原有全局 API Key,同时识别签名有效且未过期的 Agent App scoped token;上游 Provider 凭证仍只由 Gateway 侧读取。 +5. `docs/aiprompts/commands.md` 已补充 Agent App runtime token 约束,避免后续回退到把全局 Gateway key 或 Provider key 注入 App。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `cargo test -p lime-core agent_app_runtime_token --target-dir /tmp/lime-cargo-agent-app-token-core` | 通过,3 tests。 | +| `cargo test -p lime-server verify_api_key --target-dir /tmp/lime-cargo-agent-app-token-core` | 通过,3 tests。 | +| `cargo test -p lime agent_app_ --lib --target-dir /tmp/lime-cargo-test-agent-app-p17-3` | 通过,5 tests。 | +| `npm run test:contracts` | 通过,命令契约、Harness 契约、modality runtime contracts 和 cleanup report contract 均通过。 | +| `npm run bridge:health -- --timeout-ms 120000` | 通过,DevBridge ready。 | +| DevBridge stop/start `content-factory-app` runtime | 通过,新 runtime `http://127.0.0.1:51728/dashboard`,目录 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`。 | +| `GET http://127.0.0.1:51728/api/bootstrap` | 通过,`model.ready=true`,`provider=deepseek`,`model=deepseek-v4-pro`,`protocol=openai_chat`,`gatewayBase=http://127.0.0.1:8999`,`missing=[]`。 | +| Playwright `http://127.0.0.1:51728/settings` | 通过,设置页显示“已连接”,当前模型 `deepseek-v4-pro`,新增 console error 为 0。 | +| Playwright `http://127.0.0.1:51728/scenario-planning` | 通过,`更新场景和图片需求` 按钮 `disabled=false`,新增 console error 为 0。 | + +未执行: + +1. 未点击真实模型生成按钮;该操作会消耗真实上游 Provider 配额。完整生成链仍以 App fake-model E2E 作为不耗费配额的回归证据。 +2. scoped token 在本节后仍是 Gateway 级短期 bearer token;后续已继续收窄到 `model-generation` 生成能力,P17.4 / P18 后续仍需补更细粒度 capability、审计和 token revoke。 + +## 2026-05-15 model-generation scope 收窄补丁 + +本轮继续把 Agent App runtime token 从“Gateway 级短期 bearer”收窄为“内容生成能力级 bearer”,不扩大 App 可访问的 Gateway 控制面。 + +实现内容: + +1. `lime_core::agent_app_runtime_token` 默认签发 `lime-agent-app:v1::::model-generation:`,并新增 scope 校验入口。 +2. `agent_app_start_ui_runtime` 继续只注入短期 token,但 token claims 的 scope 固定为 `model-generation`。 +3. `verify_api_key` / `verify_api_key_anthropic` 回到只接受全局 Gateway key,避免 Agent App token 被图片、count tokens、Gemini 原生或其他管理端点通用复用。 +4. 仅 `/v1/chat/completions` 与 `/v1/messages` 生成端点使用 `verify_model_generation_api_key*`,接受全局 Gateway key 或未过期的 Agent App `model-generation` scoped token。 +5. `docs/aiprompts/commands.md` 已同步记录:Agent App scoped token 当前只覆盖生成端点,不代表全 Gateway 权限。 + +验证记录: + +| 命令 / 证据 | 结果 | +|---|---| +| `cargo test -p lime-core agent_app_runtime_token --target-dir /tmp/lime-cargo-agent-app-token-core` | 通过,4 tests;覆盖签发、过期、篡改、错误 secret 和 unsupported scope。 | +| `cargo test -p lime-server auth_tests --target-dir /tmp/lime-cargo-agent-app-token-core` | 通过,4 tests;覆盖全局 key、Agent App token 被普通鉴权拒绝、生成端点接受 scoped token、错误 secret 拒绝。 | +| `cargo test -p lime agent_app_ --lib --target-dir /tmp/lime-cargo-test-agent-app-p17-3` | 通过,5 tests;覆盖 Agent App runtime 注入的 token claims。 | +| `npm run test:contracts` | 通过,命令契约、Harness 契约、modality runtime contracts 和 cleanup report contract 均通过。 | +| `npm run bridge:health -- --timeout-ms 120000` | 通过,DevBridge ready。 | +| DevBridge `agent_app_start_ui_runtime` + `agent_app_get_ui_runtime_status` | 通过,新 runtime `http://127.0.0.1:56285/dashboard`,目录 `/Users/coso/Documents/dev/ai/limecloud/content-factory-app`。 | +| `GET http://127.0.0.1:56285/api/bootstrap` | 通过,`model.ready=true`,`provider=deepseek`,`model=deepseek-v4-pro`,`protocol=openai_chat`,`gatewayBase=http://127.0.0.1:8999`,`missing=[]`。 | +| Playwright `http://127.0.0.1:56285/settings` | 通过,设置页显示“生成内容:已连接”,当前模型 `deepseek-v4-pro`,console error 为 0。 | +| Playwright `http://127.0.0.1:56285/scenario-planning` | 通过,`更新场景和图片需求` 按钮 `disabled=false`,console error 为 0。 | + +未执行: + +1. 未点击真实模型生成按钮;该操作会消耗真实上游 Provider 配额。当前只验证到生成入口 ready 和按钮可用。 +2. 未跑 `npm run verify:gui-smoke`;本轮没有修改 Host GUI 壳、Workspace 或前端可见文案,且本机有并行任务,避免为了 auth scope 收窄打断现有 GUI 会话。若下一刀改 Agent Apps 正式入口或 Host runtime 页面,再补 GUI smoke。 diff --git a/docs/roadmap/agentapp/p3-ui-extension-host.md b/docs/roadmap/agentapp/p3-ui-extension-host.md index 6a50312b1..38f7387ba 100644 --- a/docs/roadmap/agentapp/p3-ui-extension-host.md +++ b/docs/roadmap/agentapp/p3-ui-extension-host.md @@ -54,6 +54,21 @@ flowchart TD - `buildUiRuntimeCapabilityProfile` 只把 `lime.ui` 变成 native;其他能力仍来自 mock / adapter。 - `AgentAppLabPage` 只显示 P3 实验入口,不把 App entry 写入主产品路由。 +## Host Bridge 补充 + +P3 的原始目标是证明 App 可以拥有受控 UI surface;它只输出 sandbox policy 和 injected SDK bridge descriptor。进入正式 `agent-apps` runtime 后,必须补上 Host Bridge v1,否则 App UI 会变成孤立 iframe,无法跟随 Lime 主题,也无法把导航、提示、下载和 capability 调用纳入 Host policy。 + +当前补充口径: + +| 项目 | P3 原始边界 | P17.4-H Host Bridge 边界 | +|---|---|---| +| Theme / locale | 只声明可获得 theme / locale。 | 通过 `host:snapshot` 和 `theme:update` 实时推送 token。 | +| SDK bridge | 只展示 allowed / blocked capability。 | 通过 `capability:invoke` 承载请求,继续走 readiness / permission / policy。 | +| Host action | 不定义导航、toast、download。 | 统一 `host:navigate`、`host:toast`、`host:openExternal`、`host:download`。 | +| 安全校验 | sandbox policy 为主。 | 追加 `event.source`、`event.origin`、`protocol`、`version` 校验。 | + +Host Bridge 不是新的 App 类型,也不是 `scene` 概念复活;它是 UI Host 与 Capability SDK 之间的标准 transport。 + ## 运行时序 ```mermaid diff --git a/docs/roadmap/agentapp/p4-content-factory-demo.md b/docs/roadmap/agentapp/p4-content-factory-demo.md index fefb65aac..c864e8309 100644 --- a/docs/roadmap/agentapp/p4-content-factory-demo.md +++ b/docs/roadmap/agentapp/p4-content-factory-demo.md @@ -1,10 +1,10 @@ -# Agent App P4 APP 内容工厂最小闭环 +# Agent App P4 内容工厂最小闭环 更新时间:2026-05-15 ## 一句话目标 -P4.1 用 APP 内容工厂验证 Agent App 不是专家聊天框,也不是 Markdown 声明,而是可以通过 Capability SDK 跑通自己的业务流程:创建项目、绑定知识、规划内容场景、生成内容资产、保存内容表 Artifact、记录 Evidence,并在卸载 delete-data 时可清理。 +P4.1 用 内容工厂验证 Agent App 不是专家聊天框,也不是 Markdown 声明,而是可以通过 Capability SDK 跑通自己的业务流程:创建项目、绑定知识、规划内容场景、生成内容资产、保存内容表 Artifact、记录 Evidence,并在卸载 delete-data 时可清理。 ## 范围 @@ -82,7 +82,7 @@ src/features/agent-app/ ## P4.1 不变量 -1. APP 内容工厂只是 Agent App 平台验证样板,不进入 Lime Core 垂直功能。 +1. 内容工厂只是 Agent App 平台验证样板,不进入 Lime Core 垂直功能。 2. 业务状态必须写入 App namespace,不新增全局业务表。 3. 所有产物必须带 `sourceKind: agent_app` provenance。 4. cleanup / uninstall 必须覆盖 P4 demo 写入的数据。 diff --git a/docs/roadmap/agentapp/p4-workflow-runtime.md b/docs/roadmap/agentapp/p4-workflow-runtime.md index db2195ca2..e50554433 100644 --- a/docs/roadmap/agentapp/p4-workflow-runtime.md +++ b/docs/roadmap/agentapp/p4-workflow-runtime.md @@ -8,7 +8,7 @@ P4.2 把 P4.1 的“内置 demo 编排”推进为可执行的受控 workflow ru ## 背景 -P4.1 已证明 APP 内容工厂可以作为 Product-level Agent App 跑通项目、知识、内容场景、内容资产、Artifact 和 Evidence;但它仍像一段内置 TypeScript 编排。下一步如果直接执行 App package 里的 worker,会过早暴露安全、资源、权限和清理风险。 +P4.1 已证明 内容工厂可以作为 Product-level Agent App 跑通项目、知识、内容场景、内容资产、Artifact 和 Evidence;但它仍像一段内置 TypeScript 编排。下一步如果直接执行 App package 里的 worker,会过早暴露安全、资源、权限和清理风险。 因此 P4.2 先做“受控 workflow DSL runner”: @@ -51,7 +51,7 @@ flowchart TD SDK --> Artifact[lime.artifacts] SDK --> Evidence[lime.evidence] Runtime --> Trace[Workflow Trace] - Runtime --> Result[P4 Content Demo Result] + Runtime --> Result[P4 Content Factory Demo Result] ``` ## 时序图 @@ -60,7 +60,7 @@ flowchart TD sequenceDiagram participant User as 用户 participant Lab as Agent App Lab - participant Demo as Content Demo + participant Demo as Content Factory Demo participant Runtime as WorkflowRuntimeHost participant SDK as LimeAppSdk participant Host as AdapterCapabilityHost diff --git a/docs/roadmap/agentapp/p5-cloud-bootstrap.md b/docs/roadmap/agentapp/p5-cloud-bootstrap.md new file mode 100644 index 000000000..dfebcb9cf --- /dev/null +++ b/docs/roadmap/agentapp/p5-cloud-bootstrap.md @@ -0,0 +1,413 @@ +# Agent App P5 Cloud Bootstrap 客户端接入计划 + +更新时间:2026-05-15 + +## 一句话目标 + +P5 的目标不是把 Lime Desktop 变成 Cloud 的执行器,而是让客户端在保持本地运行、可离线、可清理的前提下,消费 LimeCore 下发的 catalog / release / tenant enablement 元数据,并把它收敛到现有 `package source -> verify -> manifest -> projection -> readiness -> runtime` 链路。 + +## 背景 + +`/Users/coso/Documents/dev/ai/limecloud/agentapp` 已升级到 Agent App v0.3 的完整标准层。相对早期草案,新增的约束不只是字段更多,而是把宿主实现从“能展示一个 APP.md”推进到“能安全安装、投影、授权、运行、升级和清理一个可执行应用包”。 + +对 Lime Desktop 的直接影响: + +| 标准变化 | 客户端计划影响 | +|---|---| +| 强 JSON schemas:manifest / projection / readiness | P5 必须把 schema 契约作为机械校验来源,不能只靠手写类型和 UI 展示。 | +| Release metadata:package URL、package hash、manifest hash、signature、channel / pin | Cloud payload 必须先映射为不可变 package identity,再进入安装链路。 | +| Projection provenance:packageHash + manifestHash | 所有 entry、artifact、evidence、cleanup plan 必须带 release provenance。 | +| Readiness 扩展到 Skills / Knowledge / Tools / Artifacts / Evals / Secrets / Overlays | P5 不能只检查 capability range,要输出可行动 setup findings。 | +| Overlay templates 和 policy defaults | Cloud 可以给默认策略,但 Desktop 仍负责本地 resolver、policy guard 和 readiness invalidation。 | +| Lifecycle:install / activate / upgrade / disable / uninstall / export | Cloud disable 只能禁用 entry,不删除本地 App data;delete-data 仍由客户端卸载流程控制。 | +| Typed SDK expectations:error code、cancel、retry、idempotency、trace、mock | P5 接 Cloud 前要保证 runtime call 继续穿过 Capability SDK,而不是直接调 Lime internal。 | +| `scene` / `home` 仅兼容历史 | P5 projection 必须继续拒绝 v0.3 下的旧 entry kind,不复活旧 SceneApp。 | + +## 客户端 / Cloud 分工 + +| 层 | 负责 | 不负责 | +|---|---|---| +| LimeCore / Cloud | Catalog、Release、package metadata、tenant enablement、license、ToolHub availability、policy defaults、audit metadata。 | 不运行 Agent、不渲染 UI、不执行 worker、不管理本地 storage。 | +| Lime Desktop | 下载、校验、缓存、manifest 解析、projection、readiness、authorization、SDK 注入、UI / workflow / worker runtime、storage namespace、artifact / evidence、cleanup。 | 不实现 Cloud catalog 管理台、不保存租户发布状态事实源、不把 App 写进 Lime Core。 | +| Agent App package | UI bundle、workflow、worker、storage schema、业务代码、entries、permissions、overlay templates。 | 不 import Lime internal、不携带客户数据、不携带明文 secret。 | + +## 架构图 + +```mermaid +flowchart TD + Standard[agentapp v0.3 Standard / Schemas] --> Contract[Client Contract Gate] + LimeCore[LimeCore Bootstrap Payload] --> Payload[CloudBootstrapPayload] + Local[Local Fixture / Dev Package] --> Source[Unified Package Source] + Payload --> Adapter[Cloud Bootstrap Adapter] + Adapter --> Source + Contract --> Parser[Manifest Parser + Schema Guard] + Source --> Verifier[Package / Manifest Hash Verifier] + Verifier --> Parser + Parser --> Projection[Projection Compiler] + Projection --> Readiness[Readiness Runner] + Readiness --> Authorization[Install Review / Authorization] + Authorization --> Runtime[Lab Runtime / Capability SDK] + Runtime --> UI[UI Host] + Runtime --> Workflow[Workflow Runtime] + Runtime --> Artifact[Artifact + Evidence] + Runtime --> Storage[App Namespace] + Readiness --> Cleanup[Cleanup / Disable / Upgrade Plan] +``` + +关键不变量: + +1. `CloudBootstrapPayload` 只是 `Source` 的一种输入,不是第二套安装系统。 +2. `Projection` 和 `Readiness` 必须可由同一个 package identity 重建。 +3. `Runtime` 只能拿到 scoped Capability SDK handle,不能拿 Cloud payload 原文里的业务状态。 +4. `Cleanup` 是 P5 的一等输出;disable、uninstall keep data、delete data、export then delete 必须分开。 + +## 时序图 + +```mermaid +sequenceDiagram + participant Cloud as LimeCore + participant Client as Lime Desktop + participant Adapter as Bootstrap Adapter + participant Source as Package Source + participant Verify as Verifier + participant Project as Projection + participant Ready as Readiness + participant Runtime as Agent App Lab + + Client->>Cloud: request bootstrap metadata + Cloud-->>Client: catalog + release + tenant enablement + Client->>Adapter: parse and validate CloudBootstrapPayload + Adapter-->>Source: release package identity + source URI + Source->>Verify: download or reuse cached package + Verify->>Verify: check packageHash / manifestHash + Verify-->>Project: normalized manifest + identity + Project->>Ready: projection + host profile + tenant metadata + Ready-->>Runtime: ready / needs-setup / blocked + Runtime-->>Client: show install review, entry status, cleanup plan +``` + +断网分支: + +```mermaid +flowchart TD + Start[启动 Lime Desktop] --> HasCache{本地已有 installed package?} + HasCache -- 否 --> NoApp[展示本地 fixture 或空态] + HasCache -- 是 --> CloudReach{Cloud 可访问?} + CloudReach -- 否 --> Offline[使用本地 package identity / projection cache] + CloudReach -- 是 --> Bootstrap[拉取 bootstrap payload] + Bootstrap --> Disabled{Cloud enabled=false?} + Disabled -- 是 --> DisableOnly[隐藏或禁用 entry,保留数据] + Disabled -- 否 --> HashChanged{hash / release 变化?} + HashChanged -- 否 --> Readiness[重新合并 readiness 输入] + HashChanged -- 是 --> Verify[下载并校验新 package] + Verify --> HashOk{hash 匹配?} + HashOk -- 否 --> Block[拒绝启用并保留旧可用版本] + HashOk -- 是 --> Readiness +``` + +## P5 分期 + +当前状态:P5.0-P5.5 已完成并通过定向验证。P5 证明 Cloud bootstrap 只能作为 source / metadata 输入,不接管本地运行时;后续 P6-P13 已继续完成 schema coverage、schema gate、setup resolver、setup state store、installed state snapshot、local persistence adapter、package cache / verify / rollback 与 runtime package loader,[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 + +### P5.0:标准差距收口 + +目标:把上游 v0.3 标准升级转成客户端可执行清单,避免 P5 只做 Cloud DTO 却漏掉 schema / readiness / lifecycle。 + +交付: + +- 固定上游事实源:`agentapp/docs/zh/specification.md`、`docs/zh/client-implementation/*`、`docs/zh/reference/json-schemas.md`、`docs/examples/content-factory-app/APP.md`。 +- 建立客户端 gap matrix:manifest、projection、readiness、package identity、overlay、lifecycle、SDK error semantics。 +- 明确 P5 不修改正式主导航、不新增 Tauri command、不接市场页。 + +验收: + +```bash +rg -n "scene_exhaustion|content_engineering|shenlan-content-engineering|scene\b|home\b" docs/roadmap/agentapp +``` + +允许结果:只能出现在 legacy / deprecated / 兼容历史说明中,current 计划不能依赖这些对象。 + +### P5.1:Cloud Bootstrap Payload DTO / Parser / Validator + +目标:先定义客户端能安全接受什么,不下载、不安装、不运行。 + +交付: + +- `CloudBootstrapPayload`、`CloudBootstrapApp`、`CloudBootstrapToolAvailability`、validation issue / result 类型。 +- `parseCloudBootstrapPayload()` 和 `validateCloudBootstrapPayload()`。 +- 禁止敏感数据进入 payload:secret、token、customer data、workspace data、knowledge content、private content。 +- `packageUrl` 只允许受信任来源;首轮至少拒绝非 `https:`。 +- `packageHash` / `manifestHash` 必须是具体 `sha256:` 值,不能接受占位符。 +- 默认拒绝 `server-assisted` 作为隐式执行模式;需要时必须在 manifest、tenant policy 和 runtime policy 三处显式声明。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/install/cloudBootstrap.test.ts \ + src/features/agent-app/featureFlag.test.ts +``` + +### P5.2:统一 Package Source Adapter + +目标:让 Cloud、local fixture、dev package 共用同一安装链路。 + +交付: + +- `cloud_release` package identity source kind。 +- `BootstrapPackageSourceAdapter` 把 payload 映射为现有 package source 输入。 +- 只复用现有 manifest parser、projection、readiness、cleanup plan;不新增第二套 Cloud install flow。 +- 支持 cached package:hash 未变化时不重复下载,断网时可使用已安装版本。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/install/cloudBootstrap.test.ts \ + src/features/agent-app/install/packageIdentity.test.ts \ + src/features/agent-app/install/installedAppPreview.test.ts +``` + +## P5.1 落地证据 + +P5.1 当前只覆盖 bootstrap 输入层,未进入 package 下载、安装、projection 或 runtime。 + +| 项 | 证据 | +|---|---| +| DTO | `CloudBootstrapPayload`、`CloudBootstrapApp`、`CloudBootstrapToolAvailability`、`CloudBootstrapLicenseState` 已落到 `src/features/agent-app/types.ts`。 | +| parser / validator | `src/features/agent-app/install/cloudBootstrap.ts` 提供 `validateCloudBootstrapPayload()`、`parseCloudBootstrapPayload()` 和 `AgentAppCloudBootstrapError`。 | +| release identity | `buildCloudReleasePackageIdentity()` 输出 `cloud_release` package identity,并保留 `releaseId`、`tenantId`、`tenantEnablementRef`、`channel`、`signatureRef`。 | +| 安全拒绝 | validator 拒绝非 `https:` package URL、占位 / 非 `sha256:` hash、敏感字段、默认 `allowServerAssisted=true`、非法 license state。 | +| feature flag | `cloudBootstrapEnabled` 默认关闭;开启时只打开 Lab / projection / readiness / cleanup dry-run,不自动启用 mock / adapter / UI / workflow runtime。 | +| 定向测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts` 通过,10 tests。 | +| 回归测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts` 通过,17 tests。 | +| 类型与命令边界 | `npm run typecheck`、`npm run test:contracts`、`git diff --check -- docs/roadmap/agentapp src/features/agent-app src/vite-env.d.ts` 均通过。 | +| 越界审计 | `src/features/agent-app` 中未出现 `safeInvoke` / `invoke` / Tauri 注册 / mock priority / raw Worker 入口。 | +| legacy 审计 | `src/features/agent-app` 与五语言 `agent.json` 中未出现 `contentEngineering`、`scene_exhaustion`、`content_engineering_*`、`shenlan-content-engineering` 等旧 current 关键字。 | + +## P5.2 落地证据 + +P5.2 当前只把 Cloud release metadata 适配到现有 preview / projection / readiness / cleanup 链路,仍未实现下载器、安装器或正式入口。 + +| 项 | 证据 | +|---|---| +| 统一 source | `buildCloudBootstrapPackageSource()` 输出 `cloud_release` source,携带 identity、enabled、defaultEntries、policyDefaults、toolAvailability。 | +| 复用 preview 链路 | `buildCloudBootstrapInstalledAppPreview()` 接收已取得的 package manifest,并复用 `buildInstalledAppPreview()` 的 manifest normalize、projection、readiness、cleanup plan。 | +| identity guard | `buildInstalledAppPreview()` 支持外部 `PackageIdentity`,并拒绝 identity appId / version 与 manifest 不一致的 package。 | +| provenance | Cloud release 的 `packageHash` / `manifestHash` 进入 projection provenance 和 cleanup package cache path。 | +| 定向测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts` 通过,12 tests。 | +| 回归测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts` 通过,19 tests。 | +| 类型与命令边界 | `npm run typecheck`、`npm run test:contracts` 通过;未新增 Tauri command。 | + +### P5.3:Tenant Enablement + Local Readiness 合并 + +目标:Cloud 可以影响“可见 / 默认启用 / tool availability / policy defaults”,但不能绕过本地 readiness。 + +交付: + +- 合并输入:host capability profile、manifest requirements、tenant enablement、tool availability、policy defaults、license state、local storage state。 +- readiness 输出补齐 v0.3 类别:compatibility、capability、package、storage、permissions、knowledge、skills、tools、artifacts、evals、secrets、overlay。 +- 支持 global readiness 和 entry-specific readiness。 +- 每个 blocker / warning 输出 remediation,不泄露 secret 或客户数据。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/readiness/checkReadiness.test.ts \ + src/features/agent-app/projection/projectApp.test.ts \ + src/features/agent-app/manifest/parseManifest.test.ts +``` + +## P5.3 落地证据 + +P5.3 当前只把 Cloud metadata 合并为本地 readiness 输入;Cloud 仍不能直接把 App 标记为 ready。 + +| 项 | 证据 | +|---|---| +| readiness 输入 | `checkReadiness()` 支持 `cloud?: CloudBootstrapApp` 输入,并把 Cloud metadata 与本地 capability/profile 结果合并。 | +| tenant enablement | `enabled=false` 生成 `CLOUD_APP_DISABLED` blocker,并同步到 entry-specific readiness。 | +| license state | `expired` / `revoked` 生成 blocker;`trial` / `unknown` 生成 warning。 | +| ToolHub availability | required tool 的 `missing` / `not-enabled` 生成 blocker;optional tool 生成 warning。 | +| policy defaults | `allowServerAssisted=true` 生成 `CLOUD_POLICY_UNSUPPORTED` blocker,不能绕过本地 runtime policy。 | +| entry-specific readiness | `defaultEntries` 未覆盖的 entry 生成 `CLOUD_ENTRY_NOT_ENABLED` warning。 | +| preview 贯通 | `buildCloudBootstrapInstalledAppPreview()` 自动把 Cloud app metadata 传入 readiness runner。 | +| 定向测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts` 通过,13 tests。 | +| 回归测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts` 通过,20 tests。 | +| 类型与命令边界 | `npm run typecheck`、`npm run test:contracts` 通过;未新增 Tauri command。 | + +### P5.4:Disable / Upgrade / Offline Regression + +目标:验证 Cloud 控制面变化不会破坏本地数据和已安装 App。 + +交付: + +- `enabled=false`:disable only,隐藏或阻断 entry,保留 package、storage、artifact、evidence。 +- hash mismatch:拒绝启用新 release,保留旧 installed package 可审查状态。 +- offline:已安装 App 继续可读、可展示 projection、可按本地 policy 运行已授权 entry。 +- upgrade:新 release 先生成 migration / cleanup preview,不覆盖 overlay、secret、storage、artifact、evidence。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/install/uninstallApp.test.ts \ + src/features/agent-app/runtime/workflowRuntimeHost.test.ts \ + src/features/agent-app/ui/AgentAppLabPage.test.tsx +``` + +## P5.4 落地证据 + +P5.4 当前只提供 Cloud bootstrap install decision,本地不会因为 Cloud 状态变化删除用户数据。 + +| 项 | 证据 | +|---|---| +| 决策 helper | `resolveCloudBootstrapInstallDecision()` 输出 `install_required / up_to_date / upgrade_available / disabled / hash_mismatch / offline_available / offline_unavailable`。 | +| Disable only | `enabled=false` 输出 `disabled`,`shouldDownload=false`、`shouldDeleteData=false`、`preserveData=true`。 | +| Hash mismatch | `actualPackageHash` / `actualManifestHash` 与 Cloud release metadata 不一致时输出 `hash_mismatch`,保留旧 installed identity 可审查状态。 | +| Offline | Cloud 不可达且存在 installed identity 时输出 `offline_available`,允许使用本地 package identity。 | +| Upgrade preview | 同 app 不同 package identity 输出 `upgrade_available`,只提示 `shouldDownload=true`,不覆盖本地数据。 | +| 定向测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts` 通过,17 tests。 | +| 回归测试 | `npm run test -- src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts` 通过,24 tests。 | +| 类型与命令边界 | `npm run typecheck`、`npm run test:contracts` 通过;未新增 Tauri command。 | + +### P5.5:Schema / Reference CLI Cross-check + +目标:把上游标准从“参考文档”变成客户端计划的机械门禁。 + +交付: + +- 用上游 `agentapp-ref` 校验 `content-factory-app` 示例的 validate / project / readiness 输出。 +- 记录 Lime Desktop 当前 projection / readiness 和 reference CLI 的差异。 +- 对差异分类:必须实现、可降级、标准候选反馈、暂缓。 + +验收: + +```bash +npm --prefix /Users/coso/Documents/dev/ai/limecloud/agentapp run cli -- validate docs/examples/content-factory-app +npm --prefix /Users/coso/Documents/dev/ai/limecloud/agentapp run cli -- project docs/examples/content-factory-app +npm --prefix /Users/coso/Documents/dev/ai/limecloud/agentapp run cli -- readiness docs/examples/content-factory-app +``` + +本轮标准基线: + +| 命令 | 结果 | 关键信息 | +|---|---|---| +| `agentapp-ref validate content-factory-app` | 通过 | `findings=0`,manifest hash 为 `sha256:6ac0f5793c37f8a52ef87dd5c70417649c10c659be43a14534b0a811b8d17908`。 | +| `agentapp-ref project content-factory-app` | 通过 | `content-factory-app@0.3.0`,投影 entry 数量为 5。 | +| `agentapp-ref readiness content-factory-app` | 通过 | 状态为 `needs-setup`,输出 32 条 setup checks;这说明 package 有效,但宿主仍需完成 Knowledge / Tool / Skill / Eval / Secret 等准备。 | + +## P5.5 落地证据 + +P5.5 当前完成 reference CLI cross-check 和差异分类,不把 reference CLI 输出直接当 Lime runtime 实现。 + +| 检查项 | Reference CLI | Lime Desktop 当前 | 分类 | +|---|---|---|---| +| Validate | `ok=true`、`findings=0`、manifest hash `sha256:6ac0f5793c37f8a52ef87dd5c70417649c10c659be43a14534b0a811b8d17908`。 | P5 bootstrap validator 只校验 Cloud payload;manifest parser 仍是客户端子集 parser。 | P6 必须实现:schema validator / manifest parser coverage。 | +| Projection entries | `dashboard`、`knowledge_builder`、`content_factory`、`content_strategist`、`content_calendar`。 | `dashboard`、`knowledge`、`content_scenario_planning`、`content_factory`、`content_strategist`。 | 已记录差异:客户端 P4 标杆 key 暂不混用;进入主路径前必须统一到标准示例或形成标准反馈。 | +| Projection fields | 包含 `services`、`workflows`、`toolRequirements`、`evals`、`events`、`secrets`、`overlayTemplates`、`lifecycle`、`ui`。 | 当前 projection 仅覆盖 `app`、`package`、`entries`、`requiredCapabilities`、`runtimePackage`、`storage`、`knowledgeBindings`、`artifactTypes`、`policies`、`provenance`。 | P6 必须实现:v0.3 projection schema 子集扩展。 | +| Readiness kinds | `artifact / capability / eval / knowledge / overlay / secret / service / skill / tool / workflow`,状态 `needs-setup`,32 checks。 | 当前 readiness 覆盖 capability、storage、UI runtime、worker runtime、Cloud app disabled、license、tool availability、policy defaults、entry enablement。 | P6 必须实现:readiness schema、remediation、Knowledge / Skill / Eval / Secret / Overlay checks。 | +| Cloud boundary | Reference docs明确 Cloud 只做 catalog / release / tenant enablement。 | P5.1-P5.4 已保持 Desktop 本地 install / projection / readiness / cleanup;未新增 Tauri command。 | 已满足 P5。 | +| Data cleanup | Reference docs要求 disable / uninstall / export 分离。 | P5.4 install decision 保证 `shouldDeleteData=false`;delete-data 仍由客户端 uninstall / cleanup plan 控制。 | 已满足 P5;P6/P7 再接真实 installed state store。 | + +## 用户故事 + +| 用户故事 | 验收 | +|---|---| +| 作为租户管理员,我希望 Cloud 启用某个 App 后,Desktop 能展示安装审查和 readiness 缺口,而不是自动运行。 | Bootstrap payload 解析成功后只生成 install review / projection / readiness。 | +| 作为普通用户,我希望断网时已安装 App 仍然能打开已有页面和数据。 | Cloud 不可用时使用本地 package identity 和 cache。 | +| 作为安全负责人,我希望 Cloud 下发 disable 时只禁用 App,不删除用户数据。 | `enabled=false` 进入 disable-only 状态,cleanup plan 不执行 delete-data。 | +| 作为 App 作者,我希望同一个 package 在本地 fixture 和 Cloud release 下产生同样 projection。 | local / cloud source 使用同一 parser 和 projection compiler。 | +| 作为维护者,我希望升级失败时不会污染主路径。 | P5 仍在 `src/features/agent-app/` 实验岛,不新增正式导航和 Tauri command。 | + +## 用例 + +### 用例 1:Cloud 启用内容工厂 + +1. LimeCore payload 返回 `content-factory-app@0.3.0`、`registrationRequired=true`、`registrationState`、tenant policy defaults。 +2. 如果 `registrationState!=active`,Desktop 只展示 App 元数据和注册码输入,不生成下载 identity,不请求 package。 +3. 用户提交注册码后,Desktop 调用 `POST /client/agent-apps/:appId/registration` 并刷新云端目录。 +4. 注册激活后,Desktop 校验 package URL / hash,生成 `cloud_release` package identity。 +5. Desktop 下载或复用本地 package,校验 hash。 +6. Desktop 解析 manifest,投影 entries 和 setup tasks。 +7. Desktop readiness 发现缺少 `project_knowledge` 或 ToolHub 授权,展示 `needs-setup`。 +8. 用户完成设置后,Lab 内运行内容工厂 entry。 + +### 用例 2:Cloud 禁用 App + +1. payload 中 `enabled=false`。 +2. Desktop 将 App entry 标记为 disabled。 +3. 本地 storage、artifact、evidence、logs 保留。 +4. 用户仍可从 Lab 看到数据保留说明和 uninstall / export 操作。 + +### 用例 3:hash mismatch + +1. payload 指向新 release,但下载包 hash 与 `packageHash` 不一致。 +2. Desktop 拒绝启用新 release。 +3. 如果已有旧 release,本地保留旧 installed state;如果没有,则展示 blocked。 +4. Evidence / log 记录失败原因,但不记录敏感 payload。 + +## DTO 草案 + +```ts +type CloudBootstrapPayload = { + schemaVersion?: string + generatedAt?: string + tenantId?: string + apps: CloudBootstrapApp[] +} + +type CloudBootstrapApp = { + appId: string + version: string + enabled: boolean + packageUrl: string + packageHash: `sha256:${string}` + manifestHash: `sha256:${string}` + releaseId?: string + channel?: string + signatureRef?: string + capabilityRequirements?: Record + tenantPolicyDefaults?: Record + toolAvailability?: CloudBootstrapToolAvailability[] + licenseState?: 'active' | 'trial' | 'expired' | 'revoked' | 'unknown' + registrationRequired: boolean + registrationState?: 'not_required' | 'required' | 'active' | 'expired' | 'revoked' + registrationHint?: string +} +``` + +DTO 只表达安装和启用元数据;不得携带 App runtime code、客户知识全文、workspace data、secret value 或用户私有内容。 +企业定制 App 未注册时,`packageUrl` / `packageHash` / `manifestHash` 必须为空;客户端必须把 `CLOUD_REGISTRATION_REQUIRED` 作为 blocker,不能绕过云端目录安装。 + +## 风险与止损 + +| 风险 | 止损 | +|---|---| +| Cloud payload 变成业务状态总线 | Validator 禁止客户数据和 secret;adapter 只生成 package identity 和 readiness 输入。 | +| 为了 Cloud 接入新增第二套安装链路 | P5.2 必须复用现有 package source、manifest、projection、readiness、cleanup。 | +| tenant enablement 绕过本地安全检查 | P5.3 明确 local readiness final say;Cloud 只能增加输入,不能直接 ready。 | +| App 失败后难清理 | P5.4 把 disable / uninstall keep data / delete data / export then delete 拆开验证。 | +| 上游标准继续变化 | P5.5 引入 reference CLI / schema cross-check,差异进入 gap matrix。 | + +## Current / Compat / Dead 分类 + +| 分类 | 对象 | 处理 | +|---|---|---| +| current | `src/features/agent-app/**`、`content-factory-app`、`CloudBootstrapPayload`、`cloud_release` package identity | P5 只在实验岛推进。 | +| current | `page / panel / expert-chat / command / workflow / artifact / background-task / settings` | v0.3 entry kind,projection 必须支持或显式阻断。 | +| compat | `tenantPolicyDefaults` 作为服务端字段别名 | 可在 parser 中映射到客户端 policy defaults,不代表旧 Agent App 兼容层。 | +| deprecated | `scene` / `home` entry kind | 只作为历史说明;v0.3 manifest 下应拒绝。 | +| dead | 旧 SceneApp、`sceneapp_*`、`contentEngineering*`、`content_engineering_*`、`shenlan-content-engineering` | 不复活、不新增兼容代码。 | + +## P5 完成判定 + +P5 不是“Cloud 能返回列表”就完成。完成标准是: + +1. Cloud / local / fixture 三种 source 共用一条 package 安装链路。 +2. package hash / manifest hash 校验失败会阻断启用。 +3. projection 和 readiness 含 package provenance,且可由 cache 重建。 +4. tenant enablement、tool availability、policy defaults 只影响 readiness 和 entry 状态,不绕过 SDK policy guard。 +5. disable、offline、upgrade、uninstall keep data、uninstall delete data 均有测试。 +6. `src/features/agent-app` 无 `safeInvoke` / `invoke` / Tauri command / raw Worker 越界入口。 +7. `test:contracts` 通过,证明没有新增命令边界漂移。 diff --git a/docs/roadmap/agentapp/p6-schema-coverage.md b/docs/roadmap/agentapp/p6-schema-coverage.md new file mode 100644 index 000000000..ca50b361f --- /dev/null +++ b/docs/roadmap/agentapp/p6-schema-coverage.md @@ -0,0 +1,82 @@ +# Agent App P6 v0.3 Projection / Readiness Schema Coverage + +更新时间:2026-05-15 + +## 一句话目标 + +P6 的目标是把上游 Agent App v0.3 标准中的 projection / readiness 关键对象落到 Lime Desktop 实验岛:让 `services / workflows / skillRefs / toolRefs / evals / events / secrets / overlayTemplates / lifecycle / ui` 能被 manifest parser 保留、projection 投影、readiness 输出 setup checks,但仍不执行 App code、不接正式主路径。 + +## 背景 + +P5.5 cross-check 发现:上游 reference CLI 的 projection 已覆盖 v0.3 多数对象,而 Lime Desktop 只覆盖了 app、package、entries、capabilities、runtimePackage、storage、knowledge、artifact、policies 的子集。 + +P6 先补 schema coverage,不补完整 runtime: + +| 领域 | P6 做 | P6 不做 | +|---|---|---| +| Manifest | 保留 v0.3 descriptor 字段。 | 不引入完整 JSON Schema validator。 | +| Projection | 生成 host 可读 projection 子集。 | 不注册正式 catalog / route / command。 | +| Readiness | 输出 setup checks、remediation、required 标记。 | 不真的安装 Skill、绑定 Secret、启用 ToolHub。 | +| Runtime | 保持 Lab / SDK / adapter 现状。 | 不执行 raw worker / service。 | + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Manifest 类型 | `src/features/agent-app/types.ts` 增加 `ServiceDeclaration`、`WorkflowDeclaration`、`SkillRefDeclaration`、`ToolRefDeclaration`、`EvalDeclaration`、`EventDeclaration`、`SecretDeclaration`、`OverlayTemplateDeclaration`、`UiDeclaration`、`LifecycleDeclaration`。 | +| Normalize | `normalizeManifest()` 保留 `services / workflows / skillRefs / toolRefs / evals / events / secrets / overlayTemplates / ui / lifecycle`。 | +| Projection | `projectApp()` 输出 `services / workflows / skillRequirements / toolRequirements / evals / events / secrets / overlayTemplates / ui / lifecycle`。 | +| Readiness | `checkReadiness()` 输出 `KNOWLEDGE_BINDING_REQUIRED / SKILL_REQUIRED / TOOL_REQUIRED / ARTIFACT_TYPE_REQUIRED / EVAL_REQUIRED / SECRET_REQUIRED / OVERLAY_REQUIRED / SERVICE_REQUIRED / WORKFLOW_REQUIRED`,包含 `kind / key / required / remediation`。 | +| Fixture | `content-factory-app.json` 补齐 service、workflow、skill、tool、eval、event、secret、overlay、ui、lifecycle 子集。 | +| 运行边界 | setup checks 当前作为 warning / degraded 输出,避免破坏 P1-P4 实验岛 runtime;Cloud disable / license / required tool 仍可产生 blocker。 | + +## 架构图 + +```mermaid +flowchart TD + Manifest[Agent App Manifest v0.3] --> Normalize[normalizeManifest] + Normalize --> Projection[projectApp] + Projection --> Readiness[checkReadiness] + Readiness --> Lab[Agent App Lab] + Projection --> Cleanup[cleanup plan] + Readiness --> RuntimeGuard[SDK / runtime guard] +``` + +## Readiness 映射 + +| Projection 对象 | Readiness kind | Issue code | 当前 severity | +|---|---|---|---| +| `knowledgeBindings` | `knowledge` | `KNOWLEDGE_BINDING_REQUIRED` | warning | +| `skillRequirements` | `skill` | `SKILL_REQUIRED` | warning | +| `toolRequirements` | `tool` | `TOOL_REQUIRED` | warning | +| `artifactTypes` | `artifact` | `ARTIFACT_TYPE_REQUIRED` | warning | +| `evals` | `eval` | `EVAL_REQUIRED` | warning | +| `secrets` | `secret` | `SECRET_REQUIRED` | warning | +| `overlayTemplates` | `overlay` | `OVERLAY_REQUIRED` | warning | +| `services` | `service` | `SERVICE_REQUIRED` | warning | +| `workflows` | `workflow` | `WORKFLOW_REQUIRED` | warning | + +说明:P6 先选择 warning / degraded,是为了避免 P6 schema coverage 破坏已验证的 P1-P4 Lab runtime。进入正式主路径前,required setup 必须升级为 `needs-setup` 或 blocker 语义,并提供真实 resolver / binding 状态。 + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts` | 通过,24 tests。 | +| `npm run test -- src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,22 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| 尚未接完整 JSON Schema validator。 | P7 再接 schema validator 或 reference CLI fixture snapshot。 | +| Reference CLI 示例 entry key 与客户端 P4 current key 不一致。 | 继续记录差异;进入主路径前统一或向标准反馈。 | +| setup checks 目前是 warning,不是完整 `needs-setup` 状态。 | P7/P8 引入 setup resolver 后再升级语义。 | +| Skill / Tool / Secret / Overlay 没有真实 resolver。 | 后续接 AgentSkills / ToolHub / Secret Manager / Overlay Resolver。 | +| Projection 未校验 UI descriptor shape。 | P7 schema validator 覆盖。 | + +## 下一刀 + +P6 已完成最小 schema coverage,后续 P7-P13 已继续完成 schema gate、setup resolver、setup state store、installed state snapshot、local persistence adapter、package cache / verify / rollback 与 runtime package loader。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p7-schema-gate.md b/docs/roadmap/agentapp/p7-schema-gate.md new file mode 100644 index 000000000..5c982154a --- /dev/null +++ b/docs/roadmap/agentapp/p7-schema-gate.md @@ -0,0 +1,49 @@ +# Agent App P7 Schema Validator / Reference Snapshot Gate + +更新时间:2026-05-15 + +## 一句话目标 + +P7 的目标是把 P6 新增的 v0.3 projection / readiness 字段变成可机械验证的门禁,防止后续靠散文记忆维护 schema coverage。当前实现先做本地 schema gate,不引入外部依赖、不接正式主路径。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Projection gate | `src/features/agent-app/schema/schemaGate.ts` 提供 `validateProjectionSchemaCoverage()`,验证 v0.3 projection 必需 object / array 字段与 provenance。 | +| Readiness gate | `validateReadinessSchemaCoverage()` 验证 readiness arrays,以及 setup issue 的 `kind / key / remediation`。 | +| Snapshot test | `src/features/agent-app/schema/schemaGate.test.ts` 固定 content factory fixture 的 projection keys、readiness setup codes,并验证缺失字段会失败。 | +| 导出入口 | `src/features/agent-app/index.ts` 导出 schema gate API,便于后续接 CI / verify script。 | +| 无外部依赖 | 当前不新增 `ajv` 等依赖;P7 只提供本地结构门禁。 | + +## Gate 范围 + +| 对象 | 当前检查 | +|---|---| +| Projection objects | `app / package / runtimePackage / provenance / lifecycle`。 | +| Projection arrays | `entries / requiredCapabilities / knowledgeBindings / artifactTypes / policies / services / workflows / skillRequirements / toolRequirements / evals / events / secrets / overlayTemplates / readinessHints`。 | +| Provenance | projection 和 entry provenance 必须包含 `appId / appVersion / packageHash / manifestHash`。 | +| Readiness arrays | `blockers / warnings / supportedCapabilities / missingCapabilities / entryReadiness`。 | +| Setup issues | `KNOWLEDGE_BINDING_REQUIRED / SKILL_REQUIRED / TOOL_REQUIRED / ARTIFACT_TYPE_REQUIRED / EVAL_REQUIRED / SECRET_REQUIRED / OVERLAY_REQUIRED / SERVICE_REQUIRED / WORKFLOW_REQUIRED` 必须包含 `kind / key / remediation`。 | + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts` | 通过,10 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,49 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| 尚未使用上游 JSON Schema 文件做完整 validation。 | P8 可评估引入轻量 schema validator 或生成本地 schema snapshot。 | +| Gate 只验证结构,不验证每个 descriptor 的深层字段类型。 | 后续按风险补充 descriptor-level validator。 | +| Reference CLI 输出没有纳入自动测试。 | 后续可通过 env 指定 `AGENTAPP_STANDARD_ROOT` 后运行 optional reference check。 | +| setup issues 仍是 warning / degraded。 | 进入正式主路径前需要 setup resolver 和 `needs-setup` 语义。 | + +## 下一刀 + +P7 已完成本地 schema gate,后续 P8-P13 已继续完成 setup resolver、setup state store、installed state snapshot、local persistence adapter、package cache / verify / rollback 与 runtime package loader。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p8-setup-resolver.md b/docs/roadmap/agentapp/p8-setup-resolver.md new file mode 100644 index 000000000..811ca09d4 --- /dev/null +++ b/docs/roadmap/agentapp/p8-setup-resolver.md @@ -0,0 +1,67 @@ +# Agent App P8 Setup Resolver / Needs-Setup Semantics + +更新时间:2026-05-15 + +## 一句话目标 + +P8 的目标是把 P6/P7 中“结构可见”的 setup checks 推进成明确的 readiness 语义:当 Knowledge、Skill、Tool、Secret、Overlay、Eval、Service、Workflow 等 resolver 输入缺失时,App 进入 `needs-setup`,而不是被误判为 `ready` 或普通 `degraded`。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| 状态语义 | `ReadinessStatus` 增加 `needs-setup`。 | +| Resolver 输入 | `AgentAppSetupState` 支持 `knowledgeBindings / skills / tools / artifactTypes / evals / secrets / overlays / services / workflows`。 | +| Readiness 合并 | `checkReadiness()` 接收 `setup?: AgentAppSetupState`;未 resolved 的 setup descriptor 生成 setup issue,已 resolved 的 descriptor 不再报缺口。 | +| UI 文案 | Agent App Lab 五语言新增 `agentApp.lab.status.needs-setup`。 | +| UI 表现 | `needs-setup` 使用蓝色状态,图标走待处理提示,不显示成成功。 | +| 运行边界 | P8 没有接真实 resolver / ToolHub / Secret Manager;只定义 readiness 输入和状态语义。 | + +## 状态决策 + +```text +存在 hard blocker(capability 缺失、Cloud disabled、hash/policy 阻断等) -> blocked +无 hard blocker,但存在 required setup issue -> needs-setup +无 required setup issue,但存在 warning -> degraded +无 issue -> ready +``` + +说明:当前 setup issue 的 `severity` 仍是 warning,以避免破坏 P1-P4 Lab runtime;但全局 `status` 会进入 `needs-setup`,从产品语义上避免假 ready。进入正式主路径前,runtime guard 应按入口策略决定是否允许 `needs-setup` 下的 demo / preview 动作。 + +## Resolver 输入草案 + +```ts +type AgentAppSetupState = { + knowledgeBindings?: Record + skills?: Record + tools?: Record + artifactTypes?: Record + evals?: Record + secrets?: Record + overlays?: Record + services?: Record + workflows?: Record +} +``` + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx src/i18n/__tests__/translation-coverage.test.ts src/i18n/__tests__/loadNamespace.test.ts src/i18n/__tests__/types.test.ts` | 通过,29 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,50 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| Resolver 现在只有布尔输入,没有真实数据来源。 | P9 接本地 installed setup state store。 | +| Tool / Secret / Overlay / Skill 尚未接真实服务。 | 后续分别接 ToolHub、Secret Manager、Overlay Resolver、AgentSkills catalog。 | +| Entry-specific setup 尚未细分到每个 workflow / page。 | P9/P10 增加 entry-level setup resolver。 | +| `needs-setup` 下 runtime guard 仍允许 Lab demo。 | 正式主路径前按 entry policy 加严格 guard。 | + +## 下一刀 + +P8 已完成 readiness 语义层,后续 P9-P13 已继续完成本地 setup state store、installed state snapshot、local persistence adapter、package cache / verify / rollback 与 runtime package loader。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/p9-setup-state-store.md b/docs/roadmap/agentapp/p9-setup-state-store.md new file mode 100644 index 000000000..6167d325e --- /dev/null +++ b/docs/roadmap/agentapp/p9-setup-state-store.md @@ -0,0 +1,68 @@ +# Agent App P9 Installed Setup State Store + +更新时间:2026-05-15 + +## 一句话目标 + +P9 的目标是把 P8 的 `AgentAppSetupState` 从测试参数推进为本地可查询、可清理的 setup state store。Store 只保存 binding 状态和引用,不保存 Secret 明文、客户知识正文、workspace data 或 App runtime 业务数据。 + +## 当前落地 + +| 项 | 证据 | +|---|---| +| Store 类型 | `AgentAppSetupBindingKind`、`AgentAppSetupBindingRecord` 已加入 `src/features/agent-app/types.ts`。 | +| In-memory store | `src/features/agent-app/install/setupStateStore.ts` 提供 `InMemoryAgentAppSetupStateStore`。 | +| State 聚合 | `buildSetupStateFromBindings()` 把 binding records 聚合成 `AgentAppSetupState`。 | +| 查询 / 清理 | Store 支持 `upsert()`、`list()`、`getSetupState()`、`remove()`、`clearApp()`。 | +| Preview 集成 | `buildInstalledAppPreview()` 接收 `setup`,readiness 可由 store 输出消除 `needs-setup`。 | +| Cleanup 集成 | `AppCleanupPlan` 增加 `setupStatePaths`,`buildCleanupPlan()` 输出 `/agent-apps/setup/.json`。 | +| Uninstall 集成 | Mock / Adapter host 的 delete-data 路径包含 `setupStatePaths`。 | +| UI Preview | Lab cleanup panel 把 setup state path 纳入 projection/readiness 清理组展示。 | + +## 数据边界 + +允许保存: + +```text +appId +kind +key +resolved +ref +source +updatedAt +``` + +禁止保存: + +```text +secret value +OAuth token +API key +客户知识正文 +workspace 文件内容 +App storage record +Tool 调用输入输出全文 +``` + +## 验证记录 + +| 命令 | 结果 | +|---|---| +| `npm run test -- src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,14 tests。 | +| `npm run test -- src/features/agent-app/schema/schemaGate.test.ts src/features/agent-app/manifest/parseManifest.test.ts src/features/agent-app/projection/projectApp.test.ts src/features/agent-app/readiness/checkReadiness.test.ts src/features/agent-app/install/cloudBootstrap.test.ts src/features/agent-app/install/setupStateStore.test.ts src/features/agent-app/featureFlag.test.ts src/features/agent-app/sdk/MockCapabilityHost.test.ts src/features/agent-app/adapters/AdapterCapabilityHost.test.ts src/features/agent-app/runtime/contentFactoryDemo.test.ts src/features/agent-app/runtime/workflowRuntimeHost.test.ts src/features/agent-app/ui/AgentAppLabPage.test.tsx` | 通过,53 tests。 | +| `npm run typecheck` | 通过。 | +| `npm run test:contracts` | 通过。 | + +## 剩余差距 + +| 差距 | 处理 | +|---|---| +| Store 当前已能进入 P11 installed state persistence。 | P12 已继续完成 package cache / verify / rollback。 | +| Resolver 仍由测试 records 构造。 | 后续接真实 Knowledge / Skill / Tool / Secret / Overlay resolver。 | +| setup state 删除只进入 host delete-data 目标,还没有真实文件删除。 | 等本地持久化后接真实删除。 | +| Entry-specific setup state 尚未细化。 | 后续按 entryKey 扩展 binding record。 | + +## 下一刀 + +P9 已完成本地 setup state store 的最小闭环,后续 P10-P13 已继续完成 installed state snapshot、local persistence adapter、package cache / verify / rollback 与 runtime package loader。[P14 Entry Runtime Guard / Permission Prompt](./p14-entry-runtime-guard-permission-prompt.md) 与 [P15 Lab Install / Launch Flow](./p15-lab-install-launch-flow.md) 已完成当前实现与定向验证,P15-H 已补 Agent App Lab 专用 GUI smoke / cleanup rehearsal 证据,P16 已完成最小 Agent App Manager;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。 diff --git a/docs/roadmap/agentapp/v0.3-rebaseline-plan.md b/docs/roadmap/agentapp/v0.3-rebaseline-plan.md new file mode 100644 index 000000000..ece89e109 --- /dev/null +++ b/docs/roadmap/agentapp/v0.3-rebaseline-plan.md @@ -0,0 +1,259 @@ +# Agent App v0.3 / 内容工厂 Rebaseline 计划 + +更新时间:2026-05-15 + +## 一句话结论 + +`docs/roadmap/agentapp` 已升级到 Agent App v0.3 与 `内容工厂`事实源;P4-R Rebaseline 已把客户端实验岛、fixture、demo、workflow runtime、Lab UI 和测试对齐到 `content-factory-app / content_scenario_planning / content_factory_demo / content_table / content_scenarios`。P5-P16 已继续把 Cloud bootstrap、schema coverage、schema gate、setup resolver、setup state store、installed state snapshot、local persistence adapter、package cache / verify / rollback、runtime package loader、entry runtime guard、Lab install / launch flow、Agent App Lab 专用 GUI smoke 与最小 Agent App Manager 补齐;P17 Gate 审计、P17.0 Formal Entry Contract、P17.1 Formal route / nav / copy hardening、P17.2.1 Source state model、P17.2.2 Install review descriptor、P17.2.3 Registration hardening 与 P17.2.4a Cloud release descriptor / verification gate、P17.2.4b-1 acquisition seam / verified cache source、P17.2.4b-2 packageUrl fetch / staging / manifest extraction 与 P17.2.5 public schema / reference CLI / standard example package cross-check 已完成,P17.3 lifecycle / cleanup contract 与 P17.4 runtime surface production hardening 已完成,当前进入 P17.5 formal entry GUI smoke。上游标准新增 public schema、reference CLI、release metadata、runtime model 和 overlay resolver 后,P17.3 已把 source / install 已验证的 package code 与 overlay、secret binding、setup state、cleanup evidence 分离;P17.4 已补 Host Bridge 与完整 GUI smoke 证据。 + +## 升级后的事实源 + +| 事实源 | 关键变化 | 对实施计划的影响 | +|---|---|---| +| `README.md` | 标准从 v0.2 升级到 v0.3,标杆 App 改为 `内容工厂`。 | Host profile、manifest normalize、readiness 与测试夹具要支持 v0.3。 | +| `content-factory-app.md` | 产品从“内容工程化案例”升级为可安装垂直 App:项目首页、知识库、内容场景规划、内容工厂、策略分析、PPT / 报告、数据复盘。 | P4 仍只做最小闭环,但命名和数据对象必须贴近完整 App。 | +| `p4-content-factory-demo.md` | P4.1 接口改为 `runContentFactoryDemo()`,入口改为 `content_scenario_planning`。 | 旧 `runContentEngineeringDemo()` 不能继续作为 current 主实现。 | +| `p4-workflow-runtime.md` | P4.2 workflow definition 改为 `content_factory_demo`。 | runtime 已有雏形,但需要改到新 workflow key 和 storage/artifact/evidence kind。 | +| 当前工作区 | 旧 SceneApps 主路径正在被清理。 | Agent App 不应复活 `sceneapp_*` 命令或旧 SceneApps 页面;继续保持 `src/features/agent-app/` 实验岛。 | +| `/Users/coso/Documents/dev/ai/limecloud/agentapp/docs/zh/client-implementation/*` | 新增 discovery / installation、projection、readiness、runtime、安全、overlay、typed SDK 宿主实现指南。 | P17.2.4a 已把 Cloud metadata 落成 release descriptor;P17.2.4b-1 已把 acquisition seam / verified cache source 接入安装主链;P17.2.4b-2 已补 packageUrl fetch / staging;P17.2.5 已以 schema / reference CLI 做机械 cross-check,后续 P17.3 与 P17.4 已完成,当前进入 P17.5 formal entry GUI smoke。 | +| `/Users/coso/Documents/dev/ai/limecloud/agentapp/docs/public/schemas/*` 与 `bin/agentapp-ref.mjs` | 标准开始提供 public JSON Schema 与 reference CLI。 | 客户端不能只靠内部 fixture;需要和 `content-factory-app` 标准示例包做投影 / readiness 对照。 | + +## P4-R 执行状态 + +| 阶段 | 当前状态 | 证据 / 下一步 | +|---|---|---| +| P4-R0 计划与事实源收口 | 已完成 | `README.md`、`implementation-plan.md` 与本文都已把 P5 前置 gate 调整为 v0.3 / 内容工厂 rebaseline。 | +| P4-R1 v0.3 manifest / fixture / readiness | 已完成 | `content-factory-app.json` 成为 current fixture;manifest normalize、projection、readiness 和 host capability profile 已支持 v0.3。 | +| P4-R2 内容工厂 demo rebaseline | 已完成 | current API 是 `runContentFactoryDemo()`;storage / artifact / evidence key 收口到 `content_scenarios/*`、`content_table`、`content_factory_demo`。 | +| P4-R3 WorkflowRuntimeHost rebaseline | 已完成 | workflow key 收口到 `content_factory_demo`;trace step 使用内容工厂 current 语义。 | +| P4-R4 Lab UI / i18n / tests rebaseline | 已完成 | `AgentAppLabPage`、导出 API 与五语言 `agent.json` 已切到 内容工厂。 | +| P4-R5 Gate 恢复与旧 SceneApp 不复活 | 已完成 | `test:contracts`、`governance:legacy-report` 与 `LIME_VITEST_BATCH_SIZE=8 npm run verify:local` 已通过;未复活 `sceneapp_*` Rust 命令或旧 SceneApps 页面。 | +| P5 Cloud Bootstrap | 已通过 | P4-R5 与 P5.0-P5.5 已通过;不让 Cloud 运行默认 Agent。详见 [p5-cloud-bootstrap.md](./p5-cloud-bootstrap.md)。 | +| P6 Schema Coverage | 已通过 | v0.3 projection / readiness schema coverage 已补到客户端实验岛。详见 [p6-schema-coverage.md](./p6-schema-coverage.md)。 | +| P7 Schema Gate | 已通过 | 本地 schema / snapshot gate 已覆盖 projection / readiness 结构与 setup issue 字段。详见 [p7-schema-gate.md](./p7-schema-gate.md)。 | +| P8 Setup Resolver | 已通过 | `AgentAppSetupState` 与 `needs-setup` readiness 语义已落地。详见 [p8-setup-resolver.md](./p8-setup-resolver.md)。 | +| P9 Setup State Store | 已通过 | 本地 setup state store、readiness 输入和 cleanup preview 已集成。详见 [p9-setup-state-store.md](./p9-setup-state-store.md)。 | +| P10 Installed State | 已通过 | Installed state snapshot、in-memory store、cleanup preview 与导出入口已集成。详见 [p10-installed-state-persistence.md](./p10-installed-state-persistence.md)。 | +| P11 Local Persistence Adapter | 已通过 | P10 installed state 已从 in-memory 推进到本地可恢复 adapter;未做下载器、市场页或正式主导航。详见 [p11-local-persistence-adapter.md](./p11-local-persistence-adapter.md)。 | +| P12 Package Cache / Verify / Rollback | 已通过 | Package cache、hash verify、cached fallback、upgrade staging 和 rollback 已落地;未执行 package JS。详见 [p12-package-cache-verify-rollback.md](./p12-package-cache-verify-rollback.md)。 | +| P13 Runtime Package Loader / UI Bundle Loader | 已通过 | 已从 verified package cache 读取 runtime descriptor / UI bundle descriptor,并继续通过 UI Host 和 policy gate。详见 [p13-runtime-package-loader.md](./p13-runtime-package-loader.md)。 | +| P14 Entry Runtime Guard / Permission Prompt | 已通过 | 合并 entry readiness、permission、setup state、package verification 和 runtime policy,并接入 Lab run / mount 前置路径。详见 [p14-entry-runtime-guard-permission-prompt.md](./p14-entry-runtime-guard-permission-prompt.md)。 | +| P15 Lab Install / Launch Flow | 已通过 | 已串联 install review、verified cache、installed state、permission prompt、runtime launch 和 cleanup preview。详见 [p15-lab-install-launch-flow.md](./p15-lab-install-launch-flow.md)。 | +| P15-H GUI Smoke / Cleanup Rehearsal | 已通过专用验证 | 已补 Agent App Lab 专用 smoke、summary / screenshot 证据和 cleanup rehearsal 口径。详见 [p15-h-gui-smoke-cleanup-rehearsal.md](./p15-h-gui-smoke-cleanup-rehearsal.md)。 | +| P16 Agent App Manager / Product Entry Gate | 已通过最小实现 | 已在实验岛内验证 App 管理面、entry launcher、disable / uninstall preview 和 cleanup evidence。详见 [p16-agent-app-manager-product-entry-gate.md](./p16-agent-app-manager-product-entry-gate.md)。 | +| P16-H Multi-app repository / lifecycle hardening | P17.2.1-P17.2.5 已完成,下一刀 P17.3 lifecycle / cleanup contract hardening | 详见 [p16-h-multi-app-repository-lifecycle-hardening.md](./p16-h-multi-app-repository-lifecycle-hardening.md)、[p17-formal-entry-contract.md](./p17-formal-entry-contract.md) 与 [p17-source-install-contract-hardening.md](./p17-source-install-contract-hardening.md);继续硬化 lifecycle / cleanup、overlay / setup / secret binding 和 evidence 边界,不直接发布 marketplace 或 Cloud 管理台。 | + +## P4-R5 验证记录 + +更新时间:2026-05-15 + +| 校验 | 结果 | 说明 | +|---|---|---| +| `rg` legacy audit | 通过 | `src/features/agent-app` 与五语言 `agent.json` 中不再出现 `contentEngineering`、`scene_exhaustion`、`content_engineering_*`、`shenlan-content-engineering` 等旧 current 关键字。 | +| `rg` boundary audit | 通过 | `src/features/agent-app` 没有 `safeInvoke` / `invoke`、Tauri 注册、mock priority、raw Worker 等越界入口。 | +| `npm run test:contracts` | 通过 | 命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。 | +| `npm run governance:legacy-report` | 通过 | 边界违规为 0;`rust-sceneapp-command-module-surface` 继续保持 `dead / 零引用`。 | +| `LIME_VITEST_BATCH_SIZE=8 npm run verify:local` | 通过 | 覆盖 `verify:app-version`、`lint`、`typecheck`、105 批 Vitest、`test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 与 `verify:gui-smoke`。 | + +门禁修复:首轮 `verify:local` 暴露 `useServiceSkills.test.tsx` 隐式依赖未声明的 `get_automation_jobs` Tauri mock;已改为测试内显式 mock `getAutomationJobs()`,避免把 service skills 测试耦合到全局 browser mock。 + +补充清理:Desktop 端已继续下线 SceneApp 首页推荐卡片、Workspace 启动 Hook、launch bridge / launcher / launch draft 旧链路,以及自动化详情页对 `planSceneAppLaunch` 等 retired API 的运行时调用;历史 `sceneapp_execution_summary` 仍仅作为只读回看与结果复盘参考保留。治理目录册新增 `sceneapp-active-launch-surface` / `sceneapp-active-launch-frontend-surface`,防止后续把独立 SceneApp 启动面接回 Agent Chat 首页或 Workspace。 + +继续清障:Desktop 端已删除旧 `src/lib/sceneapp/catalog.ts` catalog 编译 / 存储投影,并从 retired API 网关和产品 view model 中移除 `listSceneAppCatalog`、`SceneAppCatalog`、目录统计卡与目录卡片 helper;Agent App 后续作为应用目录与运行装配的唯一 current 事实源,历史 `sceneapp_execution_summary` 只读链不受影响。 + +## 新实施顺序 + +```text +P4-R0~P4-R5 v0.3 / 内容工厂 Rebaseline(已通过) +→ P5.0 v0.3 标准差距收口(已通过) +→ P5.1 Cloud Bootstrap payload DTO / parser / validator(已通过) +→ P5.2 Bootstrap payload 本地 package source adapter(已通过) +→ P5.3 Tenant enablement + local readiness 合并判断(已通过) +→ P5.4 hash mismatch / disable / offline regression tests(已通过) +→ P5.5 schema / reference CLI cross-check(已通过) +→ P6 v0.3 projection / readiness schema coverage(已通过) +→ P7 schema validator / reference snapshot gate(已通过) +→ P8 setup resolver / needs-setup semantics(已通过) +→ P9 installed setup state store(已通过) +→ P10 installed app state / persistence(已通过) +→ P11 local persistence adapter(已通过) +→ P12 package cache / verify / rollback(已通过) +→ P13 runtime package loader / UI bundle loader(已通过) +→ P14 entry runtime guard / permission prompt(已通过) +→ P15 lab install / launch flow(已通过) +→ P15-H GUI smoke / cleanup rehearsal hardening(已通过专用验证) +→ P16 Agent App Manager / Product Entry Gate(已通过最小实现) +→ P16-H.0 Multi-app repository / lifecycle hardening 计划收口(已通过) +→ P16-H.1 Repository-backed multi-app list(已完成最小实现) +→ P16-H.2 Selected app launcher + persisted lifecycle(已完成最小实现) +→ P16-H.3 Cleanup rehearsal evidence export(已完成最小实现) +→ P16-H.4 Residual audit(已完成最小实现) +→ P16-H.5 Agent App Lab GUI smoke + flag-off regression(已完成最小实现) +→ P17 正式入口前 Gate 审计(已完成) +→ P17.0 Formal Entry Contract(已完成计划收口) +→ P17.1 Formal route / nav / copy hardening(已完成最小实现) → P17.2.1 Source state model(已完成) → P17.2.2 Install review descriptor(已完成) → P17.2.3 Registration hardening(已完成最小实现) +→ P17.2.4a Cloud release descriptor / verification gate(已完成) → P17.2.4b-1 Cloud package acquisition seam / verified cache source(已完成) → P17.2.4b-2 packageUrl fetch / staging(已完成) → P17.2.5 Schema / reference CLI / example package cross-check(已完成) +→ P17.3 Lifecycle / cleanup contract hardening(已完成) +→ P17.4 Runtime surface production hardening(已完成,含 P17.4.5 GUI smoke) +→ P17.5 Formal entry GUI smoke(下一刀) +``` + +## 执行边界 + +1. 本计划只更新 Lime Desktop 客户端,不把 Cloud / LimeCore 职责写进 `lime/docs/roadmap/agentapp`。 +2. `/Users/coso/Documents/dev/ai/limecloud/agentapp` 作为 v0.3 标准事实源,约束 schema、capability、entry kind、runtime package、release descriptor、overlay、readiness、reference CLI 和 provenance。 +3. `内容工厂`的客户端 P4 current 命名以本目录为准:`content-factory-app`、`content_scenario_planning`、`content_factory_demo`、`content_table`、`content_scenarios/*`。 +4. 如果上游标准示例和客户端 P4 demo 出现业务 key 差异,先保持客户端单一事实源,不在源码里混用两套 key。 +5. P4-R 只做 rebaseline,不新增完整 SaaS、市场页、正式主导航或 raw worker sandbox。 + +## 文件级落点 + +| 阶段 | 主要改动 | 文件边界 | +|---|---|---| +| P4-R1 | v0.3 manifest、fixture、readiness、capability profile。 | `types.ts`、`manifest/*`、`fixtures/content-factory-app.json`、`projection/*`、`readiness/*`、`install/installedAppPreview.ts`。 | +| P4-R2 | demo API 与业务对象改名,storage / artifact / evidence kind 收口。 | `runtime/contentFactoryDemo.ts`、`runtime/contentFactoryDemo.test.ts`、`adapters/*`、`sdk/*`。 | +| P4-R3 | workflow definition 和 trace step 改到内容工厂 current key。 | `runtime/workflowRuntimeHost.ts`、`runtime/runtimePolicy.ts`、`runtime/workflowRuntimeCapabilityProfile.ts`。 | +| P4-R4 | Lab UI、五语言文案、导出 API 和 UI 测试改名。 | `ui/AgentAppLabPage.tsx`、`ui/AgentAppLabPage.test.tsx`、`index.ts`、`src/i18n/resources/*/agent.json`。 | +| P4-R5 | 验证 gate、dangling 引用和旧 SceneApp 清障。 | `src/features/agent-app/*`、`src-tauri/*` 中仍引用旧 SceneApp 的位置。 | + +## P4-R0:计划与事实源收口 + +目标:先避免继续沿旧命名堆实现。 + +交付: + +- `README.md` 的当前执行顺序改成“先 rebaseline,再 P5”。 +- `implementation-plan.md` 明确 P4-R 是进入 P5 的前置 gate。 +- 所有 current 文档以 `内容工厂` 为主,不再把 `AI 内容工程化` 作为 current 名称。 + +验收: + +```bash +rg -n "AI 内容工程化|contentEngineering|ContentEngineering|content_engineering|scene_exhaustion|scenes/" docs/roadmap/agentapp +``` + +允许结果:只出现在历史说明或明确标注为 legacy / pre-v0.3 的段落;current 计划不能再出现。 + +## P4-R1:v0.3 Manifest / Fixture / Readiness + +目标:让 App Host 读取 v0.3 package,而不是继续伪装 v0.2。 + +交付: + +- `fixtures/content-factory-app.json` 替换旧 `content-engineering-app.json`。 +- manifest 支持 `manifestVersion: 0.3.0`,normalize 后保留 `"0.3"`。 +- `appId = content-factory-app`,storage namespace 同名。 +- entries 至少包含:`dashboard`、`knowledge`、`content_scenario_planning`、`content_factory`、`content_strategist`。 +- capability range 与 host profile 对齐 `^0.3.0`,不再靠旧 `^0.1.0` 过关。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/manifest/parseManifest.test.ts \ + src/features/agent-app/projection/projectApp.test.ts \ + src/features/agent-app/readiness/checkReadiness.test.ts \ + src/features/agent-app/featureFlag.test.ts +``` + +## P4-R2:内容工厂 Demo Rebaseline + +目标:把 P4.1 业务闭环从旧“内容工程化样板”改成 `内容工厂` 最小闭环。 + +交付: + +- `runtime/contentFactoryDemo.ts` / `contentFactoryDemo.test.ts` 成为 current 主文件。 +- 导出 `runContentFactoryDemo()`、`ContentFactoryProjectRecord`、`ContentFactoryScenarioRecord`、`ContentFactoryAssetRecord`。 +- 默认入口改为 `content_scenario_planning`。 +- storage 写入:`projects/*`、`knowledge-bindings/*`、`content_scenarios/*`、`content-assets/*`。 +- Artifact kind 改为 `content_table`。 +- Evidence kind 改为 `content_factory_demo`。 +- 旧 `contentEngineering*` 不保留 compat,实验岛无人依赖时直接删除。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/runtime/contentFactoryDemo.test.ts \ + src/features/agent-app/adapters/AdapterCapabilityHost.test.ts \ + src/features/agent-app/sdk/MockCapabilityHost.test.ts +``` + +## P4-R3:Workflow Runtime Rebaseline + +目标:保留已完成的受控 DSL runner,但把 workflow definition 对齐新内容工厂对象。 + +交付: + +- `WorkflowRuntimeHost` 继续只允许白名单 step。 +- `runContentFactoryDemo({ workflowRuntime })` 执行 `workflowKey = content_factory_demo`。 +- workflow trace 中出现 `store-project`、`search-knowledge`、`store-content-scenarios`、`store-content-assets`、`create-content-table`、`record-content-evidence`。 +- policy 仍固定阻断 raw worker、外部代码、网络、文件系统。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/runtime/workflowRuntimeHost.test.ts \ + src/features/agent-app/runtime/contentFactoryDemo.test.ts +``` + +## P4-R4:Lab UI / i18n / Index Export Rebaseline + +目标:用户可见层全部切到 内容工厂。 + +交付: + +- `AgentAppLabPage` 按 `ContentFactoryDemoResult` 展示项目、内容场景、内容资产、Artifact、Evidence、workflow trace。 +- 五语言 `agent.json` 更新:`zh-CN / zh-TW / en-US / ja-JP / ko-KR`。 +- `src/features/agent-app/index.ts` 导出 content factory current API。 +- UI 测试不再查询 `scene_exhaustion` 或 `content_engineering_demo`。 + +验收: + +```bash +npm run test -- \ + src/features/agent-app/ui/AgentAppLabPage.test.tsx \ + src/i18n/__tests__/translation-coverage.test.ts \ + src/i18n/__tests__/loadNamespace.test.ts \ + src/i18n/__tests__/types.test.ts +``` + +## P4-R5:Gate 恢复与旧 SceneApp 不复活 + +目标:把升级后的 Agent App 与当前 SceneApp 清理方向一起收口,不用修旧产品来绕过失败。 + +交付: + +- `src/features/agent-app` 不包含 Tauri `invoke`、不新增 command。 +- 不恢复已删除的 `src-tauri/src/sceneapp/*`、`src/components/sceneapps/SceneAppsPage.tsx` 等旧 SceneApp 主路径。 +- 如果 Rust 仍引用 `crate::sceneapp`,优先删除或改到当前事实源,而不是重建旧模块。 + +验收: + +```bash +rg -n "safeInvoke|invoke\(|tauri::|generate_handler|mockPriorityCommands|defaultMocks|new Worker|Worker\(" src/features/agent-app || true +npm run test:contracts +LIME_VITEST_BATCH_SIZE=8 npm run verify:local +``` + +## 后续进入门槛 + +P4-R 已通过,并且 P5-P16 已按实验岛边界继续推进。当前进入 P16-H 时仍必须保持以下边界: + +1. current 代码和文档都只承认 Agent App v0.3 / 内容工厂。 +2. `smoke:agent-app-lab` 与最新 `verify:gui-smoke` 均通过,summary / screenshot / cleanup preview 可复核;后续若全局 smoke 再受外部 provider/model 影响,必须单独记录。 +3. 内容工厂 P4.1 / P4.2 定向测试通过。 +4. delete-data 能清理 content factory demo 的 storage / artifact / evidence / task。 +5. 没有复活旧 SceneApp runtime 或旧 `sceneapp_*` command。 + +P14 已经完成 entry runtime guard / permission prompt 的当前实现,P15 已把这些能力串成 Lab-only 安装启动闭环,P15-H 已补专用 GUI smoke,P16 已补最小 App Manager;P17 Gate 审计、P17.0 Formal Entry Contract 与 P17.1 Formal route / nav / copy hardening 已完成,下一刀先做 P17.2 Source / install contract hardening;不把 App 接进 marketplace、真实 delete-data 或 Cloud 管理台,Cloud 不运行默认 Agent。 + +P14 不做: + +1. 不做 Agent App 市场页、审核流、支付或企业控制台。 +2. 不把 App entry 接入正式主导航。 +3. 不让 Cloud / LimeCore 运行默认 Agent Runtime、渲染 App UI 或管理本地 App storage。 +4. 不新增第二套安装、readiness、persistence 或 cleanup 链路。 +5. 不做 raw worker sandbox、任意 package JS 执行或完整行业内容系统。 +6. P16-H 必须继续复用 P14 guard,不允许为了 App Manager、演示、smoke 或后续正式入口而绕过授权 gate。 diff --git a/docs/roadmap/agentruntime/README.md b/docs/roadmap/agentruntime/README.md index e10668aad..c3d90b6d8 100644 --- a/docs/roadmap/agentruntime/README.md +++ b/docs/roadmap/agentruntime/README.md @@ -1,7 +1,7 @@ # Lime AgentRuntime Profile 路线图 > 状态:implementation-audited -> 更新时间:2026-05-12 +> 更新时间:2026-05-16 > 目标:把 Lime 已有的运行时、任务、模型路由、权限、证据、GUI read model 与 AgentRuntime 标准收敛成一条可执行、可测试、可回放的 current 主链。 > 上游标准草案:`/Users/coso/Documents/dev/ai/limecloud/agentruntime`,当前对齐 `Agent Runtime v0.4.0` 与 `Lime AgentRuntime Profile`。 @@ -20,6 +20,9 @@ - [./prd.md](./prd.md):背景、目标、收益、用户故事、范围、验收口径 - [./architecture.md](./architecture.md):分层、事实源、接口与 profile 映射 - [./adjacent-protocols.md](./adjacent-protocols.md):`agentcontext`、`agentevidence`、`agentpolicy`、`agentui` 的 owner 边界与连接合同 +- [./app-surface-runtime.md](./app-surface-runtime.md):Agent App 如何作为业务 surface 复用 AgentRuntime / Claw 主链 +- [./claw-capability-sharing.md](./claw-capability-sharing.md):Claw `@` 能力如何抽象为 Chat、Agent App、Automation 可共享 capability +- [./backend-surface-facade-plan.md](./backend-surface-facade-plan.md):Agent App runtime command 与共享后端 surface facade 计划 - [./diagrams.md](./diagrams.md):架构图、流程图、时序图 - [./implementation-plan.md](./implementation-plan.md):分阶段落地计划、风险和测试策略 - [./test-cases.md](./test-cases.md):结构测试、契约测试、回放测试、证据一致性和 GUI smoke 用例 @@ -58,6 +61,12 @@ Lime 当前最大问题不是功能不足,而是功能已经很多,却缺少 6. **全球本地化不进入运行事实源。** `type/status/taskKind/source/failureCategory/reasonCode` 等 profile 字段必须是稳定协议值,不能按语言环境变化;用户可见标题、说明、错误提示、按钮和空态只能在 AgentUI / GUI projection 层通过 key-based i18n 渲染。Runtime 可以携带 `message` 作为诊断事实,但不能把中文或英文展示文案当成状态机、测试断言或跨模块 join 条件。 +7. **Chat、Claw、Agent App、Automation 都只是 runtime surface。** + 完整 AI 能力只能向 AgentRuntime facts 收敛;内容工厂这类 App 不能把 `LIME_GATEWAY_*`、模型 API 或嵌入通用 Chat 当成 Agent 能力边界。App 内 `lime.agent` / `lime.workflow` 必须通过 Agent App Runtime Surface 复用 Aster / Claw / Skills / Tools / Evidence 主链,详见 [./app-surface-runtime.md](./app-surface-runtime.md)。 + +8. **Claw 能力要 catalog 化复用,不复制实现。** + `@配图`、`@搜索`、`@研报`、`@读PDF` 等已实现能力要从 Chat 入口抽象为 typed capability;Chat `@命令` 和 Agent App task 只是不同行为入口,不能为 App 复制一套 `*_skill_launch.rs`。 + ## 3. 固定主链 后续所有实现必须收敛到下面这条链: @@ -95,6 +104,8 @@ Objective / User Input 3. `agent_runtime_export_evidence_pack` 及其 replay / analysis / review 派生物。 4. `TaskProfile / RoutingDecision / LimitState` 已接入的模型路由事实。 5. Workspace / Harness GUI 对 runtime read model 的只读投影。 +6. Agent App Runtime Surface 作为新调用面,委托 AgentRuntime 主链执行 App-scoped task;`AgentAppRuntimePage` 的 Host Bridge `lime.agent` 已接入 `agent_app_runtime_*` facade,并支持 App 内响应 ask / elicitation / tool confirmation。 +7. Claw Capability Catalog 作为已实现 `@` 能力的复用索引,后续供 Chat、Agent App、Automation 共用。 ### compat @@ -103,6 +114,8 @@ Objective / User Input 1. 旧字段映射为 AgentRuntime profile ids。 2. 旧 GUI 状态卡读取新 read model 后继续展示。 3. 旧 evidence/replay 命令通过 `agent_runtime_export_*` 导出。 +4. `agent_app_cmd.rs` 继续负责 package / installed state / UI runtime / scoped model env 注入,但不能扩展为完整 AgentRuntime owner。 +5. 前端 `CapabilityHost` / `WorkflowRuntimeHost` 可暂时作为 mock 或 adapter,但生产 AI 任务必须迁向后端 Agent App Runtime Surface。 退出条件:一旦调用方能直接读取 profile read model 或 evidence pack,就删除 compat 映射。 @@ -114,6 +127,9 @@ Objective / User Input 2. analysis/review 各自重建 observability summary。 3. 子代理、automation job、task center 各自维护完成真相。 4. 只靠文本消息推断 tool success、permission denial 或 completion。 +5. Agent App 直接使用模型 token / OpenAI-compatible API 完成主流程。 +6. Agent App 通过嵌入通用 Chat 让用户手动复制结果。 +7. 为内容工厂等垂直 App 增加专用 Agent Tauri command。 ### dead @@ -123,6 +139,8 @@ Objective / User Input 2. `objective_runtime` 作为第四类 runtime taxonomy。 3. `evidence_summary_builder_v2` 作为 evidence pack 平行导出链。 4. 没有 `session/thread/turn` 关联键的 request telemetry 被当作会话级证据。 +5. 新建第二套 `agent_app_agent_runtime` 执行事实源。 +6. 为 Agent App 复制 Claw `*_skill_launch.rs` 或单独工具权限系统。 ## 5. 先读顺序 @@ -130,10 +148,13 @@ Objective / User Input 2. [./architecture.md](./architecture.md) 3. [./diagrams.md](./diagrams.md) 4. [./adjacent-protocols.md](./adjacent-protocols.md) -5. [./implementation-plan.md](./implementation-plan.md) -6. [./test-cases.md](./test-cases.md) -7. `docs/aiprompts/harness-engine-governance.md` -8. `docs/roadmap/task/README.md` +5. [./app-surface-runtime.md](./app-surface-runtime.md) +6. [./claw-capability-sharing.md](./claw-capability-sharing.md) +7. [./backend-surface-facade-plan.md](./backend-surface-facade-plan.md) +8. [./implementation-plan.md](./implementation-plan.md) +9. [./test-cases.md](./test-cases.md) +10. `docs/aiprompts/harness-engine-governance.md` +11. `docs/roadmap/task/README.md` ## 6. 完成判定 diff --git a/docs/roadmap/agentruntime/app-surface-runtime.md b/docs/roadmap/agentruntime/app-surface-runtime.md new file mode 100644 index 000000000..e1842f1fa --- /dev/null +++ b/docs/roadmap/agentruntime/app-surface-runtime.md @@ -0,0 +1,143 @@ +# Agent App Runtime Surface + +> 状态:in progress +> 更新时间:2026-05-16 +> 作用:定义 Agent App 如何作为 AgentRuntime 的业务 surface 复用 Lime Agent / Claw / Aster 主链,而不是把 App 做成模型 API 壳或 Chat UI 旁路。 + +## 1. 一句话结论 + +Agent App 是业务工作台,AgentRuntime 是智能体运行事实主链,Claw 是一组已落地的能力入口和能力治理经验。内容工厂这类 App 不能只拿模型 API,也不能跳回通用 Chat 完成主流程;它必须通过统一 Agent Runtime Surface 调用 Lime 已有 Agent 能力,并把进度、追问、人工确认、产物和证据回投到 App 页面内。 + +目标架构固定为: + +```text +Content Factory / Agent App + -> @lime/app-sdk + -> Host Bridge / Capability Bridge + -> Agent App Runtime Surface + -> AgentRuntime control plane + -> Aster / lime_agent / Skills / Tools / MCP / Browser / Evidence + -> Agent App task events / artifacts / evidence write-back +``` + +这意味着 Agent App 与 Claw Chat 共享后端 AgentRuntime,而不是共享 Chat UI。 + +## 2. 为什么 API 不够 + +内容工厂的“生成文案和配套素材”“只重写文案”不是单次文本补全,而是长生命周期 Agent 任务。它至少需要: + +1. 检查资料版本、知识绑定、场景和脚本是否满足生产条件。 +2. 缺上下文时在 App 内追问或发起补齐任务。 +3. 调用知识检索、搜索、图片、PDF、总结、报告等现有 Claw 能力。 +4. 持续回传 progress、tool call、citation、partial artifact、blocked、review request。 +5. 支持取消、重试、人工确认、结构化写回。 +6. 将最终文案、素材需求、脚本、报告写入 `lime.storage`、`lime.artifacts` 和 `lime.evidence`。 + +直接注入 `LIME_GATEWAY_*` 或给 App 一个 OpenAI-compatible token 只能提供低阶模型生成;它无法复用 Lime 的工具治理、知识绑定、Evidence Pack、review、runtime queue 和 capability gap。后续它只能作为 degraded fallback 或受控模型 executor,不是完整 Agent 能力边界。 + +## 3. Surface 边界 + +| 层 | current 责任 | 禁止事项 | +| --- | --- | --- | +| Agent App UI | 业务形态、表单、资料版本、场景、文案、素材、交付和复盘 | 直接访问 Tauri / Node / 宿主 DOM,绕过 Host Bridge | +| Agent App Workflow | 从 App 状态组装 task input、expected output、review/write-back 目标 | 自己维护第二套 Agent task runtime | +| Host Bridge | iframe sandbox、主题、语言、导航、capability transport、安全校验 | 承载业务 Agent 逻辑 | +| Agent App Runtime Surface | 把 App task 映射到 AgentRuntime control plane,附加 app provenance | 复制 Claw skill launch 或绕过 `agent_runtime_*` 主链 | +| AgentRuntime | session/thread/turn/task、queue、event、tool、policy、evidence facts | 决定垂直业务 UI 长什么样 | +| Claw Capability Catalog | 把 `@配图`、`@搜索`、`@研报` 等能力变成可复用 capability | 继续把能力绑定死在 Chat/Inputbar 字符串分支 | +| Artifact / Evidence | 产物、引用、工具调用、知识版本、人工确认和验证记录 | 让最终结果只停留在聊天文本 | + +事实源声明: + +```text +Agent App 的完整 AI 能力只允许向 AgentRuntime Surface + AgentRuntime facts 收敛。 +``` + +## 4. current / compat / deprecated / dead + +### current + +1. `agent_runtime_submit_turn -> runtime_turn -> runtime_queue -> stream_reply_once` 仍是现有执行主链。 +2. Agent App Runtime Surface 是新的调用面,不是新的 runtime owner。 +3. `lime.agent` / `lime.workflow` 是 App 面向 SDK 的 capability facade,内部必须委托 AgentRuntime Surface。 +4. App 任务进度、工具、引用、产物和证据必须投影自 runtime events / read model。 + +### compat + +1. `agent_app_cmd.rs` 当前负责 package、installed state、UI runtime 和 scoped model env 注入,可继续作为 App 生命周期兼容层。 +2. `CapabilityHost` / `WorkflowRuntimeHost` 可暂时作为前端 adapter / mock,但真实能力必须迁向后端 AgentRuntime Surface。 +3. `LIME_GATEWAY_*` 可作为低阶模型 executor 或开发期 fallback,但不能被文档或 UI 宣称为完整 Agent 能力。 + +当前进展:`agent_app_runtime_start_task` 等后端 facade 已可用,`AgentAppRuntimePage` 的 Host Bridge 已通过 `AgentRuntimeCapabilityHost` 将 `lime.agent` 的 start / stream / get / cancel / retry / submitHostResponse 接到该 facade;本地 in-memory adapter 只继续承接 storage / artifact / evidence / knowledge 的前端样板与离线测试,不再冒充完整 Agent 任务执行事实源。 + +### deprecated + +1. App 自己调模型 API 完成主流程。 +2. App 通过嵌入通用 Chat 让用户手动复制结果。 +3. 为每个垂直 App 增加专用 Tauri command,例如 `content_factory_generate_copy`。 +4. 前端 `capability + method` 字符串分支继续膨胀成事实源。 + +### dead + +1. 新建第二套 `agent_app_agent_runtime` 执行事实源。 +2. 复制 `*_skill_launch.rs` 给 Agent App 使用。 +3. 让 App 自建模型、工具、凭证、权限、证据系统。 + +## 5. 内容工厂主链 + +内容工厂的“写文案”页面应投影为一个 App-scoped Agent task / workflow: + +```text +App form state + -> lime.agent.startTask / lime.workflow.start + -> Agent App Runtime Surface + -> Aster runtime turn + -> Claw capability / Skill / Tool execution + -> AgentAppTaskStreamEvent + -> App review + -> storage / artifact / evidence write-back +``` + +最小 task input: + +```text +appId = content-factory-app +entryKey = content_factory +taskKind = content_factory.copy.generate +input = { + task, + category, + platform, + audience, + coreWords, + materialVersion, + selectedScenarios, + scriptRefs +} +expectedOutput = { + artifacts: ["copy", "assetBrief", "deliveryPackage"] +} +humanReview = true +``` + +最小事件: + +| 事件 | App 内展示 | +| --- | --- | +| `task:started` | 本轮任务已启动 | +| `task:contextChecked` | 资料、场景、脚本和知识绑定检查结果 | +| `task:missingContextRequested` | 在当前表单或侧栏追问缺口 | +| `task:toolCall` | 搜索、知识检索、图片、报告、PDF 等工具进度 | +| `task:citation` | 资料、场景、网页、文件引用 | +| `task:partialArtifact` | 草稿文案、素材 brief、脚本片段 | +| `task:reviewRequested` | 用户确认、编辑、拒绝或重试 | +| `task:completed` | 写回完成,可导出交付物 | +| `artifact:created` / `evidence:recorded` | 交付物和证据已落库 | + +当前已落地第一刀:`agent_app_runtime_get_task` 会从 `AgentRuntimeThreadReadModel` 投影 `taskStatus` 与 `taskEvents`,先覆盖 queued / progress / missing context / review request / tool call / evidence recorded / evidence verified / completed / cancelled / error / incident。`AgentAppRuntimePage` 已消费这组 snapshot 事件并转成 App 可见的 `AgentAppTaskStreamEvent`;App 也可以通过 `lime.agent.submitHostResponse` 把 ask_user / elicitation / tool_confirmation 响应回 `agent_app_runtime_submit_host_response`。它仍不是最终实时 stream,后续还要补 subscribe / artifact-created / evidence write-back 与跨刷新 task 恢复。 + +## 6. 与 Agent App 文档的关系 + +Agent App 文档负责 App 安装、Host Bridge、Capability SDK、UI runtime、manifest 和业务 App 形态。本文负责说明 `lime.agent` / `lime.workflow` 背后的后端事实源必须是 AgentRuntime Surface。 + +后续任何 Agent App 文档若提到“AI 能力”“Agent task”“workflow task”,必须回链到本文或 [./backend-surface-facade-plan.md](./backend-surface-facade-plan.md),避免把 App SDK 误解成模型 API SDK。 diff --git a/docs/roadmap/agentruntime/architecture.md b/docs/roadmap/agentruntime/architecture.md index 1bc2b3e18..ecbaf5bc3 100644 --- a/docs/roadmap/agentruntime/architecture.md +++ b/docs/roadmap/agentruntime/architecture.md @@ -1,7 +1,7 @@ # Lime AgentRuntime Profile 架构蓝图 > 状态:implementation-audited -> 更新时间:2026-05-12 +> 更新时间:2026-05-16 > 作用:定义 AgentRuntime Profile 在 Lime current 主链中的分层、owner、事实源和接口边界。 ## 1. 架构目标 @@ -18,7 +18,8 @@ | 层 | 角色 | 输入 | 输出 | Owner | | --- | --- | --- | --- | --- | -| Product Input | 收集用户意图与 workspace 状态 | 用户输入、设置、文件上下文 | submit request | GUI / frontend gateway | +| Product Surface | 收集用户意图与业务状态 | Chat 输入、Claw `@` 命令、Agent App 表单、Automation job | surface request | Chat / Claw / Agent App / Automation | +| Runtime Surface Adapter | 把 surface intent 适配为统一 runtime request | taskKind、capabilityId、app provenance、request metadata | AgentRuntime submit request | frontend gateway / Rust surface facade | | Runtime Control Plane | 接收、排队、中断、恢复 | submit/interrupt/resume/respond/export | accepted turn、queued turn、actions | Rust runtime command | | Execution Loop | 执行 agent turn | input snapshot、model config、tool inventory | model/tool/action events | Query Loop / runtime_turn | | Orchestration Facts | 记录 task、routing、permission、sandbox、process、subagent | execution loop 内部状态 | RuntimeEvent stream | AgentRuntime Profile | @@ -51,6 +52,33 @@ runtime control plane 2. analysis/review 重新拼 observability summary。 3. replay 不复用 evidence pack。 4. dashboard 用本地 heuristics 推断 task completion。 +5. Agent App 用模型 API、App 本地 mock 或嵌入通用 Chat 作为完整 Agent 能力边界。 +6. Claw `@` 能力被复制成 Agent App 专用 skill launch。 + +## 3.2 Surface 边界 + +Chat、Claw、Agent App、Automation 都是 AgentRuntime 的调用面,不是新的运行事实 owner: + +```text +Surface input + -> AgentRuntime Surface Adapter + -> Runtime Control Plane + -> RuntimeEvent / ThreadReadModel / TaskSnapshot / EvidencePack + -> Surface-specific projection +``` + +Surface adapter 只允许做: + +1. 把 UI / App / job 输入转换为 `taskKind`、`capabilityId`、`inputRef`、`expectedOutput`、`eventSink`。 +2. 附加 `appId`、`entryKey`、`workflowRunId`、`artifactPolicyRef`、`evidencePolicyRef` 等 provenance。 +3. 选择事件投影目标,例如 conversation stream、AgentAppTaskStreamEvent、automation job event。 + +Surface adapter 不允许做: + +1. 自己执行模型或工具。 +2. 自己判断 task completed / failed / evidence pass。 +3. 复制 `runtime_turn.rs`、`tool_runtime.rs` 或 `*_skill_launch.rs` 的执行准备逻辑。 +4. 让 App 业务组件直接决定 allowed tools / denied tools。 ## 3.1 全球本地化边界 @@ -146,6 +174,10 @@ GUI 与恢复入口。必须表达: | Profile 能力 | 当前或建议 Lime 落点 | | --- | --- | | submit turn | `agent_runtime_submit_turn` | +| surface facade | 后续 `agent_runtime_surface_service`,供 Chat / Claw / Agent App / Automation 共用 | +| Agent App task | 后续 `agent_app_runtime_cmd` + `agent_app_runtime_service`,只作为 App runtime facade | +| Agent App lifecycle | `agent_app_cmd.rs`,仅负责 package、installed state、UI runtime、scoped env 注入 | +| Claw capability catalog | 后续 `agent_runtime_capability_catalog_service`,复用现有 `*_skill_launch.rs` 主链 | | runtime turn | `runtime_turn.rs` / Query Loop 主链 | | model routing | `request_model_resolution.rs`、`docs/roadmap/task/` | | thread read | `AgentRuntimeThreadReadModel` | @@ -157,6 +189,8 @@ GUI 与恢复入口。必须表达: | GUI projection | Workspace / Harness current read model | | command contract | frontend gateway、Rust `generate_handler!`、catalog、mock | +`agent_app_cmd.rs` 当前属于 Agent App 生命周期 owner,不是完整 AgentRuntime owner。`LIME_GATEWAY_*` / `OPENAI_BASE_URL` 注入只能视为低阶模型 executor 或开发期 fallback;完整 `lime.agent` / `lime.workflow` 必须进入 AgentRuntime Surface。 + ## 7. 接口设计原则 ### 7.1 写入边界 @@ -235,3 +269,4 @@ legacy state -> new parallel truth -> UI/evidence 3. 新增 `task_center_status` 与 `TaskSnapshot.status` 不一致。 4. tool/process/action 只写入文本消息,不写 runtime event。 5. rate limit / quota / cost 只进日志,不进 runtime facts。 +6. Agent App 新增平行 runtime facts、工具权限系统或垂直专用 Agent command。 diff --git a/docs/roadmap/agentruntime/backend-surface-facade-plan.md b/docs/roadmap/agentruntime/backend-surface-facade-plan.md new file mode 100644 index 000000000..09dbdfc43 --- /dev/null +++ b/docs/roadmap/agentruntime/backend-surface-facade-plan.md @@ -0,0 +1,194 @@ +# AgentRuntime Surface 后端 Facade 计划 + +> 状态:in progress(P1/P2 最小 facade 已落地,P3 snapshot event projection 与 App 页面消费第一刀已落地,P4 capability hints 第一刀已落地,P5 未完成) +> 更新时间:2026-05-16 +> 作用:定义如何把 Chat / Claw / Agent App / Automation 收敛到同一个后端 AgentRuntime Surface,并记录当前最小实现边界。 + +## 1. 目标 + +当前 `agent_runtime_submit_turn` 已经能进入 Aster / lime_agent 主链,但它的调用形态和事件消费更贴近 Chat / Claw。Agent App 需要完整 Agent 能力时,不应直接调用 Chat 专用命令,也不应新建平行 runtime;应新增后端 facade,把 App task 映射到同一 AgentRuntime control plane。 + +目标结构: + +```text +Chat / Claw command +Agent App runtime command +Automation job command + -> AgentRuntimeSurfaceService + -> RuntimeCommandContext + -> build_queued_turn_task + -> submit_runtime_turn + -> lime_agent::AgentEvent + -> surface-specific projection +``` + +## 2. 建议代码落点 + +| 路径 | 责任 | +| --- | --- | +| `src-tauri/src/commands/agent_app_runtime_cmd.rs` | Agent App runtime Tauri command 外壳,只做参数反序列化和 state 注入。 | +| `src-tauri/src/services/agent_app_runtime_service.rs` | App task facade:校验 appId / entryKey / manifest capability / provenance,组装 surface request。 | +| `src-tauri/src/services/agent_runtime_surface_service.rs` | 多 surface 共享入口:把 Chat、Claw、Agent App、Automation 输入转换成 runtime turn。 | +| `src-tauri/src/services/agent_runtime_capability_catalog_service.rs` | Claw capability catalog:声明 capabilityId、metadata contract、tool surface、artifact/evidence policy。 | + +`agent_app_runtime_cmd.rs` 已作为 P1/P2 最小 facade 落地,并内置首批 capability hint -> Claw skill launch metadata 映射;service 拆分、独立 capability catalog 和 event translator 仍是后续工作。实现时必须同步命令治理四侧:前端 gateway、Rust handler、`agentCommandCatalog`、mock / DevBridge。 + +## 3. 最小命令语义 + +首批 Agent App runtime command: + +| 命令 | 作用 | +| --- | --- | +| `agent_app_runtime_start_task` | 从 App task request 启动一个 AgentRuntime turn / workflow,并返回 `taskId`、`traceId`、`sessionId`、`turnId`。 | +| `agent_app_runtime_cancel_task` | 取消 App task 对应的 runtime turn,必须回写 cancelled / interrupted fact。 | +| `agent_app_runtime_get_task` | 读取 App task snapshot,不从 UI 本地状态反推。 | +| `agent_app_runtime_submit_host_response` | 响应 human review、permission approval、missing context、secret binding 等 Host -> App request。 | + +首批不新增 `content_factory_*` 专用命令。内容工厂业务只通过 taskKind / workflowKind / capabilityId 表达。 + +## 4. Surface Request + +建议统一输入结构: + +```text +AgentRuntimeSurfaceRequest { + surface: chat | claw | agent_app | automation + sourceId + appId? + entryKey? + workflowRunId? + taskKind + idempotencyKey + userInputRef + input + expectedOutput? + requiredCapabilities[] + capabilityHints[] + knowledgeBindings[] + artifactPolicy? + evidencePolicy? + humanReview? + eventSink +} +``` + +`eventSink` 只描述事件投影目标,不改变底层 runtime facts: + +```text +chat -> conversation stream +agent_app -> AgentAppTaskStreamEvent / Host Bridge notification +automation -> job item event +``` + +## 5. Runtime Metadata 投影 + +Agent App task 进入现有 runtime 时,需要形成可审计 metadata,而不是靠 prompt 字符串隐式表达: + +```text +lime_runtime = { + surface: "agent_app", + app_id: "content-factory-app", + entry_key: "content_factory", + workflow_run_id: "...", + app_task_id: "...", + task_kind: "content_factory.copy.generate", + capability_hints: ["lime.capability.research.search", "lime.capability.image.generate"], + artifact_policy_ref: "...", + evidence_policy_ref: "..." +} +``` + +后端可以继续复用现有 `request_metadata` 读取逻辑,但语义 owner 要迁到 capability catalog / surface request。 + +## 6. 事件转译 + +底层事实源: + +```text +lime_agent::AgentEvent +AgentRuntimeProfileEvent +AgentRuntimeThreadReadModel +EvidencePack +``` + +Surface projection: + +| 底层事件 | Chat projection | Agent App projection | Automation projection | +| --- | --- | --- | --- | +| runtime status | 状态卡 / timeline | `task:progress` | job progress | +| model delta | assistant message | 可选 partial text | log / summary | +| tool start/end | tool timeline | `task:toolCall` | job item tool event | +| permission/action required | approval UI | Host -> App request | paused job action | +| artifact changed | artifact card | `artifact:created` | artifact ref | +| evidence changed | evidence panel | `evidence:recorded` | evidence ref | +| turn completed | assistant completion | `task:completed` | job item completed | +| turn failed | error card | `task:error` | job item failed | + +lossless 事件: + +1. started / completed / failed / cancelled +2. permission blocked / approval requested / action resolved +3. artifact created / evidence recorded +4. human review requested +5. missing context requested + +best-effort 事件: + +1. token delta +2. keepalive +3. 高频 progress +4. reasoning delta + +## 7. 后端边界 + +### `agent_app_cmd.rs` + +继续负责: + +1. package inspect / fetch / cache / installed state +2. UI runtime start / stop / status +3. scoped runtime token / model env 注入 + +不继续扩展为完整 AgentRuntime owner。 + +### `agent_app_runtime_cmd.rs` + +负责 Agent App 的运行时任务 facade,但不实现 Agent loop。 + +### `aster_agent_cmd` + +继续承载 current AgentRuntime command 和 Aster 集成,但逐步把 Chat 专用输入外壳与共享 runtime submit 分离。 + +## 8. 实施阶段 + +| 阶段 | 目标 | 退出条件 | +| --- | --- | --- | +| P0 | 文档冻结和 owner 边界确认 | 本文件、App surface、Claw sharing 文档都接入 README | +| P1 | 新增 Agent App runtime command shell | 已完成:四侧命令治理同步,浏览器桥接模式 fail-closed,普通 mock 只服务离线测试 | +| P2 | Surface facade 复用 `submit_runtime_turn` | 已完成最小版:App task 可创建 / 复用 runtime session,并提交真实 runtime turn | +| P3 | Event translator | 已完成第一刀:`agent_app_runtime_get_task` 返回 `taskStatus` 与 `taskEvents`,可从 `AgentRuntimeThreadReadModel` 投影 queued / progress / missing context / host request / tool call / evidence / outcome / incident;`AgentAppRuntimePage` 已通过 `AgentRuntimeCapabilityHost` 把 Host Bridge 的 `lime.agent.startTask / streamTask / getTask / cancelTask / retryTask / submitHostResponse` 接到 `agent_app_runtime_*` facade,不再只走本地 in-memory adapter;未完成:实时 stream / subscribe、artifact-created 专用投影、持久化 task/session 恢复 | +| P4 | Capability catalog 首批能力 | 已完成第一刀:`agent_app_runtime_start_task` 可把 `lime.capability.image.generate` / `cover.generate` / `research.search` / `report.generate` / `pdf.read` / `summary.generate` 等 hint 映射到现有 `*_skill_launch` metadata;未完成:独立 catalog service、manifest capability 校验、多能力 workflow 编排 | +| P5 | Evidence / Artifact write-back | 内容工厂主链完成 review 后写回 storage/artifact/evidence | + +## 9. 验收与守卫 + +实现本计划时必须验证: + +1. `agent_app_runtime_*` 命令同步 `safeInvoke`、Rust handler、DevBridge、catalog、mock。 +2. 新增 App task 不绕过 `agent_runtime_submit_turn` / shared surface service。 +3. 搜索不到 `content_factory_*` 专用后端命令作为 Agent 能力入口。 +4. 首批 capability 的 Chat 和 App 两个入口能指向同一个 capabilityId。 +5. Evidence Pack 可通过 `session/thread/turn/task` 关联到 App provenance。 + +最小检查: + +```bash +npm run test:contracts +npm run governance:legacy-report +``` + +如果内容工厂 GUI 主链变更,再补: + +```bash +npm run verify:gui-smoke +``` diff --git a/docs/roadmap/agentruntime/claw-capability-sharing.md b/docs/roadmap/agentruntime/claw-capability-sharing.md new file mode 100644 index 000000000..9a65fca79 --- /dev/null +++ b/docs/roadmap/agentruntime/claw-capability-sharing.md @@ -0,0 +1,149 @@ +# Claw Capability 共享方案 + +> 状态:draft +> 更新时间:2026-05-16 +> 作用:把 Claw 已经实现的 `@` 能力从 Chat surface 中抽象为 AgentRuntime 可复用 capability,供 Agent App、Automation 和未来 surface 共享。 + +## 1. 背景 + +Lime Claw 已经有大量可用能力:配图、封面、海报、视频、搜索、深搜、研报、竞品、站点搜索、读 PDF、总结、翻译、分析、转写、链接解析、网页、PPT、表单、发布等。这些能力在后端并不是普通 prompt,而是通过 `request_metadata`、skill launch guard、tool permission、runtime contract、artifact/evidence 主链进入 Aster runtime。 + +当前共享困难不是能力缺失,而是入口形态过于贴近 Chat / Claw: + +```text +Inputbar / @命令 + -> capability route / request metadata + -> agent_runtime_submit_turn + -> runtime_turn.rs + -> *_skill_launch.rs / tool_runtime.rs + -> Tauri event_name + -> Chat workspace projection +``` + +Agent App 如果只通过 `capability:invoke` 或模型 API,就无法自然复用这条主链。 + +## 2. 一句话事实源 + +Claw 能力以后只允许向 AgentRuntime capability catalog 收敛;Chat `@命令`、Agent App `lime.agent.startTask`、Automation job 都只是不同 surface adapter。 + +```text +Surface intent + -> Runtime Capability Catalog + -> capability metadata contract + -> AgentRuntime submit + -> existing skill/tool launch + -> runtime events / artifacts / evidence +``` + +## 3. 共享原则 + +1. **复用后端主链,不复制 skill launch。** + `image_skill_launch.rs`、`research_skill_launch.rs`、`report_skill_launch.rs` 等现有实现继续是 current execution 准备逻辑。 + +2. **Chat 入口降级为 surface adapter。** + `@配图`、`@搜索` 等 Chat 命令只负责把用户输入映射成 capability intent。 + +3. **Agent App 不接触 Chat 专用协议。** + App 提交 `taskKind`、业务输入、期望产物和 human review 策略,由 Agent App Runtime Surface 生成 runtime metadata。 + +4. **事件按 surface 投影。** + 底层 `lime_agent::AgentEvent` 是共享事实;Chat 投影为 conversation,App 投影为 task stream,Automation 投影为 job item。 + +5. **Artifact / Evidence 不分家。** + 图片任务、报告、PPT、网页、内容表都必须继续写入统一 Artifact / Evidence 主链。 + +## 4. 首批共享能力映射 + +| Capability | Chat surface | Agent App surface | Current 后端主链 | 关键产物 | +| --- | --- | --- | --- | --- | +| `lime.capability.image.generate` | `Claw @配图` / `@海报` | 内容工厂生成配套素材、封面 brief | `image_skill_launch -> Skill(image_generate)` | media task、image artifact、evidence | +| `lime.capability.cover.generate` | `Claw @封面` | 内容工厂文章封面 | `cover_skill_launch -> Skill(cover_generate)` | cover task file、artifact | +| `lime.capability.video.generate` | `Claw @视频` | 短视频脚本到视频素材 | `video_skill_launch -> Skill(video_generate)` | video task、timeline | +| `lime.capability.research.search` | `Claw @搜索` / `@深搜` | 生产前资料补齐、素材检索 | `research_skill_launch -> Skill(research)` | search timeline、citations | +| `lime.capability.report.generate` | `Claw @研报` / `@竞品` | 内容策略分析、竞品分析 | `report_skill_launch -> Skill(report_generate)` | report artifact、sources | +| `lime.capability.site.search` | `Claw @站点搜索` | 平台规则/竞品站点检索 | `site_search_skill_launch -> Skill(site_search)` | site tool timeline | +| `lime.capability.pdf.read` | `Claw @读PDF` | 资料整理、课程稿解析 | `pdf_read_skill_launch -> Skill(pdf_read)` | file refs、summary evidence | +| `lime.capability.summary.generate` | `Claw @总结` | 知识库整理、复盘摘要 | `summary_skill_launch -> Skill(summary)` | structured summary | +| `lime.capability.webpage.generate` | `Claw @网页` | 交付页、活动页草案 | `webpage_skill_launch -> Skill(webpage_generate)` | HTML artifact | +| `lime.capability.presentation.generate` | `Claw @PPT` | 策略报告 / 交付 PPT | `presentation_skill_launch -> Skill(presentation_generate)` | Markdown/PPT source artifact | + +命名是 roadmap 目标名,后续实现时可根据现有 catalog 命名压缩;但语义必须稳定,不能继续以 UI 文案作为能力 id。 + +## 5. Capability 覆盖矩阵 + +每个可共享能力必须登记以下字段: + +| 字段 | 说明 | +| --- | --- | +| `capabilityId` | 稳定机器 id,例如 `lime.capability.research.search` | +| `owner` | AgentRuntime / Claw capability catalog | +| `chatEntry` | 对应 `@` 命令或 Chat surface adapter | +| `appEntry` | 可供 Agent App 调用的 task kind / workflow kind | +| `metadataContract` | required metadata、默认值、禁止绕行的 guard | +| `allowedTools` | 当前 tool surface / allowlist / denylist | +| `runtimeEvents` | 需要转译给 Chat / App / Automation 的事件族 | +| `artifactPolicy` | 产物类型、写入位置、可导出形式 | +| `evidencePolicy` | 必须记录的 provenance、source、verification | +| `tests` | 后端定向测试、contract test、GUI/App smoke | + +## 6. current / compat / deprecated / dead + +### current + +1. `runtime_turn.rs`、`tool_runtime.rs`、`*_skill_launch.rs` 仍是当前执行准备主链。 +2. `AgentRuntimeProfileEvent` / `AgentRuntimeThreadReadModel` / Evidence Pack 是共享事实输出。 +3. Chat `@` 命令和 Agent App task 都应进入相同 capability catalog。 + +### compat + +1. 前端 Inputbar 现有 capability route 可以继续存在,但只作为 Chat surface adapter。 +2. 旧 `request_metadata` shape 可继续被后端读取,但要收敛到 capability metadata contract。 +3. App 前端 `CapabilityHost` mock 可用于测试,但不能被当作生产执行事实。 + +退出条件:首批共享能力 catalog 落地并有覆盖矩阵后,Chat 和 App 都不得直接拼同一能力的私有 metadata。 + +### deprecated + +1. 在 App 内复制 Chat `@` 文案或 prompt 触发能力。 +2. 让业务组件直接决定 allowed tools / denied tools。 +3. 用最终聊天文本反推 artifact、tool success 或 evidence pass。 + +### dead + +1. 新增 `content_factory_image_generate` 这类垂直专用后端能力复制。 +2. 新增与 `*_skill_launch.rs` 平行的 Agent App skill launch 实现。 +3. 新增只服务 Agent App 的工具权限系统。 + +## 7. 内容工厂首批落地口径 + +内容工厂 P0 至少证明三条共享能力: + +1. **资料补齐**:`content_factory.copy.generate` 缺资料时进入 research / summary / pdf read 能力,而不是让模型凭空补。 +2. **文案生产**:文案生成走 AgentRuntime task,支持 stream、cancel、retry、review 和 structured write-back。 +3. **素材配套**:配套素材复用 image / cover capability,生成 artifact 和 evidence,而不是只产出图片 prompt 文本。 + +验收时看同一底层 runtime facts: + +```text +taskKind / capabilityId + -> runtime events + -> tool timeline + -> artifact refs + -> evidence refs + -> App task projection +``` + +## 8. 已落地第一刀 + +`agent_app_runtime_start_task` 已经能读取 `requiredCapabilities` / `capabilityHints` 中的首个受支持 capability,并写入现有 Claw `*_skill_launch` metadata: + +| Capability hint | 写入 metadata | 首刀 Skill | +| --- | --- | --- | +| `lime.capability.image.generate` / `image_generation` | `harness.image_skill_launch.image_task` | `image_generate` | +| `lime.capability.cover.generate` / `cover_generation` | `harness.cover_skill_launch.cover_task` | `cover_generate` | +| `lime.capability.research.search` / `web_search` | `harness.research_skill_launch.research_request` | `research` | +| `lime.capability.report.generate` | `harness.report_skill_launch.report_request` | `report_generate` | +| `lime.capability.pdf.read` / `pdf_extract` | `harness.pdf_read_skill_launch.pdf_read_request` | `pdf_read` | +| `lime.capability.summary.generate` | `harness.summary_skill_launch.summary_request` | `summary` | + +当前仍是第一刀:一个 App task 只会锁定首个可识别 capability,适合证明“App 不复制 Claw、而是进入同一 skill launch 主链”。多能力 workflow、manifest capability gate、事件转译和 artifact/evidence write-back 仍需后续阶段补齐。 diff --git a/docs/roadmap/zuanjia/README.md b/docs/roadmap/zuanjia/README.md new file mode 100644 index 000000000..1fd6b5735 --- /dev/null +++ b/docs/roadmap/zuanjia/README.md @@ -0,0 +1,83 @@ +# 专家功能路线图 + +更新时间:2026-05-15 + +## 定位 + +本目录承载 Lime Desktop 客户端侧的专家功能路线图。 + +专家功能的目标是把“可复用的专业 Agent 能力”做成用户能发现、理解、添加和持续使用的产品入口:专家广场负责发现,专家详情负责决策,添加专家负责建立本地使用关系,专家对话负责执行任务,右侧专家信息面板负责解释专家的人设、记忆、技能和工作流。 + +```text +LimeCore / Lime Cloud + Expert Catalog / Release / Tenant Enablement / Ranking / Audit + ↓ +Lime Desktop Client + Expert Plaza / Detail / Install Overlay / Local Cache / Agent Runtime Binding + ↓ +Agent Runtime Current Chain + Prompt Foundation / Memory / Skill Catalog / Workflow / Evidence +``` + +## 当前事实源 + +| 分类 | 对象 | 说明 | +|---|---|---| +| current | `docs/roadmap/zuanjia/prd.md` | 专家广场、详情、添加和对话体验的产品方案。 | +| current | `docs/roadmap/zuanjia/architecture.md` | 客户端投影、缓存、安装 overlay、运行时绑定和失败模式。 | +| current | `docs/roadmap/zuanjia/implementation-plan.md` | P0-P5 客户端实施顺序、测试和验收。 | +| current | `/Users/coso/Documents/dev/ai/limecloud/limecore/docs/roadmap/zuanjia` | LimeCore 专家云市场控制面方案。 | +| reference | `docs/aiprompts/skill-standard.md` | 专家绑定技能时必须复用的 Skill / ServiceSkill 标准。 | +| reference | `docs/aiprompts/prompt-foundation.md` | 专家 persona 注入 Agent Runtime 的 Prompt 主链。 | +| reference | `docs/aiprompts/memory-compaction.md` | 专家记忆和上下文压缩边界。 | +| reference | `docs/roadmap/agentapp/README.md` | Agent App 与 `expert-chat` entry 的平台边界。 | + +## 文档索引 + +| 文档 | 说明 | +|---|---| +| [prd.md](./prd.md) | 产品目标、用户故事、页面结构、MVP 和非目标。 | +| [architecture.md](./architecture.md) | 客户端数据模型、运行时绑定、云同步、离线和 readiness 设计。 | +| [implementation-plan.md](./implementation-plan.md) | 分期实施、文件边界、验收命令和风险控制。 | + +## 客户端边界 + +1. 专家不是第二套聊天系统,专家对话必须继续进入 `agent_runtime_submit_turn` 主链。 +2. 专家不是独立技能格式,`skillRefs` 必须引用 `SkillCatalog` / `ServiceSkillCatalog` 中的 current 对象。 +3. 专家不是完整 Agent App 平台替代品;如果某个专家需要独立 UI、storage、worker 或 workflow runtime,应升级为 Agent App。 +4. 专家目录来自 LimeCore;客户端 seeded catalog 只做离线兜底和开发 fixture。 +5. 用户私有记忆、对话内容、workspace 数据不进入公共专家目录。 +6. 新增用户可见文案必须覆盖 `zh-CN / zh-TW / en-US / ja-JP / ko-KR`。 +7. GUI 改动必须按 Lime 桌面产品视觉语言落地,并做最小 GUI 冒烟。 + +## 主路线 + +```mermaid +flowchart TD + Cloud[Expert Catalog / Bootstrap] --> Cache[Client Expert Cache] + Cache --> Plaza[专家广场] + Plaza --> Detail[专家详情] + Detail --> Install[添加专家 / Install Overlay] + Install --> Chat[专家对话] + Chat --> Runtime[Agent Runtime] + Runtime --> Prompt[Persona / Prompt Foundation] + Runtime --> Memory[Memory Template / User Memory] + Runtime --> Skills[Skill Catalog / Tool Selection] + Runtime --> Workflow[Workflow Runtime] + Runtime --> Evidence[Timeline / Artifact / Evidence] +``` + +## 当前执行顺序 + +```text +P0 文档与本地 fixture +→ P1 本地专家广场 MVP +→ P2 专家对话闭环 +→ P3 技能 / 记忆 / 工作流绑定 +→ P4 LimeCore 云目录同步 +→ P5 榜单与运营质量闭环 +``` + +## 下一刀判定 + +先按 [implementation-plan.md](./implementation-plan.md) 做 P0:补本地 `ExpertProfile` fixture、parser / projection 单测和专家广场静态页面,不直接接正式云 API,也不新增第二套 runtime。 diff --git a/docs/roadmap/zuanjia/architecture.md b/docs/roadmap/zuanjia/architecture.md new file mode 100644 index 000000000..ccd5069ee --- /dev/null +++ b/docs/roadmap/zuanjia/architecture.md @@ -0,0 +1,136 @@ +# 专家功能客户端架构 + +更新时间:2026-05-15 + +## 一句话结论 + +专家功能在客户端只新增“专家产品投影”和“安装 overlay”,执行仍回到现有 Agent Runtime、Prompt Foundation、Memory、Skill Catalog 和 Workflow 主链。 + +## 架构边界 + +| 层 | current 职责 | 不做 | +|---|---|---| +| LimeCore | 下发专家目录、release、租户可见性、榜单和策略。 | 不运行默认 Agent,不保存客户私有对话。 | +| Lime Desktop | 渲染专家广场、缓存目录、保存添加状态、组装运行时上下文。 | 不维护第二份云目录事实源。 | +| Agent Runtime | 执行专家会话、工具调用、Artifact、Evidence。 | 不关心市场排序和运营榜单。 | +| Skill / ServiceSkill | 提供专家可绑定的能力引用。 | 不被复制到专家对象内部。 | +| Memory | 提供专家记忆模板和用户侧记忆。 | 公共目录不保存用户私有记忆全文。 | + +## 数据流 + +```mermaid +sequenceDiagram + participant Cloud as LimeCore expertCatalog + participant Cache as ExpertCatalogCache + participant UI as Expert Plaza UI + participant Overlay as ExpertInstallOverlay + participant Chat as Agent Chat + participant Runtime as Agent Runtime + + Cloud-->>Cache: catalog / releases / rankings / tenant enablement + Cache-->>UI: ExpertProfile projection + UI->>Overlay: add expert + Overlay-->>Chat: installed expert entry + Chat->>Runtime: submit turn with expert context refs + Runtime->>Runtime: merge persona / memory / skills / workflow + Runtime-->>Chat: timeline / artifact / evidence +``` + +## 客户端模块建议 + +| 模块 | 责任 | +|---|---| +| `expertCatalog` | 解析云端 / seeded `ExpertProfile`,做 schema guard、版本和 fallback。 | +| `expertProjection` | 合并目录、ranking、tenant enablement、本地 overlay,生成 UI view model。 | +| `expertInstallOverlay` | 保存已添加、置顶、最近使用、自定义 starter、隐藏状态。 | +| `ExpertPlazaPage` | 专家广场页面、榜单、分类、搜索和卡片网格。 | +| `ExpertDetailDialog` | 专家详情大浮层。 | +| `expertRuntimeBinding` | 把专家引用转换为 Agent Runtime 可消费的 prompt/context/tool selection 输入。 | +| `ExpertInfoPanel` | 专家对话右侧信息栏。 | + +具体文件名以后续代码结构为准;首期不应把全部逻辑塞进 `AppSidebar` 或 `AgentChatWorkspace`。 + +## 本地状态 + +### ExpertCatalogCache + +缓存云端目录和 seeded fallback 的归一化结果: + +```text +version +syncedAt +tenantId +items[] +rankings[] +releases[] +``` + +缓存只用于展示和启动,不是用户真实使用状态。 + +### ExpertInstallOverlay + +用户侧 overlay 独立保存: + +```text +expertId +releaseId +installedAt +lastUsedAt +pinned +hidden +customTitle? +customPromptStarters? +memoryEnabled +workflowEnabled +``` + +overlay 必须可迁移、可清理,且不能写回公共目录对象。 + +## 运行时绑定 + +专家启动会话时生成一个 `ExpertRuntimeContext`,再并入现有 turn input: + +| 引用 | 注入位置 | +|---|---| +| `personaRef` | Prompt Foundation / runtime agents system prompt。 | +| `memoryTemplateRef` | Memory source resolver 和 thread memory prefetch。 | +| `skillRefs` | Skill selection / tool catalog 裁剪。 | +| `workflowRefs` | Workflow runtime launcher 或 starter chips。 | +| `promptStarters` | 聊天首屏建议任务。 | +| `readiness` | 启动前依赖检查和错误提示。 | + +不允许把 persona 文本散落在 UI 组件里;UI 只消费展示摘要,运行时消费版本化引用。 + +## 离线与失败模式 + +| 场景 | 行为 | +|---|---| +| 云目录请求失败 | 使用上次成功缓存;没有缓存时使用 seeded fallback。 | +| 专家 release 被下架 | 已安装专家保留本地入口,但显示“云端已下架 / 可继续本地使用”提示;不自动删除用户 overlay。 | +| 绑定 skill 缺失 | 专家卡片和详情显示 readiness 缺口,启动时提示安装或禁用对应技能。 | +| persona hash 不匹配 | 阻断启动,提示刷新目录或回落到上一可用 release。 | +| workflow 不可用 | 专家仍可聊天,但工作流区域显示不可用原因。 | +| 用户删除专家 | 只删除 install overlay,不删除公共缓存和历史会话。 | + +## UI 设计约束 + +- 专家广场是卡片型列表工作台,宽度应按桌面容器自适应,不锁死窄列。 +- 榜单区要轻,不做大面积营销 hero。 +- 详情浮层主体使用实体底色、清晰边框和浅阴影,不使用半透明主表面。 +- 右侧信息栏信息密度可以高,但必须分组,避免连续套三层卡片。 +- 所有新文案必须进入 i18n 资源,不写死中文。 + +## Telemetry 与隐私 + +首期只允许聚合运营事件: + +```text +expert_impression +expert_detail_opened +expert_installed +expert_chat_started +expert_liked +expert_shared +``` + +事件只携带 `tenantId`、`expertId`、`releaseId`、页面来源、时间和匿名会话引用;不得携带用户 prompt、assistant response、文件内容或私有记忆全文。 diff --git a/docs/roadmap/zuanjia/implementation-plan.md b/docs/roadmap/zuanjia/implementation-plan.md new file mode 100644 index 000000000..1299598da --- /dev/null +++ b/docs/roadmap/zuanjia/implementation-plan.md @@ -0,0 +1,200 @@ +# 专家功能客户端实施计划 + +更新时间:2026-05-15 + +## 一句话结论 + +先用本地 fixture 跑通“专家广场 → 详情 → 添加 → 专家对话”闭环,再接 LimeCore 云目录;不要先做运营后台、付费市场或第二套 Agent Runtime。 + +## 当前进度 + +2026-05-15:P0-P4 已落第一版。已新增 `src/features/experts/**`、专家广场入口、详情/添加 overlay、专家对话运行时 metadata 绑定、LimeCore `client/experts` 云目录同步、五语言文案和定向回归。下一刀进入 GUI smoke / 交互续测与 P5 运营事件、榜单质量闭环。 + +2026-05-15 收口验证:通用 `npm run verify:gui-smoke -- --reuse-running` 已通过;补充本地 Playwright 真实交互验证“专家广场 → 搜索营销策略专家 → 打开详情 → 添加专家 → 开始对话”,捕获到 `agent_runtime_submit_turn`,且 `turn_config.metadata.expert.expertId` 与 `metadata.harness.expert.expert_id` 均为 `marketing-strategist`,控制台 error 为 0。已验证海洋浅色 / 霓虹深色主题下主按钮跟随 Lime 主题且不是黑色,`390x780` 视口可纵向滚动且无横向溢出。P0-P4 current 主链达到可交付门槛;后续只剩 P5 运营事件、榜单质量与对话内固定专家信息面板增强。 + +2026-05-15 右侧专家信息面板补齐:已在 Agent 对话工作台 `WorkspaceShellScene` 增加可选右侧 rail,并用 `ExpertInfoPanel` 消费 `initialRequestMetadata / initialAutoSendRequestMetadata` 中的 `expert` / `harness.expert`。专家入口启动后右侧固定显示“专家信息、简介、记忆、日记、技能、工作流程”,内容来自云目录缓存或 seeded catalog 投影;面板只读展示,不创建第二套 runtime,也不把私有对话写回公共专家目录。 + +2026-05-15 面板验证:新增 `WorkspaceShellScene` right rail 回归与 `AgentChatPage` 专家首发回归,确认尚无会话时也能自动创建发送计划、保留 `skipSessionRestore` 和专家 metadata,并渲染 `expert-info-panel / memory / skills / workflow`。本地 Playwright 真实交互已复走“专家 → 搜索营销 → 详情 → 添加 → 开始对话 → 右侧专家信息面板”,同时验证 `390x780` 专家广场可滚动且无横向溢出、专家开始按钮不为黑色、`agent_runtime_submit_turn` metadata 仍为 `marketing-strategist`。证据写入 `.lime/e2e/expert-panel/expert-panel-e2e-summary.json`。 + +2026-05-15 技能添加补齐:右侧 `ExpertInfoPanel` 的“技能”区已从静态标签升级为可添加入口。点击 `+` 会打开实体底色技能选择弹窗,候选合并当前本地 Lime Skills、服务技能目录和 seeded Skill Catalog;添加后即时出现在当前专家面板,并把合并后的 `skillRefs` 写入后续 workspace request metadata 的 `expert.skillRefs` 与 `harness.expert.skill_refs`,不新增第二套 runtime 或 Tauri command。 + +2026-05-15 P5 运营事件补齐:客户端新增 `expertAnalytics`,专家广场会在云目录专家上记录曝光、详情打开、添加和开始对话事件,批量上报到 LimeCore `POST /client/experts/events`;无会话但已有 OEM 云端上下文时进入本地队列,网络恢复后 `flushExpertCatalogEvents` 可补发。事件仅包含 expert / release / surface / catalogVersion / locale / metadata 等运营字段,主动过滤 `prompt / message / response / fileContent / memory / conversation`,不把用户内容、助手回复、文件或私有记忆写入统计。 + +2026-05-15 专家 Agent 身份层补齐:客户端新增 `ExpertAgentInstance` 本地事实源与 LimeCore 同步入口,同一 `tenantId + expertId + releaseId` 默认恢复最近 `latestSessionId`,只有详情里的“新对话”显式创建新会话。`ExpertPlazaPage` 会从云端拉取 active 实例并合并本地 fallback;`AgentChatWorkspace` 在 sessionId 出现和右侧技能变更时回写实例,确保重复点击专家不再自动发送首条 prompt,也不再重复创建 chat。 + +2026-05-15 详情弹窗视觉收口:专家详情侧栏按钮从纵向 flex 膨胀改为固定 48px 高的主题按钮组,主按钮使用 Lime 青绿渐变,次按钮使用实体白底描边;详情弹窗宽度收为 `980px` 级工作台浮层,右侧背景回到低饱和 Lime 主题色,禁止黑色按钮并保持多主题变量。 + +2026-05-15 测试限制:Playwright MCP 工具当前返回 `Transport closed`,无法用 MCP 通道复走同一 GUI 流程;已用本地 Playwright + 真实 DevBridge 补等价交互验证,并在 request 侧捕获 `agent_runtime_submit_turn` metadata。MCP 服务恢复后需优先复走同一路径作为最终 MCP 证据。 + +## P0:文档与本地 fixture + +目标:固定对象模型和本地样本,避免后续 UI 与云契约漂移。 + +交付: + +- 新增专家本地 fixture,覆盖推荐、热门、上新、分类和详情样例。 +- 新增 `ExpertProfile` parser / projection 单测。 +- 明确 `personaRef`、`memoryTemplateRef`、`skillRefs`、`workflowRefs` 的引用格式。 +- seeded fallback 至少包含 6 个专家:营销策略、资料整理、代码文学、短视频脚本、法务合同、数据分析。 + +验收: + +- parser 能拒绝缺少 `id/title/personaRef/releaseId` 的无效专家。 +- projection 能合并 ranking、category、stats 和 install overlay。 +- 文档与 fixture 中不出现云端执行专家对话的描述。 + +## P1:本地专家广场 MVP + +目标:用户能在桌面 GUI 中浏览和筛选专家。 + +交付: + +- 新增专家广场入口。 +- 渲染三块榜单、分类筛选、搜索框、专家卡片网格。 +- 卡片支持未添加 / 已添加 / 依赖缺失状态。 +- 补组件测试覆盖分类、搜索、榜单和空态。 + +设计约束: + +- 页面使用卡片型列表工作台布局。 +- 不引入高饱和营销背景。 +- 搜索和分类是独立筛选区,不挤在标题行。 + +## P2:详情与添加闭环 + +目标:用户能理解专家能力并添加到本地。 + +交付: + +- 新增专家详情大浮层。 +- 展示简介、showcase、技能标签、工作流摘要、使用量、点赞、添加和分享入口。 +- 实现 `ExpertInstallOverlay` 本地存储。 +- 添加后更新广场状态和会话侧边栏入口。 + +验收: + +- 删除 overlay 后,专家回到未添加状态。 +- 云目录对象不因用户添加而被修改。 +- 已添加专家能保留最近使用时间。 + +## P3:专家对话运行时绑定 + +目标:添加专家后能启动真实 Agent Runtime 会话。 + +交付: + +- 新增 `expertRuntimeBinding`,把专家引用转成现有 turn context 输入。 +- 会话首屏显示专家欢迎语和 starter prompts。 +- 右侧专家信息面板展示简介、记忆、日记、技能、工作流程。 +- `skillRefs` 接入现有 Skill Catalog 解析;缺失时显示 readiness。 +- `memoryTemplateRef` 接入现有 memory source / compaction 边界。 + +验收: + +- 专家会话仍调用 `agent_runtime_submit_turn`。 +- 同一专家可创建多个普通会话,历史消息不写回公共专家目录。 +- 禁用专家记忆后,首轮 prompt 不包含该专家 memory template。 + +## P4:LimeCore 云目录同步 + +目标:用云端目录替换本地 fixture 作为 current 事实源,保留 seeded fallback。 + +交付: + +- 接入 `bootstrap.expertCatalog` 或 `client/experts`。 +- 实现目录缓存、版本号、release hash、刷新和错误回退。 +- 支持租户可见性、下架、灰度和默认推荐。 +- 对目录请求失败、hash 不一致、release 下架补 UI 状态。 + +验收: + +- 离线时可打开上次缓存专家广场。 +- 云端禁用某专家后,新用户不可见,已安装用户看到下架提示。 +- 客户端不依赖云端执行专家任务。 + +## P5:榜单与运营质量闭环 + +目标:专家广场具备可运营的发现和质量信号。 + +交付: + +- 展示推荐榜、热门榜、最近上新和分类榜单。 +- 上传聚合事件:曝光、详情打开、添加、会话启动、点赞、分享。 +- 支持榜单快照刷新和本地缓存。 +- 增加专家详情质量字段:适用场景、边界、依赖、版本说明。 + +验收: + +- 统计事件不携带用户 prompt、回复、文件内容和私有记忆。 +- 榜单排序缺失时仍可按 category / updatedAt 降级展示。 + +## 文件边界 + +优先新增独立模块,不向大文件堆逻辑: + +```text +src/features/experts/** +src/components/experts/** +src/i18n/resources/*/agentExperts.json 或现有 agent namespace +``` + +如果新增 Tauri command,必须同步: + +```text +safeInvoke(...) / invoke(...) +tauri::generate_handler! +agentCommandCatalog +mockPriorityCommands / defaultMocks +``` + +## 验收命令 + +按改动面选择,不无限追加: + +```bash +npm run test:contracts +npm run verify:gui-smoke +npm run verify:local +``` + +GUI 主路径完成后,补 Playwright 续测: + +```text +打开专家广场 → 搜索专家 → 打开详情 → 添加专家 → 进入对话 → 验证右侧专家信息栏 +``` + +已执行验证: + +```text +npm test -- src/features/experts src/components/experts/ExpertPlazaPage.test.tsx src/components/AppPageContent.test.tsx src/lib/navigation/sidebarNav.test.ts src/components/AppSidebar.test.tsx src/i18n/__tests__/types.test.ts +npm test -- src/components/experts/ExpertPlazaPage.test.tsx +npm test -- src/components/agent/chat/experts/ExpertInfoPanel.test.tsx +npm test -- src/components/agent/chat/index.test.tsx -t 自动首条专家入口 +npm test -- src/components/agent/chat/index.test.tsx +npm test -- src/components/agent/chat/workspace/WorkspaceShellScene.test.tsx +npm test -- src/i18n/__tests__/types.test.ts +npm test -- src/features/experts/expertAnalytics.test.ts src/components/experts/ExpertPlazaPage.test.tsx +npm test -- src/features/experts src/components/experts/ExpertPlazaPage.test.tsx src/components/AppPageContent.test.tsx src/components/agent/chat/experts/ExpertInfoPanel.test.tsx +npm run typecheck +npm run verify:gui-smoke +npx prettier --check src/components/agent/chat/experts/ExpertInfoPanel.tsx src/components/agent/chat/experts/ExpertInfoPanel.test.tsx src/components/agent/chat/AgentChatWorkspace.tsx src/i18n/resources/*/agentExperts.json +npm run verify:gui-smoke -- --reuse-running +本地 Playwright 交互脚本:主题切换海洋浅色 / 霓虹深色 → 专家广场主按钮非黑色 → 390x780 滚动 / 无横向溢出 +本地 Playwright 交互脚本:专家广场 → 搜索 → 详情 → 添加 overlay → 开始对话 → 捕获 agent_runtime_submit_turn 专家 metadata +本地 Playwright 交互脚本:专家广场 → 搜索营销策略专家 → 详情 → 添加 → 开始对话 → 验证右侧专家信息栏、记忆、日记、技能、工作流程 +本地 Playwright 交互脚本:专家广场 → 搜索营销策略专家 → 开始对话 → 右侧技能 + → 添加服务技能 → 下一轮发送 metadata 含 expert.skillRefs / harness.expert.skill_refs,证据 `.lime/e2e/expert-panel/expert-skill-add-e2e-summary.json` +本地 Playwright 交互脚本:预置真实 Agent session → 专家广场继续同一专家 → 验证未触发 `agent_runtime_create_session / agent_runtime_submit_turn` → 右侧技能添加写回 `skillRefsOverride` → 再次点击同一专家仍恢复同一 session → 详情按钮 48px 且非黑色,截图 `test-results/expert-agent-flow.png` +Playwright MCP:当前 Transport closed,待 MCP 恢复后复走 +``` + +当前阻塞:Playwright MCP 服务仍返回 `Transport closed`;已用本地 Playwright + 真实 DevBridge 完成等价 GUI 主路径验证。 + +## 风险控制 + +| 风险 | 控制 | +| ---------------------------- | ------------------------------------------ | +| 专家变成第二套 Agent Runtime | 启动会话只走 `agent_runtime_submit_turn`。 | +| 专家复制 Skill 定义 | `skillRefs` 只引用 Skill Catalog。 | +| 市场页先行污染主路径 | P0-P3 先本地闭环,P4 再接云目录。 | +| 私有数据进入公共目录 | 公共目录只存 metadata 和 refs。 | +| UI 看起来像网页市场 | 按 Lime 卡片型工作台和桌面设计语言收口。 | diff --git a/docs/roadmap/zuanjia/prd.md b/docs/roadmap/zuanjia/prd.md new file mode 100644 index 000000000..dba8df820 --- /dev/null +++ b/docs/roadmap/zuanjia/prd.md @@ -0,0 +1,124 @@ +# 专家功能 PRD + +更新时间:2026-05-15 + +## 一句话目标 + +在 Lime 中提供一个“专家广场”,让用户像选择专业顾问一样选择 Agent:先通过榜单、分类和搜索发现专家,再查看专家详情,添加后进入带专家身份、记忆、技能和工作流的对话。 + +## 背景 + +当前 Lime 已有 Agent Chat、Skill Catalog、ServiceSkill、Memory、Workflow 和 Agent App 平台能力,但用户仍需要理解底层能力名、工具名或命令入口。专家功能把这些能力重新包装成面向任务和角色的入口,让用户先回答“我需要哪位专家帮我”,再由系统完成 prompt、技能和工作流绑定。 + +截图参考的关键体验包括: + +- 专家广场:顶部推荐榜、热门榜、上新榜,下面是分类筛选、搜索和专家卡片网格。 +- 专家详情:打开后展示专家产出示例、可执行任务、技能标签、使用量、点赞、添加和分享。 +- 专家对话:添加后进入普通聊天页,但右侧显示专家信息、简介、记忆、日记、技能和工作流程。 + +## 目标用户 + +| 用户 | 需求 | +|---|---| +| 普通创作者 | 不想配置工具和 prompt,希望直接选择“短视频脚本专家”“资料整理专家”。 | +| 运营 / 增长人员 | 需要固定专家长期跟进趋势、竞品、投放和复盘。 | +| 团队负责人 | 希望给团队预置可信专家,降低新人使用门槛。 | +| 高阶用户 | 需要知道专家背后的技能、记忆和工作流,便于调试和定制。 | + +## 产品形态 + +### 1. 专家广场 + +页面类型是卡片型列表工作台。结构为: + +1. 页面标题:`专家广场`。 +2. 榜单区:`为你推荐`、`热门精选`、`最近上新` 三块轻量榜单。 +3. 筛选区:分类 tabs + 搜索框。 +4. 专家卡片网格:头像、标题、摘要、来源、使用量、点赞、添加状态。 + +默认分类: + +```text +全部 / 营销部 / 办公协同部 / 生活服务部 / 金融部 / 娱乐社交部 / 学术教育部 / 工程部 / 法务部 / 销售部 / 供应链部 / 产品部 / 人力资源部 +``` + +卡片点击进入详情浮层;卡片上的主按钮只负责添加或开始对话,避免“选中但无结果”的假交互。 + +### 2. 专家详情 + +详情以大浮层承载,不跳出当前广场上下文。详情内容分左右两区: + +- 主内容:专家名称、示例任务、产出样例、适用场景、工作流说明。 +- 侧栏:头像、标题、使用量、点赞、添加、分享、简介、技能标签。 + +首期详情中的产出样例可以来自专家 release metadata 的 `showcase` 字段;后续再接真实精选案例。 + +### 3. 添加专家 + +添加专家写入客户端 install overlay,不修改云端目录本体。 + +添加后: + +- 广场卡片显示已添加或可直接开始对话。 +- 对话侧边栏出现该专家。 +- 新建专家会话时自动带入 persona、starter prompts、memory template 和 skill/workflow refs。 + +### 4. 专家对话 + +专家对话仍是现有 Agent Chat 的一种入口,不新增并行聊天页面。 + +首屏显示: + +- 专家头像和欢迎语。 +- 1-3 个 starter prompts。 +- 输入框继续使用现有 Agent input bar。 + +右侧专家信息面板显示: + +- 专家信息:头像、标题、摘要、编辑入口。 +- 简介:Who I Am、How I Talk、Boundaries。 +- 记忆:当前项目关注、经验积累。 +- 日记:首期可为空态。 +- 技能:绑定的 Skill / ServiceSkill 标签。 +- 工作流程:趋势分析、内容策划、挑战赛设计、数据优化等步骤。 + +## ExpertProfile 投影字段 + +`ExpertProfile` 是客户端消费的专家产品投影,不是运行时执行格式。 + +| 字段 | 说明 | +|---|---| +| `id` | 稳定专家 ID。 | +| `slug` | URL / deep link 友好的唯一短名。 | +| `title` | 专家名称。 | +| `summary` | 卡片摘要。 | +| `avatar` | 头像 URL 或内置 token。 | +| `category` | 分类 key。 | +| `tags` | 能力标签。 | +| `source` | `cloud_catalog` / `seeded_fallback` / `local_custom`。 | +| `stats` | 使用量、点赞、热度、上新时间。 | +| `ranking` | 推荐、热门、上新或分类榜单位置。 | +| `personaRef` | 版本化 persona 引用。 | +| `memoryTemplateRef` | 可选记忆模板引用。 | +| `skillRefs` | 绑定技能引用。 | +| `workflowRefs` | 绑定工作流引用。 | +| `promptStarters` | 首屏建议任务。 | +| `showcase` | 详情页展示的样例产出。 | +| `readiness` | 模型、技能、浏览器、项目等依赖。 | + +## 非目标 + +1. 不在首版做付费分账、第三方创作者后台或公开插件市场。 +2. 不允许 LimeCore 默认执行专家对话。 +3. 不把专家实现成第二套 Skill / Scene / Agent runtime。 +4. 不把用户私有对话、私有记忆或 workspace 文件上传到公共专家目录。 +5. 不先做复杂审核后台;首期只规划 release metadata 和租户可见性。 + +## MVP 验收 + +- 用户能在专家广场查看三块榜单、分类、搜索和专家卡片。 +- 用户能打开专家详情,看到简介、样例、技能标签和添加按钮。 +- 用户能添加专家,并从专家启动一个普通 Agent Runtime 会话。 +- 会话首轮能带入专家 persona 和 starter prompt。 +- 右侧专家信息面板能展示简介、记忆、技能和工作流程。 +- 离线时可展示上次缓存目录或 seeded fallback。 diff --git a/package-lock.json b/package-lock.json index cb56a774a..1c200575e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.39.0", + "version": "1.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.39.0", + "version": "1.40.0", "license": "GPL-3.0-only", "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/package.json b/package.json index dff341862..935d71727 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.39.0", + "version": "1.40.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", @@ -130,7 +130,9 @@ "agent-qc:gui-owner-check": "node scripts/agent-qc-gui-owner-check.mjs", "agent-qc:process-owner-check": "node scripts/agent-qc-process-owner-check.mjs", "agent-qc:secret-scan": "node scripts/agent-qc-secret-scan.mjs", - "agent-qc:objective-checklist": "node scripts/agent-qc-objective-checklist.mjs" + "agent-qc:objective-checklist": "node scripts/agent-qc-objective-checklist.mjs", + "smoke:agent-apps": "node scripts/agent-apps-smoke.mjs", + "smoke:agent-app-lab": "node scripts/agent-app-lab-smoke.mjs" }, "dependencies": { "@babel/standalone": "^7.29.0", diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 89b1002a8..454bcb0fd 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.39.0", + "version": "1.40.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/agent-app-lab-smoke.mjs b/scripts/agent-app-lab-smoke.mjs new file mode 100644 index 000000000..a0c17332d --- /dev/null +++ b/scripts/agent-app-lab-smoke.mjs @@ -0,0 +1,441 @@ +#!/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", + timeoutMs: 120_000, + intervalMs: 1_000, + evidenceDir: path.join(process.cwd(), ".lime", "qc", "gui-evidence", "agent-app-lab"), + prefix: "agent-app-lab-smoke", +}; + +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 === "--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 === "--evidence-dir" && argv[index + 1]) { + options.evidenceDir = path.resolve(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--prefix" && argv[index + 1]) { + options.prefix = String(argv[index + 1]).trim(); + index += 1; + } + } + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function logStage(stage) { + console.log(`[smoke:agent-app-lab] stage=${stage}`); +} + +async function launchSmokeContext(userDataDir) { + try { + return await chromium.launchPersistentContext(userDataDir, { + channel: "chrome", + headless: true, + }); + } catch (error) { + console.warn( + `[smoke:agent-app-lab] Chrome channel 启动失败,尝试 Playwright Chromium: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return chromium.launchPersistentContext(userDataDir, { + headless: true, + }); + } +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log( + `[smoke:agent-app-lab] DevBridge 已就绪 (${Date.now() - startedAt}ms) status=${payload?.status ?? "unknown"}`, + ); + return payload; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + throw new Error( + `[smoke:agent-app-lab] DevBridge 未就绪: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + +async function runFlagOffRegression(options) { + logStage("flag-off-regression"); + const userDataDir = fs.mkdtempSync( + path.join(os.tmpdir(), "lime-agent-app-lab-flag-off-"), + ); + const context = await launchSmokeContext(userDataDir); + const consoleErrors = []; + const failedRequests = []; + + await context.addInitScript(() => { + window.localStorage.removeItem("lime.agentAppHost.flags"); + window.localStorage.removeItem("lime.agentAppHost.labEnabled"); + }); + + const page = await context.newPage(); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + page.on("requestfailed", (request) => { + failedRequests.push({ + url: request.url(), + method: request.method(), + failure: request.failure()?.errorText ?? "unknown", + }); + }); + + try { + await page.goto(options.appUrl, { + waitUntil: "domcontentloaded", + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="app-sidebar-main-nav"]', { + timeout: options.timeoutMs, + }); + + const assertions = { + labNavHidden: (await page.locator('button[title="Agent App Lab"]').count()) === 0, + labPageHidden: (await page.locator('[data-testid="agent-app-lab-page"]').count()) === 0, + agentAppsNavVisible: (await page.locator('button[title="Agent Apps"]').count()) > 0, + noConsoleErrors: consoleErrors.length === 0, + }; + + Object.entries(assertions).forEach(([key, value]) => { + if (!value && key === "noConsoleErrors") { + console.error( + `[smoke:agent-app-lab] flagOffConsoleErrors=${JSON.stringify(consoleErrors, null, 2)}`, + ); + } + assert(Boolean(value), `Flag-off assertion failed: ${key}`); + }); + + const screenshotPath = path.join(options.evidenceDir, `${options.prefix}-flag-off.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return { + assertions, + consoleErrors, + failedRequests, + screenshot: screenshotPath, + }; + } finally { + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + fs.mkdirSync(options.evidenceDir, { recursive: true }); + await waitForHealth(options); + + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-agent-app-lab-smoke-")); + let context; + context = await launchSmokeContext(userDataDir); + const consoleErrors = []; + const failedRequests = []; + + await context.addInitScript(() => { + window.localStorage.setItem( + "lime.agentAppHost.flags", + JSON.stringify({ + labEnabled: true, + realAdapterEnabled: true, + uiRuntimeEnabled: true, + }), + ); + }); + + const page = await context.newPage(); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + page.on("requestfailed", (request) => { + failedRequests.push({ + url: request.url(), + method: request.method(), + failure: request.failure()?.errorText ?? "unknown", + }); + }); + + try { + logStage("open-app"); + await page.goto(options.appUrl, { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + await page.waitForSelector('button[title="Agent App Lab"]', { + timeout: options.timeoutMs, + }); + + logStage("open-agent-app-lab"); + await page.click('button[title="Agent App Lab"]'); + await page.waitForSelector('[data-testid="agent-app-lab-page"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-install-flow"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-manager"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-manager-repository"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-manager-repository-list"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector( + '[data-testid="agent-app-manager-repository-app-content-factory-playbook-app"]', + { + timeout: options.timeoutMs, + }, + ); + consoleErrors.length = 0; + failedRequests.length = 0; + + logStage("resolve-lab-setup"); + await page.click('[data-testid="agent-app-lab-resolve-setup"]'); + await page.waitForFunction( + () => document.body.textContent?.includes("Guard 已允许") || + document.body.textContent?.includes("Guard allows launch") || + document.body.textContent?.includes("agentApp.lab.installFlow.launchReady"), + undefined, + { timeout: options.timeoutMs }, + ); + + logStage("select-companion-app"); + await page.click( + '[data-testid="agent-app-manager-repository-app-content-factory-playbook-app"]', + ); + await page.waitForFunction( + () => + document + .querySelector('[data-testid="agent-app-manager-selected-app"]') + ?.textContent?.includes("fixture:content-factory-playbook-app"), + undefined, + { timeout: options.timeoutMs }, + ); + + logStage("verify-disable-blocker"); + await page.click('[data-testid="agent-app-manager-disable"]'); + await page.waitForFunction( + () => + document + .querySelector('[data-testid="agent-app-manager-launch-entry-dashboard"]') + ?.hasAttribute("disabled") && + !document + .querySelector('[data-testid="agent-app-manager-enable"]') + ?.hasAttribute("disabled"), + undefined, + { timeout: options.timeoutMs }, + ); + const managerDisableBlocked = await page.isDisabled( + '[data-testid="agent-app-manager-launch-entry-dashboard"]', + ); + const managerEnableAvailable = !(await page.isDisabled( + '[data-testid="agent-app-manager-enable"]', + )); + await page.click('[data-testid="agent-app-manager-enable"]'); + await page.waitForFunction( + () => + !document + .querySelector('[data-testid="agent-app-manager-launch-entry-dashboard"]') + ?.hasAttribute("disabled"), + undefined, + { timeout: options.timeoutMs }, + ); + const managerReenabled = !(await page.isDisabled( + '[data-testid="agent-app-manager-launch-entry-dashboard"]', + )); + + logStage("mount-dashboard"); + await page.click('[data-testid="agent-app-manager-launch-entry-dashboard"]'); + await page.waitForSelector('[data-testid="agent-app-ui-runtime-result"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-entry-runtime-guard-allow"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-manager-cleanup-evidence"]', { + timeout: options.timeoutMs, + }); + + logStage("preview-delete-data-cleanup-evidence"); + await page.click('[data-testid="agent-app-manager-uninstall-delete-data"]'); + await page.waitForSelector('[data-testid="agent-app-manager-evidence-json"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-manager-residual-audit"]', { + timeout: options.timeoutMs, + }); + const cleanupEvidenceText = await page.textContent( + '[data-testid="agent-app-manager-evidence-json"]', + ); + const cleanupEvidence = JSON.parse(cleanupEvidenceText ?? "{}"); + const residualPendingText = await page.textContent( + '[data-testid="agent-app-manager-residual-pending"]', + ); + + const pageText = await page.textContent("body"); + const uiRuntimeText = await page.textContent('[data-testid="agent-app-ui-runtime-result"]'); + const assertions = { + labVisible: Boolean(await page.$('[data-testid="agent-app-lab-page"]')), + installFlowVisible: Boolean(await page.$('[data-testid="agent-app-install-flow"]')), + managerVisible: Boolean(await page.$('[data-testid="agent-app-manager"]')), + managerRepository: Boolean(await page.$('[data-testid="agent-app-manager-repository"]')), + managerRepositoryList: Boolean( + await page.$('[data-testid="agent-app-manager-repository-list"]'), + ), + managerMultiApp: Boolean( + await page.$('[data-testid="agent-app-manager-repository-app-content-factory-playbook-app"]'), + ), + managerSelectedApp: Boolean( + pageText?.includes("fixture:content-factory-playbook-app"), + ), + managerDisableBlocked, + managerEnableAvailable, + managerReenabled, + selectedRuntimeApp: Boolean( + uiRuntimeText?.includes("content-factory-playbook-app"), + ), + setupResolved: pageText?.includes("Lab 示例 setup 已解决") || + pageText?.includes("Lab sample setup") || + pageText?.includes("agentApp.lab.installFlow.setupResolved"), + guardAllowed: Boolean(await page.$('[data-testid="agent-app-entry-runtime-guard-allow"]')), + uiMounted: Boolean(await page.$('[data-testid="agent-app-ui-runtime-result"]')), + managerEvidence: Boolean(await page.$('[data-testid="agent-app-manager-cleanup-evidence"]')), + cleanupEvidenceJson: Boolean( + await page.$('[data-testid="agent-app-manager-evidence-json"]'), + ), + residualAuditVisible: Boolean( + await page.$('[data-testid="agent-app-manager-residual-audit"]'), + ), + residualAuditPending: Boolean( + residualPendingText?.includes("10") || + residualPendingText?.includes( + "agentApp.lab.manager.evidence.residual.pendingDeletion", + ), + ), + cleanupEvidenceSelectedApp: cleanupEvidence.appId === "content-factory-playbook-app", + cleanupEvidenceStrategy: cleanupEvidence.strategy === "delete-data", + cleanupEvidenceBlockedCount: cleanupEvidence.blockedTargetCount === 0, + launchedStatus: pageText?.includes("已在 Lab 启动") || + pageText?.includes("Launched in Lab") || + pageText?.includes("agentApp.lab.installFlow.status.launched"), + cleanupPreview: Boolean( + await page.$('[data-testid="agent-app-install-flow-stage-cleanup-preview"]'), + ), + noConsoleErrors: consoleErrors.length === 0, + }; + + Object.entries(assertions).forEach(([key, value]) => { + if (!value && key === "noConsoleErrors") { + console.error( + `[smoke:agent-app-lab] consoleErrors=${JSON.stringify(consoleErrors, null, 2)}`, + ); + } + assert(Boolean(value), `Assertion failed: ${key}`); + }); + + const flagOff = await runFlagOffRegression(options); + const flagOffAssertions = { + flagOffLabNavHidden: flagOff.assertions.labNavHidden, + flagOffLabPageHidden: flagOff.assertions.labPageHidden, + flagOffAgentAppsNavVisible: flagOff.assertions.agentAppsNavVisible, + flagOffNoConsoleErrors: flagOff.assertions.noConsoleErrors, + }; + Object.entries(flagOffAssertions).forEach(([key, value]) => { + assert(Boolean(value), `Assertion failed: ${key}`); + }); + + const screenshotPath = path.join(options.evidenceDir, `${options.prefix}.png`); + const summaryPath = path.join(options.evidenceDir, `${options.prefix}-summary.json`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + fs.writeFileSync( + summaryPath, + `${JSON.stringify( + { + scenarioId: "agent-app-lab-smoke", + appUrl: options.appUrl, + assertions: { + ...assertions, + ...flagOffAssertions, + }, + cleanupEvidence, + residualAudit: { + pendingText: residualPendingText, + }, + flagOff, + consoleErrors, + failedRequests, + screenshot: screenshotPath, + }, + null, + 2, + )}\n`, + ); + console.log(`[smoke:agent-app-lab] summary=${summaryPath}`); + console.log("[smoke:agent-app-lab] 通过"); + } finally { + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/agent-apps-smoke.mjs b/scripts/agent-apps-smoke.mjs new file mode 100644 index 000000000..4e56eacf8 --- /dev/null +++ b/scripts/agent-apps-smoke.mjs @@ -0,0 +1,394 @@ +#!/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", + timeoutMs: 120_000, + intervalMs: 1_000, + evidenceDir: path.join(process.cwd(), ".lime", "qc", "gui-evidence", "agent-apps"), + prefix: "agent-apps-smoke", +}; + +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 === "--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 === "--evidence-dir" && argv[index + 1]) { + options.evidenceDir = path.resolve(String(argv[index + 1]).trim()); + index += 1; + continue; + } + if (arg === "--prefix" && argv[index + 1]) { + options.prefix = String(argv[index + 1]).trim(); + index += 1; + } + } + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function logStage(stage) { + console.log(`[smoke:agent-apps] stage=${stage}`); +} + +async function launchSmokeContext(userDataDir) { + try { + return await chromium.launchPersistentContext(userDataDir, { + channel: "chrome", + headless: true, + }); + } catch (error) { + console.warn( + `[smoke:agent-apps] Chrome channel 启动失败,尝试 Playwright Chromium: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return chromium.launchPersistentContext(userDataDir, { + headless: true, + }); + } +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + while (Date.now() - startedAt < options.timeoutMs) { + try { + const response = await fetch(options.healthUrl); + const payload = await response.json(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + console.log( + `[smoke:agent-apps] DevBridge 已就绪 (${Date.now() - startedAt}ms) status=${payload?.status ?? "unknown"}`, + ); + return payload; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + throw new Error( + `[smoke:agent-apps] DevBridge 未就绪: ${ + lastError instanceof Error ? lastError.message : String(lastError) + }`, + ); +} + +function activeCloudBootstrapPayload() { + return { + schemaVersion: "agent-app-cloud-bootstrap/v1", + tenantId: "smoke-formal-entry", + generatedAt: "2026-05-16T00:00:00.000Z", + apps: [ + { + appId: "content-factory-app", + displayName: "内容工厂", + version: "0.3.0", + releaseId: "smoke-content-factory-app-0.3.0", + channel: "smoke", + licenseState: "active", + registrationRequired: true, + registrationState: "active", + enabled: true, + packageUrl: "https://lime.local/agent-apps/content-factory-app/releases/0.3.0/package.zip", + packageHash: "package-fnv1a-smoke-formal", + manifestHash: "manifest-fnv1a-smoke-formal", + capabilityRequirements: {}, + defaultEntries: ["dashboard", "content_scenario_planning"], + policyDefaults: {}, + toolAvailability: [], + }, + ], + }; +} + +async function runFlagOffRegression(options) { + logStage("flag-off-regression"); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-agent-apps-flag-off-")); + const context = await launchSmokeContext(userDataDir); + const page = await context.newPage(); + const consoleErrors = []; + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + + try { + await page.goto(options.appUrl, { + waitUntil: "domcontentloaded", + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="app-sidebar-main-nav"]', { + timeout: options.timeoutMs, + }); + + const assertions = { + agentAppsNavVisible: (await page.locator('button[title="Agent Apps"]').count()) > 0, + labNavHidden: (await page.locator('button[title="Agent App Lab"]').count()) === 0, + noConsoleErrors: consoleErrors.length === 0, + }; + + Object.entries(assertions).forEach(([key, value]) => { + assert(Boolean(value), `Flag-off assertion failed: ${key}`); + }); + + const screenshotPath = path.join(options.evidenceDir, `${options.prefix}-flag-off.png`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return { + assertions, + consoleErrors, + screenshot: screenshotPath, + }; + } finally { + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + fs.mkdirSync(options.evidenceDir, { recursive: true }); + await waitForHealth(options); + + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-agent-apps-smoke-")); + const context = await launchSmokeContext(userDataDir); + const consoleErrors = []; + const failedRequests = []; + + const bootstrap = activeCloudBootstrapPayload(); + await context.addInitScript((payload) => { + window.localStorage.removeItem("lime.agentAppHost.flags"); + window.localStorage.removeItem("lime.agentAppHost.labEnabled"); + window.__LIME_AGENT_APPS_SMOKE_BOOTSTRAP__ = payload; + }, bootstrap); + + const page = await context.newPage(); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + page.on("requestfailed", (request) => { + failedRequests.push({ + url: request.url(), + method: request.method(), + failure: request.failure()?.errorText ?? "unknown", + }); + }); + + try { + logStage("open-app"); + await page.goto(options.appUrl, { waitUntil: "domcontentloaded", timeout: options.timeoutMs }); + await page.waitForSelector('[data-testid="app-sidebar-main-nav"]', { + timeout: options.timeoutMs, + }); + assert( + (await page.locator('button[title="Agent Apps"]').count()) > 0, + "Agent Apps nav should be visible", + ); + assert( + (await page.locator('button[title="Agent App Lab"]').count()) === 0, + "Agent App Lab nav should stay hidden in formal smoke", + ); + + logStage("open-agent-apps"); + await page.click('button[title="Agent Apps"]'); + await page.waitForSelector('[data-testid="agent-apps-page"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-apps-installed-content-factory-app"]', { + timeout: options.timeoutMs, + }); + + logStage("verify-registration-required"); + await page.waitForSelector('[data-testid="agent-apps-registration-content-factory-app"]', { + timeout: options.timeoutMs, + }); + const registrationInstallBlocked = await page.isDisabled( + '[data-testid="agent-apps-install-cloud-content-factory-app"]', + ); + + logStage("activate-bootstrap-catalog"); + await page.evaluate((payload) => { + window.__LIME_BOOTSTRAP__ = payload; + }, bootstrap); + await page.click('[data-testid="agent-apps-refresh"]'); + await page.waitForFunction( + () => + !document + .querySelector('[data-testid="agent-apps-install-cloud-content-factory-app"]') + ?.hasAttribute("disabled"), + undefined, + { timeout: options.timeoutMs }, + ); + + logStage("install-cloud-review"); + await page.click('[data-testid="agent-apps-install-cloud-content-factory-app"]'); + await page.waitForSelector('[data-testid="agent-apps-install-review"]', { + timeout: options.timeoutMs, + }); + await page.click('[data-testid="agent-apps-install-review-confirm"]'); + await page.waitForSelector('[data-testid="agent-apps-installed-content-factory-app"]', { + timeout: options.timeoutMs, + }); + + logStage("disable-enable"); + await page.click('[data-testid="agent-apps-installed-content-factory-app"]'); + await page.click('[data-testid="agent-apps-disable"]'); + await page.waitForFunction( + () => + document + .querySelector('[data-testid="agent-apps-launch-entry-dashboard"]') + ?.hasAttribute("disabled") && + !document.querySelector('[data-testid="agent-apps-enable"]')?.hasAttribute("disabled"), + undefined, + { timeout: options.timeoutMs }, + ); + const disabledLaunchBlocked = await page.isDisabled( + '[data-testid="agent-apps-launch-entry-dashboard"]', + ); + await page.click('[data-testid="agent-apps-enable"]'); + await page.waitForFunction( + () => + !document + .querySelector('[data-testid="agent-apps-launch-entry-dashboard"]') + ?.hasAttribute("disabled"), + undefined, + { timeout: options.timeoutMs }, + ); + + logStage("launch-runtime-surface"); + await page.click('[data-testid="agent-apps-launch-entry-dashboard"]'); + await page.waitForSelector('[data-testid="agent-app-runtime-surface"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-app-runtime-frame"]', { + timeout: options.timeoutMs, + }); + const runtimeFrameSrc = await page.getAttribute( + '[data-testid="agent-app-runtime-frame"]', + "src", + ); + + logStage("return-agent-apps"); + await page.click('button[title="Agent Apps"]'); + await page.waitForSelector('[data-testid="agent-apps-page"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-apps-installed-content-factory-app"]', { + timeout: options.timeoutMs, + }); + + logStage("uninstall-rehearsal"); + await page.click('[data-testid="agent-apps-uninstall-delete-data"]'); + await page.waitForSelector('[data-testid="agent-apps-uninstall-preview"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-apps-cleanup-evidence-json"]', { + timeout: options.timeoutMs, + }); + await page.waitForSelector('[data-testid="agent-apps-residual-audit"]', { + timeout: options.timeoutMs, + }); + const cleanupEvidenceText = await page.textContent( + '[data-testid="agent-apps-cleanup-evidence-json"]', + ); + const cleanupEvidence = JSON.parse(cleanupEvidenceText ?? "{}"); + await page.click('[data-testid="agent-apps-uninstall-confirm"]'); + await page.waitForSelector('[data-testid="agent-apps-launch-summary"]', { + timeout: options.timeoutMs, + }); + const stillInstalledAfterRehearsal = + (await page.locator('[data-testid="agent-apps-installed-content-factory-app"]').count()) > 0; + + const flagOff = await runFlagOffRegression(options); + const assertions = { + formalPageVisible: Boolean(await page.$('[data-testid="agent-apps-page"]')), + installedVisible: stillInstalledAfterRehearsal, + registrationRequiredBlocked: registrationInstallBlocked, + cloudInstallReviewVisible: true, + disabledLaunchBlocked, + runtimeSurfaceVisible: Boolean(runtimeFrameSrc), + cleanupEvidenceSelectedApp: cleanupEvidence.appId === "content-factory-app", + cleanupEvidenceStrategy: cleanupEvidence.strategy === "delete-data", + cleanupEvidenceRehearsalOnly: cleanupEvidence.completionEffect === "rehearsal-only", + cleanupEvidenceBlockedCount: cleanupEvidence.blockedTargetCount === 0, + flagOffAgentAppsNavVisible: flagOff.assertions.agentAppsNavVisible, + flagOffLabNavHidden: flagOff.assertions.labNavHidden, + flagOffNoConsoleErrors: flagOff.assertions.noConsoleErrors, + }; + + Object.entries(assertions).forEach(([key, value]) => { + assert(Boolean(value), `Assertion failed: ${key}`); + }); + + const screenshotPath = path.join(options.evidenceDir, `${options.prefix}.png`); + const summaryPath = path.join(options.evidenceDir, `${options.prefix}-summary.json`); + await page.screenshot({ path: screenshotPath, fullPage: true }); + fs.writeFileSync( + summaryPath, + `${JSON.stringify( + { + scenarioId: "agent-apps-smoke", + appUrl: options.appUrl, + assertions, + runtimeFrameSrc, + cleanupEvidence, + flagOff, + consoleErrors, + failedRequests, + screenshot: screenshotPath, + }, + null, + 2, + )}\n`, + ); + console.log(`[smoke:agent-apps] summary=${summaryPath}`); + console.log("[smoke:agent-apps] 通过"); + } finally { + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/knowledge-gui-smoke.mjs b/scripts/knowledge-gui-smoke.mjs index 24fc4f52f..455459792 100644 --- a/scripts/knowledge-gui-smoke.mjs +++ b/scripts/knowledge-gui-smoke.mjs @@ -459,6 +459,98 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) { } } +async function waitForInputbarKnowledgePackToggle(page, label, timeoutMs) { + const locator = page.locator('[data-testid="inputbar-knowledge-pack-toggle"]'); + + try { + await locator.first().waitFor({ state: "visible", timeout: timeoutMs }); + } catch (error) { + const pageText = await page + .locator("body") + .innerText() + .catch(() => ""); + const knowledgeControls = await page + .locator("[data-testid^='inputbar-knowledge-']") + .evaluateAll((items) => + items.slice(0, 40).map((item) => ({ + testId: item.getAttribute("data-testid"), + text: (item.textContent || "").trim().replace(/\s+/g, " "), + aria: item.getAttribute("aria-label"), + title: item.getAttribute("title"), + })), + ) + .catch(() => []); + throw new Error( + `[smoke:knowledge-gui] ${label} 等待默认项目资料控件失败 ${JSON.stringify({ + knowledgeControls, + })},页面文本预览: ${pageText.slice(0, 2_000)}`, + { cause: error }, + ); + } +} + +async function clickTestId(page, { testId, label }) { + const locator = page.locator(`[data-testid="${testId}"]`).first(); + + try { + await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS }); + } catch (error) { + const pageText = await page + .locator("body") + .innerText() + .catch(() => ""); + const candidates = await page + .locator("[data-testid]") + .evaluateAll((items) => + items.slice(0, 100).map((item) => ({ + testId: item.getAttribute("data-testid"), + text: (item.textContent || "").trim().replace(/\s+/g, " "), + aria: item.getAttribute("aria-label"), + title: item.getAttribute("title"), + })), + ) + .catch(() => []); + throw new Error( + `[smoke:knowledge-gui] ${label} 点击失败 ${JSON.stringify({ + testId, + candidates, + })},页面文本预览: ${pageText.slice(0, 2_000)}`, + { cause: error }, + ); + } +} + +async function waitForInputbarKnowledgeHub(page, label, timeoutMs) { + try { + await page + .locator('[data-testid="inputbar-knowledge-hub"]') + .first() + .waitFor({ state: "visible", timeout: timeoutMs }); + } catch (error) { + const pageText = await page + .locator("body") + .innerText() + .catch(() => ""); + const knowledgeControls = await page + .locator("[data-testid^='inputbar-knowledge-']") + .evaluateAll((items) => + items.slice(0, 40).map((item) => ({ + testId: item.getAttribute("data-testid"), + text: (item.textContent || "").trim().replace(/\s+/g, " "), + aria: item.getAttribute("aria-label"), + title: item.getAttribute("title"), + })), + ) + .catch(() => []); + throw new Error( + `[smoke:knowledge-gui] ${label} 等待项目资料浮层失败 ${JSON.stringify({ + knowledgeControls, + })},页面文本预览: ${pageText.slice(0, 2_000)}`, + { cause: error }, + ); + } +} + async function closeFileManagerIfOpen(page) { const closeButton = page .getByRole("button", { name: "关闭文件管理器" }) @@ -793,11 +885,25 @@ async function openKnowledgePageFromMainNav(page, options) { throw new Error( `[smoke:knowledge-gui] 项目资料导航失败: ${ - lastError instanceof Error ? lastError.message : String(lastError) + lastError instanceof Error ? lastError.message : String(lastError) }`, ); } +async function openHomeFromMainNav(page, options, label) { + await syncSmokeProjectStorage(page, options); + await clickScopedButton(page, { + scope: '[data-testid="app-sidebar-main-nav"]', + ariaLabel: "新建任务", + }); + await waitForPageText( + page, + `${label} 新建任务页加载`, + ["青柠一下,灵感即来"], + options.timeoutMs, + ); +} + async function waitForKnowledgePack(options, label, matcher) { const startedAt = Date.now(); let lastError = null; @@ -960,7 +1066,20 @@ async function runPlaywrightGuiFlow(options) { ); logStage("open-home-knowledge-hub"); - await clickPageControl(page, { text: "添加资料" }); + await waitForInputbarKnowledgePackToggle( + page, + "首页默认项目资料控件加载", + options.timeoutMs, + ); + await clickTestId(page, { + testId: "entry-home-knowledge-import", + label: "首页添加资料入口", + }); + await waitForInputbarKnowledgeHub( + page, + "首页添加资料入口打开浮层", + options.timeoutMs, + ); logStage("wait-home-knowledge-hub"); await waitForPageText( @@ -1037,16 +1156,7 @@ async function runPlaywrightGuiFlow(options) { options.timeoutMs, ); await assertNoUserFacingInternalText(page, "项目资料状态说明页"); - await clickScopedButton(page, { - scope: '[data-testid="app-sidebar-main-nav"]', - ariaLabel: "灵感", - }); - await waitForPageText( - page, - "灵感页加载", - ["收藏过的想法"], - options.timeoutMs, - ); + await openHomeFromMainNav(page, options, "状态说明后返回"); await openKnowledgePageFromMainNav(page, options); await waitForPageText( page, @@ -1133,11 +1243,7 @@ async function runPlaywrightGuiFlow(options) { ); logStage("return-knowledge-overview"); - await clickScopedButton(page, { - scope: '[data-testid="app-sidebar-main-nav"]', - ariaLabel: "灵感", - }); - await waitForPageText(page, "灵感页加载", ["灵感"], options.timeoutMs); + await openHomeFromMainNav(page, options, "Agent 保存后返回"); await openKnowledgePageFromMainNav(page, options); logStage("wait-captured-agent-result"); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1b688baf5..a567d36a4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5131,7 +5131,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.39.0" +version = "1.40.0" dependencies = [ "anyhow", "arboard", @@ -5238,7 +5238,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.39.0" +version = "1.40.0" dependencies = [ "anyhow", "aster-core", @@ -5267,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.39.0" +version = "1.40.0" dependencies = [ "chrono", "futures", @@ -5284,7 +5284,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.39.0" +version = "1.40.0" dependencies = [ "clap", "lime-core", @@ -5296,7 +5296,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.39.0" +version = "1.40.0" dependencies = [ "async-trait", "lime-core", @@ -5312,7 +5312,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.39.0" +version = "1.40.0" dependencies = [ "aster-models", "async-trait", @@ -5323,6 +5323,7 @@ dependencies = [ "dirs 5.0.1", "flate2", "futures", + "hmac", "if-addrs", "indexmap 2.13.0", "notify 6.1.1", @@ -5365,7 +5366,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.39.0" +version = "1.40.0" dependencies = [ "aes", "axum 0.7.9", @@ -5395,7 +5396,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.39.0" +version = "1.40.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5415,7 +5416,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.39.0" +version = "1.40.0" dependencies = [ "chrono", "hex", @@ -5428,7 +5429,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.39.0" +version = "1.40.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5444,7 +5445,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.39.0" +version = "1.40.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5477,7 +5478,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.39.0" +version = "1.40.0" dependencies = [ "async-trait", "lime-core", @@ -5496,7 +5497,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.39.0" +version = "1.40.0" dependencies = [ "anyhow", "async-stream", @@ -5551,7 +5552,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.39.0" +version = "1.40.0" dependencies = [ "aster-core", "async-stream", @@ -5595,7 +5596,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.39.0" +version = "1.40.0" dependencies = [ "axum 0.7.9", "futures", @@ -5610,7 +5611,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.39.0" +version = "1.40.0" dependencies = [ "anyhow", "aster-core", @@ -5655,7 +5656,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.39.0" +version = "1.40.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5673,7 +5674,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.39.0" +version = "1.40.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f1fc636c5..3183e85bf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.39.0" +version = "1.40.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -89,6 +89,7 @@ regex = "1" semver = "1" md5 = "0.7" jsonwebtoken = "9.3.1" +hmac = "0.12" urlencoding = "2" subtle = "2.5" flate2 = "1" @@ -198,7 +199,7 @@ version = "2.4.9" [package] name = "lime" -version = "1.39.0" +version = "1.40.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/core/Cargo.toml b/src-tauri/crates/core/Cargo.toml index 9f6d5ce7e..39dea0b6c 100644 --- a/src-tauri/crates/core/Cargo.toml +++ b/src-tauri/crates/core/Cargo.toml @@ -36,6 +36,7 @@ indexmap.workspace = true parking_lot.workspace = true dirs.workspace = true sha2.workspace = true +hmac.workspace = true url.workspace = true urlencoding.workspace = true bytes.workspace = true @@ -68,4 +69,4 @@ if-addrs.workspace = true [dev-dependencies] proptest.workspace = true -tempfile.workspace = true \ No newline at end of file +tempfile.workspace = true diff --git a/src-tauri/crates/core/src/agent_app_runtime_token.rs b/src-tauri/crates/core/src/agent_app_runtime_token.rs new file mode 100644 index 000000000..b533c5897 --- /dev/null +++ b/src-tauri/crates/core/src/agent_app_runtime_token.rs @@ -0,0 +1,331 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use std::fmt; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +const TOKEN_PREFIX: &str = "lime-agent-app"; +const TOKEN_VERSION: &str = "v1"; +pub const AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION: &str = "model-generation"; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentAppRuntimeTokenClaims { + pub app_id: String, + pub expires_at_unix: i64, + pub scope: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentAppRuntimeTokenError { + EmptySecret, + InvalidAppId, + InvalidTtl, + Malformed, + UnsupportedVersion, + UnsupportedScope, + Expired, + InvalidSignature, +} + +impl fmt::Display for AgentAppRuntimeTokenError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EmptySecret => "Agent App runtime token secret is empty", + Self::InvalidAppId => "Agent App runtime token app id is invalid", + Self::InvalidTtl => "Agent App runtime token ttl is invalid", + Self::Malformed => "Agent App runtime token is malformed", + Self::UnsupportedVersion => "Agent App runtime token version is unsupported", + Self::UnsupportedScope => "Agent App runtime token scope is unsupported", + Self::Expired => "Agent App runtime token is expired", + Self::InvalidSignature => "Agent App runtime token signature is invalid", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AgentAppRuntimeTokenError {} + +pub fn issue_agent_app_runtime_token( + secret: &str, + app_id: &str, + ttl_secs: u64, +) -> Result { + let now = chrono::Utc::now().timestamp(); + let ttl = i64::try_from(ttl_secs).map_err(|_| AgentAppRuntimeTokenError::InvalidTtl)?; + issue_agent_app_runtime_token_at(secret, app_id, now, ttl) +} + +pub fn issue_agent_app_runtime_token_at( + secret: &str, + app_id: &str, + issued_at_unix: i64, + ttl_secs: i64, +) -> Result { + if ttl_secs <= 0 { + return Err(AgentAppRuntimeTokenError::InvalidTtl); + } + let expires_at_unix = issued_at_unix + .checked_add(ttl_secs) + .ok_or(AgentAppRuntimeTokenError::InvalidTtl)?; + let nonce = Uuid::new_v4().to_string(); + issue_agent_app_runtime_token_with_nonce(secret, app_id, expires_at_unix, &nonce) +} + +pub fn issue_agent_app_runtime_token_with_nonce( + secret: &str, + app_id: &str, + expires_at_unix: i64, + nonce: &str, +) -> Result { + issue_agent_app_runtime_token_with_nonce_for_scope( + secret, + app_id, + expires_at_unix, + nonce, + AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION, + ) +} + +fn issue_agent_app_runtime_token_with_nonce_for_scope( + secret: &str, + app_id: &str, + expires_at_unix: i64, + nonce: &str, + scope: &str, +) -> Result { + ensure_secret(secret)?; + ensure_safe_token_field(app_id).ok_or(AgentAppRuntimeTokenError::InvalidAppId)?; + ensure_safe_token_field(nonce).ok_or(AgentAppRuntimeTokenError::Malformed)?; + if scope != AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION { + return Err(AgentAppRuntimeTokenError::UnsupportedScope); + } + + let signature = sign_token_parts(secret, app_id, expires_at_unix, nonce, scope)?; + Ok(format!( + "{TOKEN_PREFIX}:{TOKEN_VERSION}:{app_id}:{expires_at_unix}:{nonce}:{scope}:{signature}" + )) +} + +pub fn verify_agent_app_runtime_token( + secret: &str, + token: &str, +) -> Result { + verify_agent_app_runtime_token_at(secret, token, chrono::Utc::now().timestamp()) +} + +pub fn verify_agent_app_runtime_token_for_scope( + secret: &str, + token: &str, + expected_scope: &str, +) -> Result { + verify_agent_app_runtime_token_at_for_scope( + secret, + token, + chrono::Utc::now().timestamp(), + expected_scope, + ) +} + +pub fn verify_agent_app_runtime_token_at( + secret: &str, + token: &str, + now_unix: i64, +) -> Result { + verify_agent_app_runtime_token_at_for_scope( + secret, + token, + now_unix, + AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION, + ) +} + +pub fn verify_agent_app_runtime_token_at_for_scope( + secret: &str, + token: &str, + now_unix: i64, + expected_scope: &str, +) -> Result { + ensure_secret(secret)?; + + let parts: Vec<&str> = token.split(':').collect(); + if parts.len() != 7 || parts[0] != TOKEN_PREFIX { + return Err(AgentAppRuntimeTokenError::Malformed); + } + if parts[1] != TOKEN_VERSION { + return Err(AgentAppRuntimeTokenError::UnsupportedVersion); + } + + let app_id = parts[2]; + let expires_at_unix = parts[3] + .parse::() + .map_err(|_| AgentAppRuntimeTokenError::Malformed)?; + let nonce = parts[4]; + let scope = parts[5]; + let signature = parts[6]; + + ensure_safe_token_field(app_id).ok_or(AgentAppRuntimeTokenError::InvalidAppId)?; + ensure_safe_token_field(nonce).ok_or(AgentAppRuntimeTokenError::Malformed)?; + if scope != expected_scope || scope != AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION { + return Err(AgentAppRuntimeTokenError::UnsupportedScope); + } + if expires_at_unix <= now_unix { + return Err(AgentAppRuntimeTokenError::Expired); + } + + let expected = sign_token_parts(secret, app_id, expires_at_unix, nonce, scope)?; + if signature.len() != expected.len() + || !bool::from(signature.as_bytes().ct_eq(expected.as_bytes())) + { + return Err(AgentAppRuntimeTokenError::InvalidSignature); + } + + Ok(AgentAppRuntimeTokenClaims { + app_id: app_id.to_string(), + expires_at_unix, + scope: scope.to_string(), + }) +} + +pub fn is_agent_app_runtime_token(token: &str) -> bool { + token.starts_with(&format!("{TOKEN_PREFIX}:")) +} + +fn ensure_secret(secret: &str) -> Result<(), AgentAppRuntimeTokenError> { + if secret.trim().is_empty() { + return Err(AgentAppRuntimeTokenError::EmptySecret); + } + Ok(()) +} + +fn ensure_safe_token_field(value: &str) -> Option<()> { + if value.is_empty() + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + { + return None; + } + Some(()) +} + +fn sign_token_parts( + secret: &str, + app_id: &str, + expires_at_unix: i64, + nonce: &str, + scope: &str, +) -> Result { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|_| AgentAppRuntimeTokenError::EmptySecret)?; + mac.update( + format!("{TOKEN_VERSION}\n{app_id}\n{expires_at_unix}\n{nonce}\n{scope}").as_bytes(), + ); + Ok(hex_encode(&mac.finalize().into_bytes())) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn issued_agent_app_runtime_token_verifies_claims() { + let token = issue_agent_app_runtime_token_with_nonce( + "server-secret", + "content-factory-app", + 1_800, + "nonce-1", + ) + .unwrap(); + + assert!(is_agent_app_runtime_token(&token)); + assert_ne!(token, "server-secret"); + + let claims = verify_agent_app_runtime_token_at("server-secret", &token, 1_000).unwrap(); + assert_eq!(claims.app_id, "content-factory-app"); + assert_eq!(claims.expires_at_unix, 1_800); + assert_eq!(claims.scope, AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION); + } + + #[test] + fn token_rejects_tampering_and_wrong_secret() { + let token = issue_agent_app_runtime_token_with_nonce( + "server-secret", + "content-factory-app", + 1_800, + "nonce-1", + ) + .unwrap(); + let tampered = token.replace("content-factory-app", "other-app"); + + assert_eq!( + verify_agent_app_runtime_token_at("server-secret", &tampered, 1_000).unwrap_err(), + AgentAppRuntimeTokenError::InvalidSignature + ); + assert_eq!( + verify_agent_app_runtime_token_at("other-secret", &token, 1_000).unwrap_err(), + AgentAppRuntimeTokenError::InvalidSignature + ); + } + + #[test] + fn token_rejects_expired_or_malformed_values() { + let token = issue_agent_app_runtime_token_with_nonce( + "server-secret", + "content-factory-app", + 1_800, + "nonce-1", + ) + .unwrap(); + + assert_eq!( + verify_agent_app_runtime_token_at("server-secret", &token, 1_801).unwrap_err(), + AgentAppRuntimeTokenError::Expired + ); + assert_eq!( + issue_agent_app_runtime_token_with_nonce("server-secret", "bad:app", 1_800, "nonce") + .unwrap_err(), + AgentAppRuntimeTokenError::InvalidAppId + ); + } + + #[test] + fn token_rejects_unsupported_scope() { + assert_eq!( + issue_agent_app_runtime_token_with_nonce_for_scope( + "server-secret", + "content-factory-app", + 1_800, + "nonce-1", + "gateway", + ) + .unwrap_err(), + AgentAppRuntimeTokenError::UnsupportedScope + ); + + let token = issue_agent_app_runtime_token_with_nonce( + "server-secret", + "content-factory-app", + 1_800, + "nonce-1", + ) + .unwrap(); + + assert_eq!( + verify_agent_app_runtime_token_at_for_scope("server-secret", &token, 1_000, "gateway",) + .unwrap_err(), + AgentAppRuntimeTokenError::UnsupportedScope + ); + } +} diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index 2dfb07da4..a5b3cf832 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -42,6 +42,7 @@ pub mod tool_calling; // 类型模块(纯数据类型,供 database 等模块使用) pub mod agent; +pub mod agent_app_runtime_token; pub mod general_chat; // 路由系统 diff --git a/src-tauri/crates/server/src/handlers/api.rs b/src-tauri/crates/server/src/handlers/api.rs index 8d9382703..86e5a20fa 100644 --- a/src-tauri/crates/server/src/handlers/api.rs +++ b/src-tauri/crates/server/src/handlers/api.rs @@ -39,6 +39,9 @@ use aster::session_context::{ PENDING_REQUEST_ID_HEADER, QUEUED_TURN_ID_HEADER, SESSION_ID_HEADER, SUBAGENT_SESSION_ID_HEADER, THREAD_ID_HEADER, TURN_ID_HEADER, }; +use lime_core::agent_app_runtime_token::{ + verify_agent_app_runtime_token_for_scope, AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION, +}; use lime_core::errors::GatewayErrorCode; use lime_core::models::anthropic::AnthropicMessagesRequest; use lime_core::models::openai::{ChatCompletionRequest, ContentPart, MessageContent}; @@ -49,6 +52,7 @@ use lime_providers::streaming::StreamFormat as StreamingFormat; use lime_server_utils::{ build_error_response_with_meta, build_gateway_error_json, message_content_len, }; +use subtle::ConstantTimeEq; use super::{call_provider_anthropic, call_provider_openai}; @@ -1933,35 +1937,24 @@ pub async fn verify_api_key( headers: &HeaderMap, expected_key: &str, ) -> Result<(), (StatusCode, Json)> { - let auth = headers - .get("authorization") - .or_else(|| headers.get("x-api-key")) - .and_then(|v| v.to_str().ok()); + let key = openai_api_key_from_headers(headers)?; - let key = match auth { - Some(s) if s.starts_with("Bearer ") => &s[7..], - Some(s) => s, - None => { - let body = build_gateway_error_json( - StatusCode::UNAUTHORIZED.as_u16(), - "No API key provided", - None, - None, - Some(GatewayErrorCode::AuthenticationFailed), - ); - return Err((StatusCode::UNAUTHORIZED, Json(body))); - } - }; + if !static_api_key_matches(expected_key, key) { + return Err(openai_auth_error("Invalid API key")); + } - if key != expected_key { - let body = build_gateway_error_json( - StatusCode::UNAUTHORIZED.as_u16(), - "Invalid API key", - None, - None, - Some(GatewayErrorCode::AuthenticationFailed), - ); - return Err((StatusCode::UNAUTHORIZED, Json(body))); + Ok(()) +} + +/// OpenAI 格式的模型生成端点认证,允许 Agent App 使用内容生成 scope。 +pub async fn verify_model_generation_api_key( + headers: &HeaderMap, + expected_key: &str, +) -> Result<(), (StatusCode, Json)> { + let key = openai_api_key_from_headers(headers)?; + + if !model_generation_access_key_matches(expected_key, key) { + return Err(openai_auth_error("Invalid API key")); } Ok(()) @@ -1972,44 +1965,98 @@ pub async fn verify_api_key_anthropic( headers: &HeaderMap, expected_key: &str, ) -> Result<(), (StatusCode, Json)> { + let key = anthropic_api_key_from_headers(headers)?; + + if !static_api_key_matches(expected_key, key) { + return Err(anthropic_auth_error("Invalid API key")); + } + + Ok(()) +} + +/// Anthropic 格式的模型生成端点认证,允许 Agent App 使用内容生成 scope。 +pub async fn verify_model_generation_api_key_anthropic( + headers: &HeaderMap, + expected_key: &str, +) -> Result<(), (StatusCode, Json)> { + let key = anthropic_api_key_from_headers(headers)?; + + if !model_generation_access_key_matches(expected_key, key) { + return Err(anthropic_auth_error("Invalid API key")); + } + + Ok(()) +} + +fn model_generation_access_key_matches(expected_key: &str, key: &str) -> bool { + static_api_key_matches(expected_key, key) + || verify_agent_app_runtime_token_for_scope( + expected_key, + key, + AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION, + ) + .is_ok() +} + +fn static_api_key_matches(expected_key: &str, key: &str) -> bool { + expected_key.len() == key.len() && bool::from(expected_key.as_bytes().ct_eq(key.as_bytes())) +} + +fn openai_api_key_from_headers( + headers: &HeaderMap, +) -> Result<&str, (StatusCode, Json)> { + let auth = headers + .get("authorization") + .or_else(|| headers.get("x-api-key")) + .and_then(|v| v.to_str().ok()); + + match auth { + Some(s) if s.starts_with("Bearer ") => Ok(&s[7..]), + Some(s) => Ok(s), + None => Err(openai_auth_error("No API key provided")), + } +} + +fn anthropic_api_key_from_headers( + headers: &HeaderMap, +) -> Result<&str, (StatusCode, Json)> { let auth = headers .get("x-api-key") .or_else(|| headers.get("authorization")) .and_then(|v| v.to_str().ok()); - let key = match auth { - Some(s) if s.starts_with("Bearer ") => &s[7..], - Some(s) => s, - None => { - let body = build_gateway_error_json( - StatusCode::UNAUTHORIZED.as_u16(), - "No API key provided. Please set the x-api-key header.", - None, - None, - Some(GatewayErrorCode::AuthenticationFailed), - ); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "type": "error", "error": body["error"].clone() })), - )); - } - }; - - if key != expected_key { - let body = build_gateway_error_json( - StatusCode::UNAUTHORIZED.as_u16(), - "Invalid API key", - None, - None, - Some(GatewayErrorCode::AuthenticationFailed), - ); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "type": "error", "error": body["error"].clone() })), - )); + match auth { + Some(s) if s.starts_with("Bearer ") => Ok(&s[7..]), + Some(s) => Ok(s), + None => Err(anthropic_auth_error( + "No API key provided. Please set the x-api-key header.", + )), } +} - Ok(()) +fn openai_auth_error(message: &str) -> (StatusCode, Json) { + let body = build_gateway_error_json( + StatusCode::UNAUTHORIZED.as_u16(), + message, + None, + None, + Some(GatewayErrorCode::AuthenticationFailed), + ); + (StatusCode::UNAUTHORIZED, Json(body)) +} + +fn anthropic_auth_error(message: &str) -> (StatusCode, Json) { + let body = build_gateway_error_json( + StatusCode::UNAUTHORIZED.as_u16(), + message, + None, + None, + Some(GatewayErrorCode::AuthenticationFailed), + ); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "type": "error", "error": body["error"].clone() })), + ) } pub async fn chat_completions( @@ -2024,7 +2071,7 @@ pub async fn chat_completions( eprintln!("[CHAT_COMPLETIONS] 流式: {}", request.stream); eprintln!("[CHAT_COMPLETIONS] 消息数量: {}", request.messages.len()); - if let Err(e) = verify_api_key(&headers, &state.api_key).await { + if let Err(e) = verify_model_generation_api_key(&headers, &state.api_key).await { eprintln!("[CHAT_COMPLETIONS] 认证失败!"); state .logs @@ -2432,7 +2479,7 @@ pub async fn anthropic_messages( Json(mut request): Json, ) -> Response { // 使用 Anthropic 格式的认证验证(优先检查 x-api-key) - if let Err(e) = verify_api_key_anthropic(&headers, &state.api_key).await { + if let Err(e) = verify_model_generation_api_key_anthropic(&headers, &state.api_key).await { state .logs .write() @@ -3110,3 +3157,81 @@ fn convert_anthropic_response_to_openai(anthropic_resp: &serde_json::Value, mode serde_json::to_string(&openai_resp).unwrap_or_default() } + +#[cfg(test)] +mod auth_tests { + use super::*; + use axum::http::HeaderValue; + use lime_core::agent_app_runtime_token::issue_agent_app_runtime_token_with_nonce; + + fn bearer_headers(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + headers + } + + #[tokio::test] + async fn verify_api_key_accepts_legacy_static_key() { + let headers = bearer_headers("server-secret"); + + verify_api_key(&headers, "server-secret").await.unwrap(); + verify_api_key_anthropic(&headers, "server-secret") + .await + .unwrap(); + } + + #[tokio::test] + async fn verify_api_key_rejects_agent_app_scoped_token() { + let token = issue_agent_app_runtime_token_with_nonce( + "server-secret", + "content-factory-app", + chrono::Utc::now().timestamp() + 60, + "nonce-1", + ) + .unwrap(); + let headers = bearer_headers(&token); + + assert!(verify_api_key(&headers, "server-secret").await.is_err()); + assert!(verify_api_key_anthropic(&headers, "server-secret") + .await + .is_err()); + } + + #[tokio::test] + async fn verify_model_generation_api_key_accepts_agent_app_scoped_token() { + let token = issue_agent_app_runtime_token_with_nonce( + "server-secret", + "content-factory-app", + chrono::Utc::now().timestamp() + 60, + "nonce-1", + ) + .unwrap(); + let headers = bearer_headers(&token); + + verify_model_generation_api_key(&headers, "server-secret") + .await + .unwrap(); + verify_model_generation_api_key_anthropic(&headers, "server-secret") + .await + .unwrap(); + } + + #[tokio::test] + async fn verify_model_generation_api_key_rejects_wrong_agent_app_scoped_token_secret() { + let token = issue_agent_app_runtime_token_with_nonce( + "other-secret", + "content-factory-app", + chrono::Utc::now().timestamp() + 60, + "nonce-1", + ) + .unwrap(); + let headers = bearer_headers(&token); + + assert!(verify_model_generation_api_key(&headers, "server-secret") + .await + .is_err()); + } +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index b9ea1179d..ec9a377c6 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1076,6 +1076,21 @@ pub fn run() { commands::capability_draft_cmd::capability_draft_list_registered_skills, commands::capability_draft_cmd::capability_draft_submit_approval_session_inputs, commands::capability_draft_cmd::capability_draft_execute_controlled_get, + // Agent App commands + commands::agent_app_cmd::agent_app_inspect_local_package, + commands::agent_app_cmd::agent_app_fetch_cloud_package, + commands::agent_app_cmd::agent_app_save_installed_state, + commands::agent_app_cmd::agent_app_list_installed, + commands::agent_app_cmd::agent_app_set_disabled, + commands::agent_app_cmd::agent_app_uninstall_rehearsal, + commands::agent_app_cmd::agent_app_uninstall, + commands::agent_app_cmd::agent_app_start_ui_runtime, + commands::agent_app_cmd::agent_app_get_ui_runtime_status, + commands::agent_app_cmd::agent_app_stop_ui_runtime, + commands::agent_app_runtime_cmd::agent_app_runtime_start_task, + commands::agent_app_runtime_cmd::agent_app_runtime_cancel_task, + commands::agent_app_runtime_cmd::agent_app_runtime_get_task, + commands::agent_app_runtime_cmd::agent_app_runtime_submit_host_response, // Skill Execution commands commands::skill_exec_cmd::execute_skill, commands::skill_exec_cmd::list_executable_skills, @@ -1085,16 +1100,6 @@ pub fn run() { commands::execution_run_cmd::execution_run_get, commands::execution_run_cmd::execution_run_get_general_workbench_state, commands::execution_run_cmd::execution_run_list_general_workbench_history, - // SceneApp commands - commands::sceneapp_cmd::sceneapp_list_catalog, - commands::sceneapp_cmd::sceneapp_get_descriptor, - commands::sceneapp_cmd::sceneapp_plan_launch, - commands::sceneapp_cmd::sceneapp_save_context_baseline, - commands::sceneapp_cmd::sceneapp_create_automation_job, - commands::sceneapp_cmd::sceneapp_list_runs, - commands::sceneapp_cmd::sceneapp_get_run_summary, - commands::sceneapp_cmd::sceneapp_prepare_run_governance_artifact, - commands::sceneapp_cmd::sceneapp_get_scorecard, // Agent Knowledge commands commands::knowledge_cmd::knowledge_import_source, commands::knowledge_cmd::knowledge_compile_pack, diff --git a/src-tauri/src/commands/agent_app_cmd.rs b/src-tauri/src/commands/agent_app_cmd.rs new file mode 100644 index 000000000..1db30ce61 --- /dev/null +++ b/src-tauri/src/commands/agent_app_cmd.rs @@ -0,0 +1,1829 @@ +//! Agent App 安装、package cache 与生命周期命令。 +//! +//! 这里不实现 Agent App 规范投影;投影和 readiness 继续由前端 current +//! Agent App 主链负责。本模块只提供受控文件读取、package fetch / staging +//! 与 installed state 持久化。 + +use crate::app::AppState; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::database::DbConnection; +use chrono::Utc; +use lime_core::agent_app_runtime_token::issue_agent_app_runtime_token; +use lime_core::config::Config; +use lime_core::database::dao::api_key_provider::{ + ApiKeyProvider, ApiProviderType, ProviderWithKeys, +}; +use lime_services::api_key_provider_service::ApiKeyProviderService; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::io::{self, Cursor}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use tokio::time::{sleep, Duration, Instant}; +use url::Url; +use zip::ZipArchive; + +const AGENT_APP_DATA_DIR: &str = "agent-apps"; +const INSTALLED_STATE_SCHEMA_VERSION: u32 = 1; +const AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS: u64 = 10; +const AGENT_APP_UI_RUNTIME_TOKEN_TTL_SECS: u64 = 12 * 60 * 60; + +static AGENT_APP_UI_RUNTIMES: Lazy>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppLocalPackageInspection { + pub source_kind: String, + pub source_uri: String, + pub app_dir: String, + pub app_markdown: String, + pub manifest: Value, + pub manifest_hash: String, + pub package_hash: String, + pub inspected_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppFetchCloudPackageRequest { + pub descriptor: AgentAppCloudReleaseDescriptor, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppCloudReleaseDescriptor { + pub source_uri: String, + pub app_id: String, + pub version: String, + pub release_id: Option, + pub tenant_id: Option, + pub tenant_enablement_ref: Option, + pub channel: Option, + pub package_url: String, + pub package_hash: String, + pub manifest_hash: String, + pub signature_ref: Option, + pub loaded_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppPackageCacheEntry { + pub app_id: String, + pub identity: AgentAppPackageIdentity, + pub manifest_snapshot: Value, + pub package_hash: String, + pub manifest_hash: String, + pub cache_path: String, + pub cached_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppPackageIdentity { + pub source_kind: String, + pub source_uri: String, + pub app_id: String, + pub app_version: String, + pub package_hash: String, + pub manifest_hash: String, + pub loaded_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub release_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_enablement_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature_ref: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveInstalledAgentAppStateRequest { + pub state: Value, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAgentAppDisabledRequest { + pub app_id: String, + pub disabled: bool, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUninstallRehearsalRequest { + pub app_id: String, + pub mode: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUninstallRequest { + pub app_id: String, + pub mode: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUiRuntimeStartRequest { + pub app_id: String, + pub entry_key: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUiRuntimeStatusRequest { + pub app_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUiRuntimeStopRequest { + pub app_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUiRuntimeStatus { + pub app_id: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub route: Option, +} + +#[derive(Debug, Clone)] +struct AgentAppUiRuntimeEntry { + entry_key: String, + route: String, +} + +#[derive(Debug)] +struct AgentAppUiRuntimeProcess { + child: Child, + app_dir: PathBuf, + port: u16, + base_url: String, + entry_key: String, + route: String, + started_at: String, +} + +#[derive(Debug, Clone, Default)] +struct AgentAppUiRuntimeEnv { + values: HashMap, +} + +#[derive(Debug, Clone)] +struct AgentAppModelBinding { + provider_id: String, + api_host: String, + protocol: String, + model: Option, + access_token: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledAgentAppStateListResult { + pub states: Vec, + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledAgentAppStatePersistenceIssue { + pub code: String, + pub path: String, + pub message: String, + pub app_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct InstalledAgentAppStateEnvelope { + schema_version: u32, + saved_at: String, + state: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUninstallRehearsalResult { + pub app_id: String, + pub mode: String, + pub generated_at: String, + pub deleted_target_count: usize, + pub retained_target_count: usize, + pub targets: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUninstallResult { + pub rehearsal: AgentAppUninstallRehearsalResult, + pub list: InstalledAgentAppStateListResult, + pub removed_target_count: usize, + pub missing_target_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppUninstallRehearsalTarget { + pub kind: String, + pub value: String, + pub safe_to_delete: bool, + pub action: String, + pub reason: String, +} + +#[tauri::command] +pub async fn agent_app_inspect_local_package( + app_dir: String, +) -> Result { + let app_dir_path = canonicalize_existing_dir(&app_dir)?; + let app_markdown_path = app_dir_path.join("APP.md"); + let app_markdown = fs::read_to_string(&app_markdown_path) + .map_err(|error| format!("读取 Agent App APP.md 失败: {error}"))?; + let manifest = parse_app_markdown_frontmatter(&app_markdown)?; + let inspected_at = now_iso(); + let manifest_hash = sha256_json_value(&manifest)?; + let package_hash = sha256_package(&app_dir_path, &manifest)?; + + Ok(AgentAppLocalPackageInspection { + source_kind: "local_folder".to_string(), + source_uri: app_dir_path.to_string_lossy().to_string(), + app_dir: app_dir_path.to_string_lossy().to_string(), + app_markdown, + manifest, + manifest_hash, + package_hash, + inspected_at, + }) +} + +#[tauri::command] +pub async fn agent_app_fetch_cloud_package( + request: AgentAppFetchCloudPackageRequest, +) -> Result { + let descriptor = request.descriptor; + validate_cloud_release_descriptor(&descriptor)?; + let bytes = download_agent_app_package(&descriptor.package_url).await?; + let actual_package_hash = sha256_prefixed(&bytes); + if actual_package_hash != descriptor.package_hash { + return Err(format!( + "Agent App package hash mismatch for {}@{}: expected {}, got {}", + descriptor.app_id, descriptor.version, descriptor.package_hash, actual_package_hash + )); + } + + let data_root = agent_app_data_dir()?; + let cache_dir = package_cache_dir(&descriptor.package_hash)?; + let staging_dir = data_root.join("staging").join(format!( + "{}-{}", + descriptor.app_id, + safe_hash_path_segment(&descriptor.package_hash) + )); + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir).map_err(|error| { + format!( + "清理 Agent App package staging 目录失败 {}: {error}", + staging_dir.display() + ) + })?; + } + fs::create_dir_all(&staging_dir) + .map_err(|error| format!("创建 Agent App package staging 目录失败: {error}"))?; + let staging_cleanup_dir = staging_dir.clone(); + scopeguard::defer! { + if staging_cleanup_dir.exists() { + let _ = fs::remove_dir_all(&staging_cleanup_dir); + } + } + + extract_agent_app_package_archive(&bytes, &staging_dir)?; + let extracted_root = find_agent_app_package_root(&staging_dir)?; + let app_markdown_path = extracted_root.join("APP.md"); + let app_markdown_bytes = fs::read(&app_markdown_path).map_err(|error| { + format!( + "读取 Agent App package APP.md 失败 {}: {error}", + app_markdown_path.display() + ) + })?; + let actual_manifest_hash = sha256_prefixed(&app_markdown_bytes); + if actual_manifest_hash != descriptor.manifest_hash { + return Err(format!( + "Agent App manifest hash mismatch for {}@{}: expected {}, got {}", + descriptor.app_id, descriptor.version, descriptor.manifest_hash, actual_manifest_hash + )); + } + let app_markdown = String::from_utf8(app_markdown_bytes) + .map_err(|error| format!("Agent App APP.md 必须是 UTF-8: {error}"))?; + let manifest = parse_app_markdown_frontmatter(&app_markdown)?; + ensure_manifest_matches_cloud_release(&manifest, &descriptor)?; + + if cache_dir.exists() { + fs::remove_dir_all(&cache_dir).map_err(|error| { + format!( + "清理旧 Agent App package cache 目录失败 {}: {error}", + cache_dir.display() + ) + })?; + } + if let Some(parent) = cache_dir.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent App package cache 目录失败 {}: {error}", + parent.display() + ) + })?; + } + fs::rename(&extracted_root, &cache_dir).map_err(|error| { + format!( + "写入 Agent App package cache 失败 {} -> {}: {error}", + extracted_root.display(), + cache_dir.display() + ) + })?; + + let cached_at = now_iso(); + Ok(AgentAppPackageCacheEntry { + app_id: descriptor.app_id.clone(), + identity: AgentAppPackageIdentity { + source_kind: "cloud_release".to_string(), + source_uri: descriptor.source_uri.clone(), + app_id: descriptor.app_id.clone(), + app_version: descriptor.version.clone(), + package_hash: descriptor.package_hash.clone(), + manifest_hash: descriptor.manifest_hash.clone(), + loaded_at: descriptor.loaded_at.clone(), + release_id: descriptor.release_id.clone(), + tenant_id: descriptor.tenant_id.clone(), + tenant_enablement_ref: descriptor.tenant_enablement_ref.clone(), + channel: descriptor.channel.clone(), + signature_ref: descriptor.signature_ref.clone(), + }, + manifest_snapshot: manifest, + package_hash: descriptor.package_hash, + manifest_hash: descriptor.manifest_hash, + cache_path: cache_dir.to_string_lossy().to_string(), + cached_at, + }) +} + +#[tauri::command] +pub async fn agent_app_save_installed_state( + request: SaveInstalledAgentAppStateRequest, +) -> Result { + let app_id = read_state_app_id(&request.state)?; + validate_safe_app_id(&app_id)?; + let saved_at = now_iso(); + write_installed_state(&app_id, &request.state, &saved_at)?; + Ok(request.state) +} + +#[tauri::command] +pub async fn agent_app_list_installed() -> Result { + let installed_dir = installed_dir()?; + fs::create_dir_all(&installed_dir) + .map_err(|error| format!("创建 Agent App installed 目录失败: {error}"))?; + + let mut states = Vec::new(); + let mut issues = Vec::new(); + let entries = fs::read_dir(&installed_dir) + .map_err(|error| format!("读取 Agent App installed 目录失败: {error}"))?; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + issues.push(issue( + "READ_FAILED", + installed_dir.to_string_lossy(), + format!("读取 installed 条目失败: {error}"), + None, + )); + continue; + } + }; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + match read_installed_state_path(&path) { + Ok(Some(state)) => states.push(state), + Ok(None) => {} + Err(error) => issues.push(issue("PARSE_FAILED", path.to_string_lossy(), error, None)), + } + } + states.sort_by(|left, right| { + read_string(left, &["appId"]) + .unwrap_or_default() + .cmp(&read_string(right, &["appId"]).unwrap_or_default()) + }); + + Ok(InstalledAgentAppStateListResult { states, issues }) +} + +#[tauri::command] +pub async fn agent_app_set_disabled( + request: SetAgentAppDisabledRequest, +) -> Result { + validate_safe_app_id(&request.app_id)?; + let path = installed_state_path(&request.app_id)?; + let Some(mut state) = read_installed_state_path(&path)? else { + return Err(format!("Agent App 未安装: {}", request.app_id)); + }; + let updated_at = request.updated_at.unwrap_or_else(now_iso); + set_object_field(&mut state, "disabled", Value::Bool(request.disabled))?; + set_object_field(&mut state, "updatedAt", Value::String(updated_at.clone()))?; + write_installed_state(&request.app_id, &state, &updated_at)?; + agent_app_list_installed().await +} + +#[tauri::command] +pub async fn agent_app_uninstall_rehearsal( + request: AgentAppUninstallRehearsalRequest, +) -> Result { + build_agent_app_uninstall_rehearsal(request.app_id, request.mode) +} + +#[tauri::command] +pub async fn agent_app_uninstall( + request: AgentAppUninstallRequest, +) -> Result { + let rehearsal = build_agent_app_uninstall_rehearsal(request.app_id, request.mode)?; + + // P17.3 只允许卸载演练;真实 delete-data 需要后续路线图单独打开。 + Ok(AgentAppUninstallResult { + rehearsal, + list: agent_app_list_installed().await?, + removed_target_count: 0, + missing_target_count: 0, + }) +} + +#[tauri::command] +pub async fn agent_app_start_ui_runtime( + request: AgentAppUiRuntimeStartRequest, + state: tauri::State<'_, AppState>, + db: tauri::State<'_, DbConnection>, + api_key_provider_service: tauri::State<'_, ApiKeyProviderServiceState>, +) -> Result { + let config = { state.read().await.config.clone() }; + let runtime_env = build_agent_app_ui_runtime_env( + &request.app_id, + &config, + Some(&db), + Some(api_key_provider_service.0.as_ref()), + ); + start_agent_app_ui_runtime_with_env(request, runtime_env).await +} + +pub async fn agent_app_start_ui_runtime_for_dev_bridge( + request: AgentAppUiRuntimeStartRequest, + config: &Config, + db: Option<&DbConnection>, + api_key_provider_service: Option<&ApiKeyProviderService>, +) -> Result { + let runtime_env = + build_agent_app_ui_runtime_env(&request.app_id, config, db, api_key_provider_service); + start_agent_app_ui_runtime_with_env(request, runtime_env).await +} + +async fn start_agent_app_ui_runtime_with_env( + request: AgentAppUiRuntimeStartRequest, + runtime_env: AgentAppUiRuntimeEnv, +) -> Result { + validate_safe_app_id(&request.app_id)?; + let state = read_installed_agent_app_state(&request.app_id)?; + let app_dir = resolve_agent_app_runtime_dir(&state)?; + ensure_agent_app_runtime_folder(&app_dir)?; + let entry = resolve_ui_runtime_entry(&state, request.entry_key.as_deref())?; + + if let Some(status) = running_runtime_status(&request.app_id, Some(&entry))? { + return Ok(status); + } + + let port = reserve_local_port()?; + let base_url = format!("http://127.0.0.1:{port}"); + let mut child = spawn_agent_app_ui_process(&app_dir, port, &runtime_env)?; + wait_for_agent_app_ui_runtime_ready(&mut child, &base_url).await?; + let pid = child.id(); + let process = AgentAppUiRuntimeProcess { + child, + app_dir, + port, + base_url: base_url.clone(), + entry_key: entry.entry_key.clone(), + route: entry.route.clone(), + started_at: now_iso(), + }; + let mut registry = runtime_registry()?; + registry.insert(request.app_id.clone(), process); + + Ok(AgentAppUiRuntimeStatus { + app_id: request.app_id, + status: "running".to_string(), + base_url: Some(base_url.clone()), + entry_url: Some(join_runtime_url(&base_url, &entry.route)), + port: Some(port), + pid: Some(pid), + message: None, + entry_key: Some(entry.entry_key), + route: Some(entry.route), + }) +} + +#[tauri::command] +pub async fn agent_app_get_ui_runtime_status( + request: AgentAppUiRuntimeStatusRequest, +) -> Result { + validate_safe_app_id(&request.app_id)?; + if let Some(status) = running_runtime_status(&request.app_id, None)? { + return Ok(status); + } + Ok(AgentAppUiRuntimeStatus { + app_id: request.app_id, + status: "stopped".to_string(), + base_url: None, + entry_url: None, + port: None, + pid: None, + message: Some("Agent App UI runtime 未启动。".to_string()), + entry_key: None, + route: None, + }) +} + +#[tauri::command] +pub async fn agent_app_stop_ui_runtime( + request: AgentAppUiRuntimeStopRequest, +) -> Result { + validate_safe_app_id(&request.app_id)?; + let mut registry = runtime_registry()?; + let Some(mut process) = registry.remove(&request.app_id) else { + return Ok(AgentAppUiRuntimeStatus { + app_id: request.app_id, + status: "stopped".to_string(), + base_url: None, + entry_url: None, + port: None, + pid: None, + message: Some("Agent App UI runtime 未启动。".to_string()), + entry_key: None, + route: None, + }); + }; + drop(registry); + + let pid = process.child.id(); + let _ = process.child.kill(); + let _ = process.child.wait(); + + Ok(AgentAppUiRuntimeStatus { + app_id: request.app_id, + status: "stopped".to_string(), + base_url: Some(process.base_url), + entry_url: None, + port: Some(process.port), + pid: Some(pid), + message: Some("Agent App UI runtime 已停止。".to_string()), + entry_key: Some(process.entry_key), + route: Some(process.route), + }) +} + +fn build_agent_app_uninstall_rehearsal( + app_id: String, + mode: String, +) -> Result { + validate_safe_app_id(&app_id)?; + let mode = match mode.as_str() { + "keep-data" | "delete-data" => mode, + other => return Err(format!("不支持的 Agent App 卸载演练模式: {other}")), + }; + let path = installed_state_path(&app_id)?; + let Some(state) = read_installed_state_path(&path)? else { + return Err(format!("Agent App 未安装: {}", app_id)); + }; + + let package_hash = read_string(&state, &["identity", "packageHash"]) + .unwrap_or_else(|| "unknown-package".to_string()); + let storage_namespace = read_string(&state, &["projection", "storage", "namespace"]) + .unwrap_or_else(|| app_id.clone()); + let base = agent_app_data_dir()?.to_string_lossy().to_string(); + + let mut targets = vec![ + target( + "path", + format!("{base}/installed/{app_id}.json"), + true, + "delete", + "Installed Agent App state snapshot.", + ), + target( + "path", + format!("{base}/setup/{app_id}.json"), + true, + "delete", + "Installed setup binding state.", + ), + target( + "path", + format!("{base}/packages/{package_hash}"), + true, + "delete", + "Cached runtime package for this Agent App.", + ), + target( + "path", + format!("{base}/package-index/{app_id}.json"), + true, + "delete", + "Package cache index.", + ), + target( + "path", + format!("{base}/projections/{app_id}.json"), + true, + "delete", + "Generated projection snapshot.", + ), + target( + "path", + format!("{base}/readiness/{app_id}.json"), + true, + "delete", + "Readiness snapshot.", + ), + target( + "path", + format!("{base}/logs/{app_id}"), + true, + "delete", + "Agent App host logs.", + ), + ]; + let data_action = if mode == "delete-data" { + "delete" + } else { + "retain" + }; + targets.push(target( + "namespace", + format!("{base}/storage/{storage_namespace}"), + true, + data_action, + "App storage namespace declared by manifest.", + )); + targets.push(target( + "path", + format!("{base}/exports/{app_id}"), + true, + data_action, + "Optional user exports for this Agent App.", + )); + + let deleted_target_count = targets + .iter() + .filter(|target| target.action == "delete") + .count(); + let retained_target_count = targets + .iter() + .filter(|target| target.action == "retain") + .count(); + + Ok(AgentAppUninstallRehearsalResult { + app_id, + mode, + generated_at: now_iso(), + deleted_target_count, + retained_target_count, + targets, + warnings: vec!["DRY_RUN_ONLY".to_string()], + }) +} + +fn read_installed_agent_app_state(app_id: &str) -> Result { + let path = installed_state_path(app_id)?; + read_installed_state_path(&path)?.ok_or_else(|| format!("Agent App 未安装: {app_id}")) +} + +fn validate_cloud_release_descriptor( + descriptor: &AgentAppCloudReleaseDescriptor, +) -> Result<(), String> { + validate_safe_app_id(&descriptor.app_id)?; + let url = Url::parse(&descriptor.package_url) + .map_err(|error| format!("Agent App packageUrl 非法: {error}"))?; + if url.scheme() != "https" { + return Err("Agent App packageUrl 必须使用 https。".to_string()); + } + if descriptor.source_uri != descriptor.package_url { + return Err("Agent App release descriptor sourceUri 必须等于 packageUrl。".to_string()); + } + validate_sha256_hash("packageHash", &descriptor.package_hash)?; + validate_sha256_hash("manifestHash", &descriptor.manifest_hash)?; + Ok(()) +} + +fn validate_sha256_hash(field: &str, value: &str) -> Result<(), String> { + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(format!("Agent App {field} 必须使用 sha256:<64 hex> 格式。")); + }; + if hex.len() == 64 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Ok(()); + } + Err(format!("Agent App {field} 必须使用 sha256:<64 hex> 格式。")) +} + +async fn download_agent_app_package(package_url: &str) -> Result, String> { + let response = reqwest::get(package_url) + .await + .map_err(|error| format!("下载 Agent App package 失败: {error}"))?; + if !response.status().is_success() { + return Err(format!( + "下载 Agent App package 失败,HTTP 状态: {}", + response.status() + )); + } + let bytes = response + .bytes() + .await + .map_err(|error| format!("读取 Agent App package 响应失败: {error}"))?; + Ok(bytes.to_vec()) +} + +fn extract_agent_app_package_archive(bytes: &[u8], staging_dir: &Path) -> Result<(), String> { + let reader = Cursor::new(bytes); + let mut archive = ZipArchive::new(reader) + .map_err(|error| format!("Agent App package 必须是 zip/lapp 格式: {error}"))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|error| format!("读取 Agent App package 条目失败: {error}"))?; + let enclosed = file + .enclosed_name() + .map(PathBuf::from) + .ok_or_else(|| format!("Agent App package 包含不安全路径: {}", file.name()))?; + let out_path = staging_dir.join(enclosed); + if file.name().ends_with('/') { + fs::create_dir_all(&out_path).map_err(|error| { + format!( + "创建 Agent App package 目录失败 {}: {error}", + out_path.display() + ) + })?; + continue; + } + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent App package 父目录失败 {}: {error}", + parent.display() + ) + })?; + } + let mut output = fs::File::create(&out_path).map_err(|error| { + format!( + "写入 Agent App package 文件失败 {}: {error}", + out_path.display() + ) + })?; + io::copy(&mut file, &mut output).map_err(|error| { + format!( + "解压 Agent App package 文件失败 {}: {error}", + out_path.display() + ) + })?; + } + Ok(()) +} + +fn find_agent_app_package_root(staging_dir: &Path) -> Result { + if staging_dir.join("APP.md").is_file() { + return Ok(staging_dir.to_path_buf()); + } + let mut matches = Vec::new(); + collect_agent_app_roots(staging_dir, &mut matches)?; + matches.sort(); + matches.dedup(); + match matches.len() { + 0 => Err("Agent App package 缺少 APP.md。".to_string()), + 1 => Ok(matches.remove(0)), + _ => Err("Agent App package 包含多个 APP.md,无法确定 package root。".to_string()), + } +} + +fn collect_agent_app_roots(dir: &Path, matches: &mut Vec) -> Result<(), String> { + for entry in fs::read_dir(dir) + .map_err(|error| format!("读取 Agent App package 目录失败 {}: {error}", dir.display()))? + { + let entry = entry.map_err(|error| format!("读取 Agent App package 条目失败: {error}"))?; + let path = entry.path(); + if path.is_dir() { + if path.join("APP.md").is_file() { + matches.push(path.clone()); + } + collect_agent_app_roots(&path, matches)?; + } + } + Ok(()) +} + +fn ensure_manifest_matches_cloud_release( + manifest: &Value, + descriptor: &AgentAppCloudReleaseDescriptor, +) -> Result<(), String> { + let manifest_app_id = + read_string(manifest, &["name"]).or_else(|| read_string(manifest, &["appId"])); + if manifest_app_id.as_deref() != Some(descriptor.app_id.as_str()) { + return Err(format!( + "Agent App package manifest appId 与 release descriptor 不一致: expected {}", + descriptor.app_id + )); + } + let manifest_version = read_string(manifest, &["version"]); + if manifest_version.as_deref() != Some(descriptor.version.as_str()) { + return Err(format!( + "Agent App package manifest version 与 release descriptor 不一致: expected {}", + descriptor.version + )); + } + Ok(()) +} + +fn resolve_agent_app_runtime_dir(state: &Value) -> Result { + let source_kind = read_string(state, &["identity", "sourceKind"]) + .ok_or_else(|| "Installed Agent App state 缺少 identity.sourceKind。".to_string())?; + let app_id = read_state_app_id(state)?; + if source_kind == "local_folder" { + let source_uri = read_string(state, &["identity", "sourceUri"]) + .ok_or_else(|| "Installed Agent App state 缺少 identity.sourceUri。".to_string())?; + let app_dir = canonicalize_existing_dir(&source_uri)?; + ensure_agent_app_dir_matches_state(&app_dir, state)?; + return Ok(app_dir); + } + + if let Some(app_dir) = resolve_cached_agent_app_runtime_dir(state)? { + return Ok(app_dir); + } + if let Some(app_dir) = resolve_configured_agent_app_runtime_dir(state)? { + return Ok(app_dir); + } + if let Some(app_dir) = resolve_dev_agent_app_runtime_dir(state)? { + return Ok(app_dir); + } + + Err(format!( + "Agent App UI runtime 未找到 {app_id} 的本地 runtime package;当前来源是 {source_kind}。请先完成云端包下载/解包,或从本地 APP.md 目录重新安装该 App。" + )) +} + +fn resolve_cached_agent_app_runtime_dir(state: &Value) -> Result, String> { + let app_id = read_state_app_id(state)?; + let mut candidates = Vec::new(); + let data_root = agent_app_data_dir()?; + candidates.push(data_root.join("packages").join(&app_id)); + candidates.push(data_root.join("staging").join(&app_id)); + if let Some(package_hash) = read_string(state, &["identity", "packageHash"]) { + candidates.push(data_root.join("packages").join(&package_hash)); + candidates.push( + data_root + .join("packages") + .join(package_hash.replace(':', "_")), + ); + } + find_matching_agent_app_dir(candidates, state) +} + +fn resolve_configured_agent_app_runtime_dir(state: &Value) -> Result, String> { + let app_id = read_state_app_id(state)?; + let Some(raw_roots) = std::env::var_os("LIME_AGENT_APP_LOCAL_ROOTS") else { + return Ok(None); + }; + let mut candidates = Vec::new(); + for root in std::env::split_paths(&raw_roots) { + candidates.push(root.clone()); + candidates.push(root.join(&app_id)); + } + find_matching_agent_app_dir(candidates, state) +} + +fn resolve_dev_agent_app_runtime_dir(state: &Value) -> Result, String> { + let app_id = read_state_app_id(state)?; + let mut candidates = Vec::new(); + if let Ok(current_dir) = std::env::current_dir() { + push_dev_agent_app_candidates(&mut candidates, ¤t_dir, &app_id); + } + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + push_dev_agent_app_candidates(&mut candidates, parent, &app_id); + } + } + find_matching_agent_app_dir(candidates, state) +} + +fn push_dev_agent_app_candidates(candidates: &mut Vec, start: &Path, app_id: &str) { + for ancestor in start.ancestors().take(8) { + candidates.push(ancestor.join("limecloud").join(app_id)); + candidates.push(ancestor.join("limecloud").join("apps").join(app_id)); + candidates.push(ancestor.join(app_id)); + } +} + +fn find_matching_agent_app_dir( + candidates: Vec, + state: &Value, +) -> Result, String> { + for candidate in candidates { + let Ok(canonical) = fs::canonicalize(&candidate) else { + continue; + }; + if !canonical.is_dir() { + continue; + } + if ensure_agent_app_dir_matches_state(&canonical, state).is_ok() { + return Ok(Some(canonical)); + } + } + Ok(None) +} + +fn ensure_agent_app_dir_matches_state(app_dir: &Path, state: &Value) -> Result<(), String> { + ensure_agent_app_runtime_folder(app_dir)?; + let app_id = read_state_app_id(state)?; + let manifest_path = app_dir.join("APP.md"); + let app_markdown = fs::read_to_string(&manifest_path).map_err(|error| { + format!( + "读取 Agent App APP.md 失败 {}: {error}", + manifest_path.display() + ) + })?; + let manifest = parse_app_markdown_frontmatter(&app_markdown)?; + let manifest_app_id = + read_string(&manifest, &["name"]).or_else(|| read_string(&manifest, &["appId"])); + if manifest_app_id.as_deref() != Some(app_id.as_str()) { + return Err(format!( + "Agent App 本地目录 {} 的 manifest name 与已安装 appId 不一致。", + app_dir.display() + )); + } + Ok(()) +} + +fn ensure_agent_app_runtime_folder(app_dir: &Path) -> Result<(), String> { + let app_markdown = app_dir.join("APP.md"); + if !app_markdown.is_file() { + return Err(format!( + "Agent App 本地目录缺少 APP.md: {}", + app_markdown.display() + )); + } + let package_json = app_dir.join("package.json"); + if !package_json.is_file() { + return Err(format!( + "Agent App 本地 UI runtime 缺少 package.json: {}", + package_json.display() + )); + } + Ok(()) +} + +fn resolve_ui_runtime_entry( + state: &Value, + requested_entry_key: Option<&str>, +) -> Result { + let entries = state + .get("projection") + .and_then(|value| value.get("entries")) + .and_then(|value| value.as_array()) + .ok_or_else(|| "Installed Agent App state 缺少 projection.entries。".to_string())?; + + let selected = if let Some(entry_key) = requested_entry_key { + entries + .iter() + .find(|entry| read_string(entry, &["key"]).as_deref() == Some(entry_key)) + .ok_or_else(|| format!("Agent App UI entry 不存在: {entry_key}"))? + } else { + entries + .iter() + .find(|entry| read_string(entry, &["key"]).as_deref() == Some("dashboard")) + .or_else(|| entries.iter().find(|entry| is_ui_runtime_entry(entry))) + .or_else(|| entries.first()) + .ok_or_else(|| "Agent App manifest 没有声明可打开入口。".to_string())? + }; + + if !is_ui_runtime_entry(selected) { + let entry_key = read_string(selected, &["key"]).unwrap_or_else(|| "unknown".to_string()); + return Err(format!( + "Agent App entry 不是可嵌入 UI 页面入口: {entry_key}" + )); + } + + let entry_key = + read_string(selected, &["key"]).ok_or_else(|| "Agent App entry 缺少 key。".to_string())?; + let route = normalize_runtime_route( + read_string(selected, &["route"]) + .unwrap_or_else(|| format!("/{entry_key}")) + .as_str(), + )?; + Ok(AgentAppUiRuntimeEntry { entry_key, route }) +} + +fn is_ui_runtime_entry(entry: &Value) -> bool { + matches!( + read_string(entry, &["kind"]).as_deref(), + Some("page" | "panel" | "settings") + ) +} + +fn normalize_runtime_route(route: &str) -> Result { + let trimmed = route.trim(); + if trimmed.is_empty() { + return Ok("/".to_string()); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return Err("Agent App UI entry route 必须是本地 runtime 相对路径。".to_string()); + } + if trimmed.starts_with('/') { + return Ok(trimmed.to_string()); + } + Ok(format!("/{trimmed}")) +} + +fn reserve_local_port() -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|error| format!("分配 Agent App UI runtime 端口失败: {error}"))?; + listener + .local_addr() + .map(|addr| addr.port()) + .map_err(|error| format!("读取 Agent App UI runtime 端口失败: {error}")) +} + +fn build_agent_app_ui_runtime_env( + app_id: &str, + config: &Config, + db: Option<&DbConnection>, + api_key_provider_service: Option<&ApiKeyProviderService>, +) -> AgentAppUiRuntimeEnv { + let mut env = AgentAppUiRuntimeEnv::default(); + let Some(binding) = + resolve_agent_app_model_binding(config, db, api_key_provider_service, app_id) + else { + return env; + }; + + insert_runtime_env(&mut env, "LIME_GATEWAY_PROVIDER", &binding.provider_id); + insert_runtime_env(&mut env, "LIME_GATEWAY_BASE", &binding.api_host); + insert_runtime_env(&mut env, "LIME_GATEWAY_PROTOCOL", &binding.protocol); + if let Some(model) = binding.model.as_deref() { + insert_runtime_env(&mut env, "LIME_MODEL", model); + } + if let Some(access_token) = binding.access_token.as_deref() { + insert_runtime_env(&mut env, "LIME_ACCESS_TOKEN", access_token); + insert_runtime_env(&mut env, "OPENAI_API_KEY", access_token); + } + insert_runtime_env(&mut env, "OPENAI_BASE_URL", &binding.api_host); + + env +} + +fn insert_runtime_env(env: &mut AgentAppUiRuntimeEnv, key: &str, value: &str) { + let trimmed = value.trim(); + if trimmed.is_empty() { + return; + } + env.values.insert(key.to_string(), trimmed.to_string()); +} + +fn process_env_has_value(key: &str) -> bool { + std::env::var(key) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) +} + +fn resolve_agent_app_model_binding( + config: &Config, + db: Option<&DbConnection>, + api_key_provider_service: Option<&ApiKeyProviderService>, + app_id: &str, +) -> Option { + let service = api_key_provider_service?; + let db = db?; + let providers = match service.get_all_providers(db) { + Ok(providers) => providers, + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 读取 Provider 配置失败,Agent App 将不注入 Lime Gateway 生成能力: {error}" + ); + return None; + } + }; + let preferred_provider_id = preferred_generation_provider_id(config); + let preferred_model = preferred_generation_model_id(config); + + let provider = preferred_provider_id + .as_deref() + .and_then(|provider_id| { + providers.iter().find(|candidate| { + candidate.provider.enabled + && (candidate.provider.id == provider_id + || candidate.provider.provider_type.to_string() == provider_id) + && provider_can_supply_agent_app_runtime(candidate, preferred_model.as_deref()) + }) + }) + .or_else(|| { + providers.iter().find(|candidate| { + provider_can_supply_agent_app_runtime(candidate, preferred_model.as_deref()) + }) + })?; + + Some(build_agent_app_model_binding( + &provider.provider, + local_gateway_base_url(config), + issue_agent_app_model_generation_access_token(config, app_id), + preferred_model.or_else(|| first_provider_model(&provider.provider)), + )) +} + +fn issue_agent_app_model_generation_access_token(config: &Config, app_id: &str) -> Option { + let secret = non_empty_string(Some(config.server.api_key.as_str()))?; + match issue_agent_app_runtime_token(&secret, app_id, AGENT_APP_UI_RUNTIME_TOKEN_TTL_SECS) { + Ok(token) => Some(token), + Err(error) => { + tracing::warn!( + "[AgentAppRuntime] 生成 Agent App scoped model-generation token 失败,app_id={app_id}: {error}" + ); + None + } + } +} + +fn preferred_generation_provider_id(config: &Config) -> Option { + let generation = &config.workspace_preferences.service_models.generation_topic; + non_empty_string(generation.preferred_provider_id.as_deref()) +} + +fn preferred_generation_model_id(config: &Config) -> Option { + let generation = &config.workspace_preferences.service_models.generation_topic; + if !generation.enabled { + return None; + } + non_empty_string(generation.preferred_model_id.as_deref()) +} + +fn provider_can_supply_agent_app_runtime( + provider: &ProviderWithKeys, + preferred_model: Option<&str>, +) -> bool { + if !provider.provider.enabled { + return false; + } + let has_model = preferred_model + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + || first_provider_model(&provider.provider).is_some() + || process_env_has_value("LIME_MODEL"); + let has_credential = provider.provider.provider_type == ApiProviderType::Ollama + || provider.api_keys.iter().any(|key| key.enabled); + has_model && has_credential +} + +fn build_agent_app_model_binding( + provider: &ApiKeyProvider, + gateway_base: String, + access_token: Option, + model: Option, +) -> AgentAppModelBinding { + AgentAppModelBinding { + provider_id: provider.id.clone(), + api_host: gateway_base, + protocol: agent_app_protocol_for_provider(provider), + model: model.and_then(|value| non_empty_string(Some(value.as_str()))), + access_token: access_token.and_then(|value| non_empty_string(Some(value.as_str()))), + } +} + +fn local_gateway_base_url(config: &Config) -> String { + let scheme = if config.server.tls.enable { + "https" + } else { + "http" + }; + let host = match config.server.host.trim() { + "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1", + value => value, + }; + format!("{scheme}://{host}:{}", config.server.port) +} + +fn agent_app_protocol_for_provider(_provider: &ApiKeyProvider) -> String { + // Agent App 只拿到 Lime 本机 Gateway 的 scoped API key;上游 Provider 协议由 Lime Gateway 继续负责。 + "openai_chat".to_string() +} + +fn first_provider_model(provider: &ApiKeyProvider) -> Option { + provider + .custom_models + .iter() + .find_map(|model| non_empty_string(Some(model.as_str()))) +} + +fn non_empty_string(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn spawn_agent_app_ui_process( + app_dir: &Path, + port: u16, + runtime_env: &AgentAppUiRuntimeEnv, +) -> Result { + let binaries: &[&str] = if cfg!(windows) { + &["npm.cmd", "npm"] + } else { + &["npm"] + }; + let mut last_error = None; + for binary in binaries { + let mut command = Command::new(binary); + command + .args(["run", "dev", "--silent"]) + .current_dir(app_dir) + .env("PORT", port.to_string()); + for key in inherited_agent_app_secret_env_keys() { + command.env_remove(key); + } + command.envs(&runtime_env.values); + match command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => return Ok(child), + Err(error) => last_error = Some(format!("{binary}: {error}")), + } + } + Err(format!( + "启动 Agent App UI runtime 失败,请确认已安装 Node.js/npm: {}", + last_error.unwrap_or_else(|| "npm 不可用".to_string()) + )) +} + +fn inherited_agent_app_secret_env_keys() -> &'static [&'static str] { + &[ + "LIME_ACCESS_TOKEN", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "DEEPSEEK_API_KEY", + "OPENROUTER_API_KEY", + "MISTRAL_API_KEY", + "XAI_API_KEY", + "DASHSCOPE_API_KEY", + "MOONSHOT_API_KEY", + "ZHIPUAI_API_KEY", + "GROQ_API_KEY", + "FAL_KEY", + ] +} + +async fn wait_for_agent_app_ui_runtime_ready( + child: &mut Child, + base_url: &str, +) -> Result<(), String> { + let health_url = format!("{base_url}/api/bootstrap"); + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(800)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let deadline = Instant::now() + Duration::from_secs(AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS); + loop { + match child.try_wait() { + Ok(Some(status)) => { + return Err(format!("Agent App UI runtime 启动后退出: {status}")); + } + Ok(None) => {} + Err(error) => { + return Err(format!("检查 Agent App UI runtime 进程状态失败: {error}")); + } + } + + if let Ok(response) = client.get(&health_url).send().await { + if response.status().is_success() { + return Ok(()); + } + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Agent App UI runtime 未在 {} 秒内就绪: {health_url}", + AGENT_APP_UI_RUNTIME_STARTUP_TIMEOUT_SECS + )); + } + sleep(Duration::from_millis(250)).await; + } +} + +fn running_runtime_status( + app_id: &str, + entry: Option<&AgentAppUiRuntimeEntry>, +) -> Result, String> { + let mut registry = runtime_registry()?; + let Some(process) = registry.get_mut(app_id) else { + return Ok(None); + }; + let pid = process.child.id(); + let mut remove_runtime = false; + let status = match process.child.try_wait() { + Ok(None) => { + if let Some(entry) = entry { + process.entry_key = entry.entry_key.clone(); + process.route = entry.route.clone(); + } + let route = process.route.clone(); + let base_url = process.base_url.clone(); + AgentAppUiRuntimeStatus { + app_id: app_id.to_string(), + status: "running".to_string(), + base_url: Some(base_url.clone()), + entry_url: Some(join_runtime_url(&base_url, &route)), + port: Some(process.port), + pid: Some(pid), + message: Some(format!( + "Agent App UI runtime 已运行,启动时间 {},目录 {}。", + process.started_at, + process.app_dir.display() + )), + entry_key: Some(process.entry_key.clone()), + route: Some(route), + } + } + Ok(Some(status)) => { + remove_runtime = true; + AgentAppUiRuntimeStatus { + app_id: app_id.to_string(), + status: "failed".to_string(), + base_url: None, + entry_url: None, + port: None, + pid: Some(pid), + message: Some(format!("Agent App UI runtime 已退出: {status}")), + entry_key: None, + route: None, + } + } + Err(error) => { + remove_runtime = true; + AgentAppUiRuntimeStatus { + app_id: app_id.to_string(), + status: "failed".to_string(), + base_url: None, + entry_url: None, + port: None, + pid: Some(pid), + message: Some(format!("读取 Agent App UI runtime 状态失败: {error}")), + entry_key: None, + route: None, + } + } + }; + if remove_runtime { + registry.remove(app_id); + } + Ok(Some(status)) +} + +fn runtime_registry( +) -> Result>, String> { + AGENT_APP_UI_RUNTIMES + .lock() + .map_err(|_| "Agent App UI runtime 状态锁已损坏。".to_string()) +} + +fn join_runtime_url(base_url: &str, route: &str) -> String { + if route == "/" { + return format!("{base_url}/"); + } + format!("{base_url}{route}") +} + +fn canonicalize_existing_dir(value: &str) -> Result { + let path = PathBuf::from(value); + let canonical = fs::canonicalize(&path) + .map_err(|error| format!("无法解析 Agent App 目录 {}: {error}", path.display()))?; + if !canonical.is_dir() { + return Err(format!("Agent App 路径不是目录: {}", canonical.display())); + } + Ok(canonical) +} + +fn parse_app_markdown_frontmatter(markdown: &str) -> Result { + let normalized = markdown.strip_prefix('\u{feff}').unwrap_or(markdown); + let Some(rest) = normalized.strip_prefix("---") else { + return Err("Agent App APP.md 缺少 YAML frontmatter。".to_string()); + }; + let rest = rest + .strip_prefix('\n') + .or_else(|| rest.strip_prefix("\r\n")) + .unwrap_or(rest); + let Some(end_index) = rest.find("\n---") else { + return Err("Agent App APP.md frontmatter 未正确结束。".to_string()); + }; + let frontmatter = &rest[..end_index]; + let yaml_value: serde_yaml::Value = serde_yaml::from_str(frontmatter) + .map_err(|error| format!("解析 Agent App frontmatter 失败: {error}"))?; + serde_json::to_value(yaml_value) + .map_err(|error| format!("转换 Agent App manifest 失败: {error}")) +} + +fn sha256_json_value(value: &Value) -> Result { + let bytes = + serde_json::to_vec(value).map_err(|error| format!("序列化 manifest 失败: {error}"))?; + Ok(format!("sha256:{}", sha256_hex(&bytes))) +} + +fn sha256_package(app_dir: &Path, manifest: &Value) -> Result { + let mut hasher = Sha256::new(); + hasher.update( + serde_json::to_vec(manifest).map_err(|error| format!("序列化 manifest 失败: {error}"))?, + ); + let app_markdown_path = app_dir.join("APP.md"); + if let Ok(bytes) = fs::read(&app_markdown_path) { + hasher.update(bytes); + } + let package_json_path = app_dir.join("package.json"); + if let Ok(bytes) = fs::read(&package_json_path) { + hasher.update(bytes); + } + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + +fn sha256_prefixed(bytes: &[u8]) -> String { + format!("sha256:{}", sha256_hex(bytes)) +} + +fn agent_app_data_dir() -> Result { + Ok(lime_core::app_paths::preferred_data_dir()?.join(AGENT_APP_DATA_DIR)) +} + +fn safe_hash_path_segment(hash: &str) -> String { + hash.replace(':', "_") +} + +fn package_cache_dir(package_hash: &str) -> Result { + validate_sha256_hash("packageHash", package_hash)?; + Ok(agent_app_data_dir()? + .join("packages") + .join(safe_hash_path_segment(package_hash))) +} + +fn installed_dir() -> Result { + Ok(agent_app_data_dir()?.join("installed")) +} + +fn setup_dir() -> Result { + Ok(agent_app_data_dir()?.join("setup")) +} + +fn installed_state_path(app_id: &str) -> Result { + validate_safe_app_id(app_id)?; + Ok(installed_dir()?.join(format!("{app_id}.json"))) +} + +fn setup_state_path(app_id: &str) -> Result { + validate_safe_app_id(app_id)?; + Ok(setup_dir()?.join(format!("{app_id}.json"))) +} + +fn validate_safe_app_id(app_id: &str) -> Result<(), String> { + if app_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + && !app_id.is_empty() + { + return Ok(()); + } + Err(format!("Agent App id 不安全: {app_id}")) +} + +fn read_state_app_id(state: &Value) -> Result { + read_string(state, &["appId"]) + .ok_or_else(|| "Installed Agent App state 缺少 appId。".to_string()) +} + +fn read_string(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current.as_str().map(ToString::to_string) +} + +fn write_installed_state(app_id: &str, state: &Value, saved_at: &str) -> Result<(), String> { + fs::create_dir_all(installed_dir()?) + .map_err(|error| format!("创建 Agent App installed 目录失败: {error}"))?; + fs::create_dir_all(setup_dir()?) + .map_err(|error| format!("创建 Agent App setup 目录失败: {error}"))?; + + let envelope = InstalledAgentAppStateEnvelope { + schema_version: INSTALLED_STATE_SCHEMA_VERSION, + saved_at: saved_at.to_string(), + state: state.clone(), + }; + let content = serde_json::to_string_pretty(&envelope) + .map_err(|error| format!("序列化 installed state 失败: {error}"))?; + fs::write(installed_state_path(app_id)?, content) + .map_err(|error| format!("写入 installed state 失败: {error}"))?; + + let setup_content = serde_json::json!({ + "schemaVersion": INSTALLED_STATE_SCHEMA_VERSION, + "appId": app_id, + "savedAt": saved_at, + "setup": state.get("setup").cloned().unwrap_or_else(|| serde_json::json!({})), + }); + fs::write( + setup_state_path(app_id)?, + serde_json::to_string_pretty(&setup_content) + .map_err(|error| format!("序列化 setup state 失败: {error}"))?, + ) + .map_err(|error| format!("写入 setup state 失败: {error}"))?; + Ok(()) +} + +fn read_installed_state_path(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(None); + } + let content = + fs::read_to_string(path).map_err(|error| format!("读取 installed state 失败: {error}"))?; + let envelope: InstalledAgentAppStateEnvelope = serde_json::from_str(&content) + .map_err(|error| format!("解析 installed state 失败: {error}"))?; + if envelope.schema_version != INSTALLED_STATE_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent App installed state schemaVersion: {}", + envelope.schema_version + )); + } + Ok(Some(envelope.state)) +} + +fn set_object_field(value: &mut Value, key: &str, next: Value) -> Result<(), String> { + let Some(object) = value.as_object_mut() else { + return Err("Installed Agent App state 必须是对象。".to_string()); + }; + object.insert(key.to_string(), next); + Ok(()) +} + +fn issue( + code: impl Into, + path: impl ToString, + message: impl Into, + app_id: Option, +) -> InstalledAgentAppStatePersistenceIssue { + InstalledAgentAppStatePersistenceIssue { + code: code.into(), + path: path.to_string(), + message: message.into(), + app_id, + } +} + +fn target( + kind: impl Into, + value: impl Into, + safe_to_delete: bool, + action: impl Into, + reason: impl Into, +) -> AgentAppUninstallRehearsalTarget { + AgentAppUninstallRehearsalTarget { + kind: kind.into(), + value: value.into(), + safe_to_delete, + action: action.into(), + reason: reason.into(), + } +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use lime_core::agent_app_runtime_token::{ + verify_agent_app_runtime_token, AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION, + }; + use std::io::Write; + use tempfile::tempdir; + use zip::write::FileOptions; + + fn sample_app_markdown() -> &'static str { + "---\nmanifestVersion: 0.3.0\nname: content-factory-app\nversion: 0.3.0\nentries:\n - key: dashboard\n kind: page\n---\n# 内容工厂\n" + } + + fn sample_descriptor( + package_hash: String, + manifest_hash: String, + ) -> AgentAppCloudReleaseDescriptor { + AgentAppCloudReleaseDescriptor { + source_uri: "https://packages.limecloud.example/apps/content-factory-app-0.3.0.lapp" + .to_string(), + app_id: "content-factory-app".to_string(), + version: "0.3.0".to_string(), + release_id: Some("release-001".to_string()), + tenant_id: Some("tenant-0001".to_string()), + tenant_enablement_ref: Some("enablement-001".to_string()), + channel: Some("stable".to_string()), + package_url: "https://packages.limecloud.example/apps/content-factory-app-0.3.0.lapp" + .to_string(), + package_hash, + manifest_hash, + signature_ref: None, + loaded_at: "2026-05-15T00:00:00.000Z".to_string(), + } + } + + fn build_sample_zip() -> Vec { + let mut buffer = Cursor::new(Vec::new()); + { + let mut zip = zip::ZipWriter::new(&mut buffer); + let options = FileOptions::default(); + zip.add_directory("content-factory-app/", options).unwrap(); + zip.start_file("content-factory-app/APP.md", options) + .unwrap(); + zip.write_all(sample_app_markdown().as_bytes()).unwrap(); + zip.start_file("content-factory-app/package.json", options) + .unwrap(); + zip.write_all(br#"{"scripts":{"dev":"vite"}}"#).unwrap(); + zip.finish().unwrap(); + } + buffer.into_inner() + } + + fn sample_provider( + id: &str, + provider_type: ApiProviderType, + api_host: &str, + custom_models: Vec<&str>, + ) -> ApiKeyProvider { + ApiKeyProvider { + id: id.to_string(), + name: id.to_string(), + provider_type, + api_host: api_host.to_string(), + is_system: false, + group: lime_core::database::dao::api_key_provider::ProviderGroup::Custom, + enabled: true, + sort_order: 0, + api_version: None, + project: None, + location: None, + region: None, + custom_models: custom_models.into_iter().map(ToString::to_string).collect(), + prompt_cache_mode: None, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[test] + fn agent_app_protocol_uses_lime_gateway_openai_chat_surface() { + let anthropic = sample_provider( + "anthropic-compatible", + ApiProviderType::AnthropicCompatible, + "https://anthropic.example.com", + vec!["claude-test"], + ); + let openai = sample_provider( + "new-api", + ApiProviderType::NewApi, + "https://relay.example.com/v1", + vec!["gpt-test"], + ); + let responses = sample_provider( + "codex", + ApiProviderType::Codex, + "https://api.openai.com", + vec!["gpt-5"], + ); + + assert_eq!(agent_app_protocol_for_provider(&anthropic), "openai_chat"); + assert_eq!(agent_app_protocol_for_provider(&openai), "openai_chat"); + assert_eq!(agent_app_protocol_for_provider(&responses), "openai_chat"); + } + + #[test] + fn agent_app_model_binding_uses_provider_model_without_secret_in_message() { + let provider = sample_provider( + "content-provider", + ApiProviderType::NewApi, + "https://relay.example.com/v1", + vec!["", "gpt-5.4-mini"], + ); + let binding = build_agent_app_model_binding( + &provider, + "http://127.0.0.1:8999".to_string(), + Some("local-gateway-token".to_string()), + first_provider_model(&provider), + ); + + assert_eq!(binding.provider_id, "content-provider"); + assert_eq!(binding.api_host, "http://127.0.0.1:8999"); + assert_eq!(binding.protocol, "openai_chat"); + assert_eq!(binding.model.as_deref(), Some("gpt-5.4-mini")); + assert_eq!(binding.access_token.as_deref(), Some("local-gateway-token")); + } + + #[test] + fn agent_app_model_generation_access_token_is_scoped_and_verifiable() { + let mut config = Config::default(); + config.server.api_key = "server-gateway-secret".to_string(); + + let token = + issue_agent_app_model_generation_access_token(&config, "content-factory-app").unwrap(); + + assert_ne!(token, config.server.api_key); + let claims = verify_agent_app_runtime_token(&config.server.api_key, &token).unwrap(); + assert_eq!(claims.app_id, "content-factory-app"); + assert_eq!(claims.scope, AGENT_APP_RUNTIME_SCOPE_MODEL_GENERATION); + } + + #[test] + fn cloud_release_descriptor_requires_https_and_sha256_hashes() { + let descriptor = sample_descriptor( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + ); + validate_cloud_release_descriptor(&descriptor).unwrap(); + + let mut bad_url = descriptor.clone(); + bad_url.package_url = "http://packages.example/app.lapp".to_string(); + bad_url.source_uri = bad_url.package_url.clone(); + assert!(validate_cloud_release_descriptor(&bad_url) + .unwrap_err() + .contains("https")); + + let mut bad_hash = descriptor; + bad_hash.package_hash = "package-fnv1a-deadbeef".to_string(); + assert!(validate_cloud_release_descriptor(&bad_hash) + .unwrap_err() + .contains("sha256")); + } + + #[test] + fn extracts_zip_package_and_verifies_manifest_hash() { + let zip_bytes = build_sample_zip(); + let package_hash = sha256_prefixed(&zip_bytes); + let manifest_hash = sha256_prefixed(sample_app_markdown().as_bytes()); + let descriptor = sample_descriptor(package_hash, manifest_hash.clone()); + let temp = tempdir().unwrap(); + + extract_agent_app_package_archive(&zip_bytes, temp.path()).unwrap(); + let root = find_agent_app_package_root(temp.path()).unwrap(); + let app_markdown = fs::read_to_string(root.join("APP.md")).unwrap(); + let manifest = parse_app_markdown_frontmatter(&app_markdown).unwrap(); + + assert_eq!(sha256_prefixed(app_markdown.as_bytes()), manifest_hash); + ensure_manifest_matches_cloud_release(&manifest, &descriptor).unwrap(); + } +} diff --git a/src-tauri/src/commands/agent_app_runtime_cmd.rs b/src-tauri/src/commands/agent_app_runtime_cmd.rs new file mode 100644 index 000000000..8783d14f0 --- /dev/null +++ b/src-tauri/src/commands/agent_app_runtime_cmd.rs @@ -0,0 +1,1197 @@ +//! Agent App Runtime surface 命令。 +//! +//! 本模块只把 App-scoped task 适配到现有 AgentRuntime 主链; +//! 不在 Agent App 下创建第二套模型、工具、证据或队列运行时。 + +use crate::agent::AsterAgentState; +use crate::app::LogState; +use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; +use crate::commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action; +use crate::commands::aster_agent_cmd::{ + agent_runtime_get_thread_read, agent_runtime_interrupt_turn, build_queued_turn_task, + create_runtime_session_internal_with_runtime, AgentRuntimeInterruptTurnRequest, + AgentRuntimeRespondActionRequest, AgentRuntimeThreadReadModel, AsterChatRequest, + AsterExecutionStrategy, RuntimeCommandContext, +}; +use crate::config::GlobalConfigManagerState; +use crate::database::DbConnection; +use crate::mcp::McpManagerState; +use crate::services::automation_service::AutomationServiceState; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use tauri::{AppHandle, State}; +use uuid::Uuid; + +const AGENT_APP_RUNTIME_EVENT_PREFIX: &str = "agent_app_runtime"; +const AGENT_APP_RUNTIME_METADATA_KEY: &str = "agent_app_runtime"; +const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; +const AGENT_APP_RUNTIME_CAPABILITY_SOURCE: &str = "agent_app_runtime"; + +#[derive(Debug, Clone, Copy)] +struct AgentAppRuntimeCapabilityDescriptor { + capability_id: &'static str, + aliases: &'static [&'static str], + launch_key: &'static str, + context_key: &'static str, + default_kind: &'static str, + skill_name: &'static str, +} + +const AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS: &[AgentAppRuntimeCapabilityDescriptor] = &[ + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.image.generate", + aliases: &[ + "lime.capability.image.generate", + "image.generate", + "image_generation", + "image", + "asset.generate", + ], + launch_key: "image_skill_launch", + context_key: "image_task", + default_kind: "image_task", + skill_name: "image_generate", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.cover.generate", + aliases: &[ + "lime.capability.cover.generate", + "cover.generate", + "cover_generation", + "cover", + ], + launch_key: "cover_skill_launch", + context_key: "cover_task", + default_kind: "cover_task", + skill_name: "cover_generate", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.research.search", + aliases: &[ + "lime.capability.research.search", + "research.search", + "research", + "web_search", + "search", + ], + launch_key: "research_skill_launch", + context_key: "research_request", + default_kind: "research_request", + skill_name: "research", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.report.generate", + aliases: &[ + "lime.capability.report.generate", + "report.generate", + "report", + "competitor_report", + ], + launch_key: "report_skill_launch", + context_key: "report_request", + default_kind: "report_request", + skill_name: "report_generate", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.pdf.read", + aliases: &["lime.capability.pdf.read", "pdf.read", "pdf_extract", "pdf"], + launch_key: "pdf_read_skill_launch", + context_key: "pdf_read_request", + default_kind: "pdf_read_request", + skill_name: "pdf_read", + }, + AgentAppRuntimeCapabilityDescriptor { + capability_id: "lime.capability.summary.generate", + aliases: &[ + "lime.capability.summary.generate", + "summary.generate", + "summary", + "text_summary", + ], + launch_key: "summary_skill_launch", + context_key: "summary_request", + default_kind: "summary_request", + skill_name: "summary", + }, +]; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeStartTaskRequest { + pub app_id: String, + #[serde(default)] + pub entry_key: Option, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub task_id: Option, + pub task_kind: String, + #[serde(default)] + pub idempotency_key: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub input: Option, + #[serde(default)] + pub expected_output: Option, + #[serde(default)] + pub required_capabilities: Vec, + #[serde(default)] + pub capability_hints: Vec, + #[serde(default)] + pub knowledge_bindings: Vec, + #[serde(default)] + pub human_review: Option, + #[serde(default)] + pub event_name: Option, + #[serde(default)] + pub turn_id: Option, + #[serde(default)] + pub queue_if_busy: Option, + #[serde(default)] + pub skip_pre_submit_resume: Option, + #[serde(default)] + pub run_start_hooks: Option, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeStartTaskResult { + pub app_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry_key: Option, + pub task_id: String, + pub trace_id: String, + pub task_kind: String, + pub session_id: String, + pub turn_id: String, + pub event_name: String, + pub status: String, + pub submitted_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeCancelTaskRequest { + pub app_id: String, + pub task_id: String, + pub session_id: String, + #[serde(default)] + pub turn_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeCancelTaskResult { + pub app_id: String, + pub task_id: String, + pub session_id: String, + pub cancelled: bool, + pub status: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeGetTaskRequest { + pub app_id: String, + pub task_id: String, + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeTaskEvent { + pub id: String, + pub event_type: String, + pub status: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub occurred_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeTaskSnapshot { + pub app_id: String, + pub task_id: String, + pub session_id: String, + pub status: String, + pub task_status: String, + pub task_events: Vec, + pub thread_read: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeSubmitHostResponseRequest { + pub app_id: String, + pub task_id: String, + pub runtime_request: AgentRuntimeRespondActionRequest, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentAppRuntimeSubmitHostResponseResult { + pub app_id: String, + pub task_id: String, + pub status: String, +} + +fn non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) +} + +fn require_text(value: Option<&str>, label: &str) -> Result { + non_empty(value).ok_or_else(|| format!("{label} 不能为空")) +} + +fn default_task_message(request: &AgentAppRuntimeStartTaskRequest) -> String { + let title = non_empty(request.title.as_deref()) + .or_else(|| non_empty(request.prompt.as_deref())) + .unwrap_or_else(|| request.task_kind.trim().to_string()); + let input = request + .input + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + let expected_output = request + .expected_output + .as_ref() + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .unwrap_or_else(|| "{}".to_string()); + + [ + "【Agent App Runtime Task】".to_string(), + format!("App: {}", request.app_id.trim()), + format!( + "Entry: {}", + request.entry_key.as_deref().unwrap_or("default").trim() + ), + format!("TaskKind: {}", request.task_kind.trim()), + format!("Title: {title}"), + "".to_string(), + "请在 Lime AgentRuntime 主链中完成这个 App 业务任务。".to_string(), + "不要要求用户跳回通用 Chat;如需补充上下文,请通过可审计的 action / request 机制表达。" + .to_string(), + "".to_string(), + "Input JSON:".to_string(), + input, + "".to_string(), + "Expected Output JSON:".to_string(), + expected_output, + ] + .join("\n") +} + +fn capability_match_token(value: &str) -> String { + value + .trim() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn descriptor_matches_capability( + descriptor: &AgentAppRuntimeCapabilityDescriptor, + value: &str, +) -> bool { + let token = capability_match_token(value); + if token.is_empty() { + return false; + } + + descriptor + .aliases + .iter() + .any(|alias| capability_match_token(alias) == token) +} + +fn resolve_primary_capability_descriptor( + request: &AgentAppRuntimeStartTaskRequest, +) -> Option { + request + .required_capabilities + .iter() + .chain(request.capability_hints.iter()) + .find_map(|value| { + AGENT_APP_RUNTIME_CAPABILITY_DESCRIPTORS + .iter() + .copied() + .find(|descriptor| descriptor_matches_capability(descriptor, value)) + }) +} + +fn insert_string_if_some(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value.map(|item| item.trim().to_string()) { + if !value.is_empty() { + map.insert(key.to_string(), json!(value)); + } + } +} + +fn app_task_prompt_summary(request: &AgentAppRuntimeStartTaskRequest) -> String { + non_empty(request.prompt.as_deref()) + .or_else(|| non_empty(request.title.as_deref())) + .unwrap_or_else(|| default_task_message(request)) +} + +fn build_agent_app_capability_request_context( + request: &AgentAppRuntimeStartTaskRequest, + task_id: &str, + trace_id: &str, + descriptor: AgentAppRuntimeCapabilityDescriptor, +) -> Map { + let prompt = app_task_prompt_summary(request); + let mut context = Map::new(); + + context.insert( + "source".to_string(), + json!(AGENT_APP_RUNTIME_CAPABILITY_SOURCE), + ); + context.insert("capability_id".to_string(), json!(descriptor.capability_id)); + context.insert("app_id".to_string(), json!(request.app_id.trim())); + context.insert("task_id".to_string(), json!(task_id)); + context.insert("trace_id".to_string(), json!(trace_id)); + context.insert("task_kind".to_string(), json!(request.task_kind.trim())); + context.insert( + "entry_source".to_string(), + json!(AGENT_APP_RUNTIME_CAPABILITY_SOURCE), + ); + context.insert("raw_text".to_string(), json!(prompt.clone())); + context.insert("prompt".to_string(), json!(prompt)); + context.insert( + "required_capabilities".to_string(), + json!(request.required_capabilities.clone()), + ); + context.insert( + "capability_hints".to_string(), + json!(request.capability_hints.clone()), + ); + context.insert( + "human_review".to_string(), + json!(request.human_review.unwrap_or(false)), + ); + + insert_string_if_some( + &mut context, + "entry_key", + non_empty(request.entry_key.as_deref()), + ); + insert_string_if_some( + &mut context, + "workspace_id", + non_empty(request.workspace_id.as_deref()), + ); + insert_string_if_some( + &mut context, + "idempotency_key", + non_empty(request.idempotency_key.as_deref()), + ); + + if let Some(input) = request.input.clone() { + context.insert("input".to_string(), input); + } + if let Some(expected_output) = request.expected_output.clone() { + context.insert("expected_output".to_string(), expected_output); + } + if !request.knowledge_bindings.is_empty() { + context.insert( + "knowledge_bindings".to_string(), + json!(request.knowledge_bindings.clone()), + ); + } + + match descriptor.context_key { + "image_task" | "cover_task" => { + context.insert("mode".to_string(), json!("generate")); + } + "research_request" | "report_request" => { + if !context.contains_key("query") { + let query = context + .get("prompt") + .and_then(Value::as_str) + .unwrap_or("请根据当前 App 任务补齐资料") + .to_string(); + context.insert("query".to_string(), json!(query)); + } + } + _ => {} + } + + context +} + +fn insert_agent_app_capability_launch_metadata( + root: &mut Map, + request: &AgentAppRuntimeStartTaskRequest, + task_id: &str, + trace_id: &str, + descriptor: AgentAppRuntimeCapabilityDescriptor, +) { + let launch_context = + build_agent_app_capability_request_context(request, task_id, trace_id, descriptor); + let harness = root + .entry("harness".to_string()) + .or_insert_with(|| json!({})); + let Some(harness) = harness.as_object_mut() else { + return; + }; + + harness.insert("allow_model_skills".to_string(), json!(true)); + let mut launch = Map::new(); + launch.insert("skill_name".to_string(), json!(descriptor.skill_name)); + launch.insert("kind".to_string(), json!(descriptor.default_kind)); + launch.insert( + descriptor.context_key.to_string(), + Value::Object(launch_context), + ); + harness.insert(descriptor.launch_key.to_string(), Value::Object(launch)); +} + +fn push_task_event( + events: &mut Vec, + event_type: &str, + status: &str, + message: impl Into, + occurred_at: Option, + payload: Option, +) { + events.push(AgentAppRuntimeTaskEvent { + id: format!("{}:{}", event_type, events.len() + 1), + event_type: event_type.to_string(), + status: status.to_string(), + message: message.into(), + severity: None, + turn_id: None, + request_id: None, + tool_name: None, + evidence_ref: None, + occurred_at, + payload, + }); +} + +fn outcome_event_type(outcome_type: &str) -> &'static str { + let normalized = outcome_type.to_ascii_lowercase(); + if normalized.contains("cancel") || normalized.contains("interrupt") { + "task:cancelled" + } else if normalized.contains("fail") + || normalized.contains("error") + || normalized.contains("timeout") + { + "task:error" + } else { + "task:completed" + } +} + +fn has_missing_context(context_summary: Option<&Value>) -> Option { + let summary = context_summary?.as_object()?; + let missing_context = summary + .get("missing_context") + .or_else(|| summary.get("missingContext"))?; + if missing_context + .as_array() + .is_some_and(|items| !items.is_empty()) + { + Some(missing_context.clone()) + } else { + None + } +} + +fn build_agent_app_runtime_task_events( + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + let mut events = Vec::new(); + + for queued_turn in &thread_read.queued_turns { + push_task_event( + &mut events, + "task:queued", + "queued", + queued_turn.message_preview.clone(), + None, + serde_json::to_value(queued_turn).ok(), + ); + } + + let status_message = match thread_read.profile_status.as_str() { + "queued" => "任务已进入队列".to_string(), + "running" => "任务正在执行".to_string(), + "blocked" => "任务等待用户或权限响应".to_string(), + "completed" => "任务已完成".to_string(), + "failed" => "任务执行失败".to_string(), + "cancelled" => "任务已取消".to_string(), + _ => format!("任务状态:{}", thread_read.status), + }; + push_task_event( + &mut events, + "task:progress", + thread_read.profile_status.as_str(), + status_message, + thread_read.updated_at.clone(), + Some(json!({ + "thread_id": thread_read.thread_id.clone(), + "active_turn_id": thread_read.active_turn_id.clone(), + "profile_status": thread_read.profile_status.clone(), + "status": thread_read.status.clone(), + })), + ); + + for pending_request in &thread_read.pending_requests { + let message = pending_request + .title + .clone() + .unwrap_or_else(|| "任务等待 Host / 用户响应".to_string()); + let mut event = AgentAppRuntimeTaskEvent { + id: format!("task:reviewRequested:{}", pending_request.id), + event_type: "task:reviewRequested".to_string(), + status: pending_request.status.clone(), + message, + severity: None, + turn_id: pending_request.turn_id.clone(), + request_id: Some(pending_request.id.clone()), + tool_name: None, + evidence_ref: None, + occurred_at: pending_request.created_at.clone(), + payload: serde_json::to_value(pending_request).ok(), + }; + if matches!( + pending_request.request_type.as_str(), + "missing_context" | "ask_user" | "elicitation" + ) { + event.event_type = "task:missingContextRequested".to_string(); + } + events.push(event); + } + + if let Some(missing_context) = has_missing_context(thread_read.context_summary.as_ref()) { + push_task_event( + &mut events, + "task:missingContextRequested", + "blocked", + "任务需要补齐上下文", + thread_read.updated_at.clone(), + Some(json!({ "missing_context": missing_context })), + ); + } + + for tool_call in &thread_read.tool_calls { + events.push(AgentAppRuntimeTaskEvent { + id: format!("task:toolCall:{}", tool_call.tool_call_id), + event_type: "task:toolCall".to_string(), + status: tool_call.status.clone(), + message: format!("工具 {} {}", tool_call.tool_name, tool_call.status), + severity: if tool_call.success == Some(false) { + Some("warning".to_string()) + } else { + None + }, + turn_id: Some(tool_call.turn_id.clone()), + request_id: None, + tool_name: Some(tool_call.tool_name.clone()), + evidence_ref: None, + occurred_at: None, + payload: serde_json::to_value(tool_call).ok(), + }); + } + + for evidence_ref in &thread_read.evidence_summary.evidence_refs { + events.push(AgentAppRuntimeTaskEvent { + id: format!("evidence:recorded:{evidence_ref}"), + event_type: "evidence:recorded".to_string(), + status: "recorded".to_string(), + message: "运行证据已记录".to_string(), + severity: None, + turn_id: None, + request_id: None, + tool_name: None, + evidence_ref: Some(evidence_ref.clone()), + occurred_at: thread_read.updated_at.clone(), + payload: None, + }); + } + + for (index, outcome) in thread_read + .evidence_summary + .verification_outcomes + .iter() + .enumerate() + { + push_task_event( + &mut events, + "evidence:verified", + "verified", + "运行证据已验证", + thread_read.updated_at.clone(), + Some(json!({ "index": index, "outcome": outcome })), + ); + } + + if let Some(outcome) = &thread_read.last_outcome { + let event_type = outcome_event_type(&outcome.outcome_type); + events.push(AgentAppRuntimeTaskEvent { + id: format!( + "{}:{}", + event_type, + outcome.turn_id.as_deref().unwrap_or("latest") + ), + event_type: event_type.to_string(), + status: outcome.outcome_type.clone(), + message: outcome + .summary + .clone() + .or_else(|| outcome.primary_cause.clone()) + .unwrap_or_else(|| "任务回合已结束".to_string()), + severity: (event_type == "task:error").then(|| "error".to_string()), + turn_id: outcome.turn_id.clone(), + request_id: None, + tool_name: None, + evidence_ref: None, + occurred_at: outcome.ended_at.clone(), + payload: serde_json::to_value(outcome).ok(), + }); + } + + for incident in &thread_read.incidents { + events.push(AgentAppRuntimeTaskEvent { + id: format!("task:incident:{}", incident.id), + event_type: "task:incident".to_string(), + status: incident.status.clone(), + message: incident.title.clone(), + severity: Some(incident.severity.clone()), + turn_id: incident.turn_id.clone(), + request_id: None, + tool_name: None, + evidence_ref: None, + occurred_at: incident.detected_at.clone(), + payload: serde_json::to_value(incident).ok(), + }); + } + + events +} + +fn build_agent_app_runtime_metadata( + request: &AgentAppRuntimeStartTaskRequest, + task_id: &str, + trace_id: &str, +) -> Value { + let mut metadata = request.metadata.clone().unwrap_or_else(|| json!({})); + if !metadata.is_object() { + metadata = json!({}); + } + + let app_runtime = json!({ + "surface": "agent_app", + "app_id": request.app_id.trim(), + "entry_key": request.entry_key.as_deref().unwrap_or("").trim(), + "task_id": task_id, + "trace_id": trace_id, + "task_kind": request.task_kind.trim(), + "idempotency_key": request.idempotency_key.as_deref().unwrap_or("").trim(), + "required_capabilities": request.required_capabilities.clone(), + "capability_hints": request.capability_hints.clone(), + "knowledge_bindings": request.knowledge_bindings.clone(), + "human_review": request.human_review.unwrap_or(false), + }); + + if let Some(root) = metadata.as_object_mut() { + root.insert( + AGENT_APP_RUNTIME_METADATA_KEY.to_string(), + app_runtime.clone(), + ); + let lime_runtime = root + .entry(LIME_RUNTIME_METADATA_KEY.to_string()) + .or_insert_with(|| json!({})); + if let Some(lime_runtime) = lime_runtime.as_object_mut() { + lime_runtime.insert("surface".to_string(), json!("agent_app")); + lime_runtime.insert("app_id".to_string(), json!(request.app_id.trim())); + lime_runtime.insert("task_id".to_string(), json!(task_id)); + lime_runtime.insert("task_kind".to_string(), json!(request.task_kind.trim())); + } + { + let harness = root + .entry("harness".to_string()) + .or_insert_with(|| json!({})); + if let Some(harness) = harness.as_object_mut() { + harness.insert( + AGENT_APP_RUNTIME_METADATA_KEY.to_string(), + app_runtime.clone(), + ); + } + } + if let Some(descriptor) = resolve_primary_capability_descriptor(request) { + insert_agent_app_capability_launch_metadata( + root, request, task_id, trace_id, descriptor, + ); + } + } + + metadata +} + +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub async fn agent_app_runtime_start_task( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentAppRuntimeStartTaskRequest, +) -> Result { + let app_id = require_text(Some(request.app_id.as_str()), "appId")?; + let task_kind = require_text(Some(request.task_kind.as_str()), "taskKind")?; + let workspace_id = require_text(request.workspace_id.as_deref(), "workspaceId")?; + let task_id = non_empty(request.task_id.as_deref()) + .unwrap_or_else(|| format!("agent-app-task-{}", Uuid::new_v4())); + let trace_id = format!("agent-app-trace-{}", Uuid::new_v4()); + let turn_id = + non_empty(request.turn_id.as_deref()).unwrap_or_else(|| Uuid::new_v4().to_string()); + let event_name = non_empty(request.event_name.as_deref()) + .unwrap_or_else(|| format!("{AGENT_APP_RUNTIME_EVENT_PREFIX}:{app_id}:{task_id}")); + let session_id = match non_empty(request.session_id.as_deref()) { + Some(session_id) => session_id, + None => { + create_runtime_session_internal_with_runtime( + db.inner(), + state.inner(), + mcp_manager.inner(), + None, + workspace_id.clone(), + non_empty(request.title.as_deref()) + .or_else(|| Some(format!("Agent App · {task_kind}"))), + Some(AsterExecutionStrategy::Auto), + request.run_start_hooks.unwrap_or(true), + ) + .await? + } + }; + let metadata = build_agent_app_runtime_metadata(&request, &task_id, &trace_id); + let message = + non_empty(request.prompt.as_deref()).unwrap_or_else(|| default_task_message(&request)); + let runtime_request = AsterChatRequest { + message, + session_id: session_id.clone(), + event_name: event_name.clone(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id, + web_search: None, + search_mode: None, + execution_strategy: Some(AsterExecutionStrategy::Auto), + auto_continue: None, + system_prompt: None, + metadata: Some(metadata), + turn_id: Some(turn_id.clone()), + queue_if_busy: Some(request.queue_if_busy.unwrap_or(true)), + queued_turn_id: Some(format!("agent-app-queued-{task_id}")), + }; + + let queued_task = build_queued_turn_task(runtime_request)?; + let runtime = RuntimeCommandContext::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ); + runtime + .submit_runtime_turn( + queued_task, + request.queue_if_busy.unwrap_or(true), + request.skip_pre_submit_resume.unwrap_or(false), + ) + .await?; + + Ok(AgentAppRuntimeStartTaskResult { + app_id, + entry_key: request + .entry_key + .and_then(|value| non_empty(Some(value.as_str()))), + task_id, + trace_id, + task_kind, + session_id, + turn_id, + event_name, + status: "accepted".to_string(), + submitted_at: Utc::now().to_rfc3339(), + }) +} + +#[tauri::command] +pub async fn agent_app_runtime_cancel_task( + app: AppHandle, + state: State<'_, AsterAgentState>, + request: AgentAppRuntimeCancelTaskRequest, +) -> Result { + let cancelled = agent_runtime_interrupt_turn( + app, + state, + AgentRuntimeInterruptTurnRequest { + session_id: request.session_id.clone(), + turn_id: request.turn_id.clone(), + }, + ) + .await?; + + Ok(AgentAppRuntimeCancelTaskResult { + app_id: request.app_id, + task_id: request.task_id, + session_id: request.session_id, + cancelled, + status: (if cancelled { + "cancelled" + } else { + "not_running" + }) + .to_string(), + }) +} + +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub async fn agent_app_runtime_get_task( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentAppRuntimeGetTaskRequest, +) -> Result { + let thread_read = agent_runtime_get_thread_read( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request.session_id.clone(), + ) + .await?; + let task_status = thread_read.profile_status.clone(); + let task_events = build_agent_app_runtime_task_events(&thread_read); + let thread_read_value = serde_json::to_value(&thread_read) + .map_err(|error| format!("序列化 AgentRuntimeThreadReadModel 失败: {error}"))?; + + Ok(AgentAppRuntimeTaskSnapshot { + app_id: request.app_id, + task_id: request.task_id, + session_id: request.session_id, + status: "thread_read_available".to_string(), + task_status, + task_events, + thread_read: thread_read_value, + }) +} + +#[tauri::command] +pub async fn agent_app_runtime_submit_host_response( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + request: AgentAppRuntimeSubmitHostResponseRequest, +) -> Result { + agent_runtime_respond_action(app, state, db, request.runtime_request).await?; + + Ok(AgentAppRuntimeSubmitHostResponseResult { + app_id: request.app_id, + task_id: request.task_id, + status: "submitted".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::QueuedTurnSnapshot; + use crate::commands::aster_agent_cmd::{ + AgentRuntimeIncidentView, AgentRuntimeOutcomeView, AgentRuntimeRequestView, + AgentRuntimeThreadEvidenceSummary, AgentRuntimeThreadTelemetrySummary, + AgentRuntimeThreadToolCallView, + }; + + fn runtime_request( + required_capabilities: Vec<&str>, + capability_hints: Vec<&str>, + ) -> AgentAppRuntimeStartTaskRequest { + AgentAppRuntimeStartTaskRequest { + app_id: "content-factory-app".to_string(), + entry_key: Some("content_factory".to_string()), + workspace_id: Some("workspace-1".to_string()), + session_id: None, + task_id: None, + task_kind: "content_factory.copy.generate".to_string(), + idempotency_key: None, + title: Some("生成小红书种草文案".to_string()), + prompt: Some("围绕春季护肤新品生成文案,并补齐资料来源".to_string()), + input: Some(json!({ + "platform": "xiaohongshu", + "audience": "敏感肌用户" + })), + expected_output: Some(json!({ + "artifacts": ["copy", "assetBrief"] + })), + required_capabilities: required_capabilities + .into_iter() + .map(str::to_string) + .collect(), + capability_hints: capability_hints.into_iter().map(str::to_string).collect(), + knowledge_bindings: Vec::new(), + human_review: Some(true), + event_name: None, + turn_id: None, + queue_if_busy: None, + skip_pre_submit_resume: None, + run_start_hooks: None, + metadata: None, + } + } + + fn base_thread_read() -> AgentRuntimeThreadReadModel { + AgentRuntimeThreadReadModel { + thread_id: "thread-1".to_string(), + status: "running".to_string(), + profile_status: "running".to_string(), + active_turn_id: Some("turn-1".to_string()), + turns: Vec::new(), + pending_requests: Vec::new(), + last_outcome: None, + incidents: Vec::new(), + queued_turns: Vec::new(), + tool_calls: Vec::new(), + model_routing: None, + evidence_summary: AgentRuntimeThreadEvidenceSummary::default(), + telemetry_summary: AgentRuntimeThreadTelemetrySummary::default(), + context_summary: None, + interrupt_state: None, + updated_at: Some("2026-05-16T00:00:00.000Z".to_string()), + latest_compaction_boundary: None, + file_checkpoint_summary: None, + diagnostics: None, + task_kind: None, + service_model_slot: None, + routing_mode: None, + decision_source: None, + decision_reason: None, + candidate_count: None, + fallback_chain: None, + capability_gap: None, + estimated_cost_class: None, + single_candidate_only: None, + oem_policy: None, + runtime_summary: None, + auxiliary_task_runtime: None, + limit_state: None, + cost_state: None, + permission_state: None, + limit_event: None, + } + } + + #[test] + fn test_agent_app_runtime_metadata_maps_research_capability_to_claw_launch() { + let request = runtime_request( + vec!["text_generation", "lime.capability.research.search"], + Vec::new(), + ); + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + let launch = harness + .get("research_skill_launch") + .and_then(Value::as_object) + .expect("research launch"); + let research_request = launch + .get("research_request") + .and_then(Value::as_object) + .expect("research request"); + + assert_eq!(harness.get("allow_model_skills"), Some(&json!(true))); + assert!(harness.get("agent_app_runtime").is_some()); + assert_eq!(launch.get("skill_name"), Some(&json!("research"))); + assert_eq!(launch.get("kind"), Some(&json!("research_request"))); + assert_eq!( + research_request.get("source"), + Some(&json!("agent_app_runtime")) + ); + assert_eq!( + research_request.get("app_id"), + Some(&json!("content-factory-app")) + ); + assert_eq!( + research_request.get("capability_id"), + Some(&json!("lime.capability.research.search")) + ); + assert!(research_request.get("query").is_some()); + } + + #[test] + fn test_agent_app_runtime_metadata_maps_image_alias_to_claw_launch() { + let request = runtime_request(Vec::new(), vec!["image_generation"]); + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let launch = metadata + .get("harness") + .and_then(Value::as_object) + .and_then(|harness| harness.get("image_skill_launch")) + .and_then(Value::as_object) + .expect("image launch"); + let image_task = launch + .get("image_task") + .and_then(Value::as_object) + .expect("image task"); + + assert_eq!(launch.get("skill_name"), Some(&json!("image_generate"))); + assert_eq!(launch.get("kind"), Some(&json!("image_task"))); + assert_eq!(image_task.get("mode"), Some(&json!("generate"))); + assert_eq!( + image_task.get("entry_source"), + Some(&json!("agent_app_runtime")) + ); + } + + #[test] + fn test_agent_app_runtime_metadata_ignores_unknown_capability_without_fake_launch() { + let request = runtime_request(vec!["text_generation"], Vec::new()); + let metadata = build_agent_app_runtime_metadata(&request, "task-1", "trace-1"); + let harness = metadata + .get("harness") + .and_then(Value::as_object) + .expect("harness metadata"); + + assert!(harness.get("agent_app_runtime").is_some()); + assert!(harness.get("allow_model_skills").is_none()); + assert!(harness.get("image_skill_launch").is_none()); + assert!(harness.get("research_skill_launch").is_none()); + } + + #[test] + fn test_agent_app_runtime_task_events_project_thread_read_facts() { + let mut thread_read = base_thread_read(); + thread_read.queued_turns = vec![QueuedTurnSnapshot { + queued_turn_id: "queued-1".to_string(), + message_preview: "排队任务".to_string(), + message_text: "排队任务完整文本".to_string(), + created_at: 1_789_000_000, + image_count: 0, + position: 0, + }]; + thread_read.pending_requests = vec![AgentRuntimeRequestView { + id: "request-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + request_type: "ask_user".to_string(), + status: "pending".to_string(), + title: Some("需要确认素材方向".to_string()), + payload: Some(json!({ "question": "是否继续?" })), + decision: None, + scope: None, + created_at: Some("2026-05-16T00:00:01.000Z".to_string()), + resolved_at: None, + }]; + thread_read.context_summary = Some(json!({ + "missing_context": [{ "field": "target_audience" }] + })); + thread_read.tool_calls = vec![AgentRuntimeThreadToolCallView { + tool_call_id: "tool-1".to_string(), + turn_id: "turn-1".to_string(), + tool_name: "Skill(research)".to_string(), + status: "completed".to_string(), + success: Some(true), + error: None, + }]; + thread_read.evidence_summary = AgentRuntimeThreadEvidenceSummary { + evidence_refs: vec!["evidence-1".to_string()], + verification_outcomes: vec![json!({ "status": "passed" })], + }; + thread_read.last_outcome = Some(AgentRuntimeOutcomeView { + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + outcome_type: "completed".to_string(), + summary: Some("任务完成".to_string()), + primary_cause: None, + retryable: false, + ended_at: Some("2026-05-16T00:00:02.000Z".to_string()), + }); + thread_read.incidents = vec![AgentRuntimeIncidentView { + id: "incident-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: Some("turn-1".to_string()), + item_id: None, + incident_type: "provider_warning".to_string(), + severity: "medium".to_string(), + status: "open".to_string(), + title: "Provider warning".to_string(), + details: None, + detected_at: Some("2026-05-16T00:00:03.000Z".to_string()), + cleared_at: None, + }]; + + let events = build_agent_app_runtime_task_events(&thread_read); + let event_types = events + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(); + + assert!(event_types.contains(&"task:queued")); + assert!(event_types.contains(&"task:progress")); + assert!(event_types.contains(&"task:missingContextRequested")); + assert!(event_types.contains(&"task:toolCall")); + assert!(event_types.contains(&"evidence:recorded")); + assert!(event_types.contains(&"evidence:verified")); + assert!(event_types.contains(&"task:completed")); + assert!(event_types.contains(&"task:incident")); + assert!(events + .iter() + .any(|event| event.request_id.as_deref() == Some("request-1"))); + assert!(events + .iter() + .any(|event| event.evidence_ref.as_deref() == Some("evidence-1"))); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index aa9761134..d07c91cd4 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -1,9 +1,7 @@ use super::*; use crate::commands::aster_agent_cmd::dto::AgentRuntimeSessionHistoryCursor; use crate::database::lock_db; -use crate::sceneapp::application::SceneAppService; use crate::services::agent_timeline_service::abort_running_turn_by_id; -use crate::services::execution_tracker_service::ExecutionTracker; use crate::services::runtime_analysis_handoff_service::{ export_runtime_analysis_handoff_with_locale, RuntimeAnalysisHandoffExportResult, }; @@ -1014,19 +1012,6 @@ pub async fn agent_runtime_save_review_decision( Some(export_locale.as_str()), )?; - let tracker = ExecutionTracker::new(runtime.db().clone()); - if let Err(error) = SceneAppService::sync_review_decision_feedback_for_session( - &tracker, - &session_id, - &saved.decision, - ) { - tracing::warn!( - "[AsterAgent] 保存 review decision 后同步 SceneApp feedback 失败: session_id={}, error={}", - session_id, - error - ); - } - Ok(saved) } diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 5dfa1d535..808d4273e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -564,6 +564,7 @@ pub(crate) use service_skill_launch::{ prepare_service_scene_launch_request_metadata, should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution, }; +pub(crate) use session_runtime::create_runtime_session_internal_with_runtime; pub(crate) use session_runtime::{ persist_session_provider_routing, resolve_recent_preference_from_sources, resolve_session_provider_selector, resolve_session_recent_harness_context, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2add5eaa3..c74df9443 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,6 @@ pub mod a2ui_form_cmd; +pub mod agent_app_cmd; +pub mod agent_app_runtime_cmd; pub mod agent_cmd; pub mod api_key_provider_cmd; pub mod asr_cmd; @@ -43,7 +45,6 @@ pub mod model_registry_cmd; pub mod models_cmd; pub mod persona_cmd; pub mod prompt_cmd; -pub mod sceneapp_cmd; pub mod security_perf_cmd; pub mod session_files_cmd; pub mod site_capability_cmd; diff --git a/src-tauri/src/commands/sceneapp_cmd.rs b/src-tauri/src/commands/sceneapp_cmd.rs deleted file mode 100644 index 61409bbf7..000000000 --- a/src-tauri/src/commands/sceneapp_cmd.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::database::DbConnection; -use crate::sceneapp::application::SceneAppService; -use crate::sceneapp::dto::{ - SceneAppAutomationIntent, SceneAppAutomationResult, SceneAppCatalog, SceneAppDescriptor, - SceneAppGovernanceArtifactKind, SceneAppLaunchIntent, SceneAppPlanResult, SceneAppRunSummary, - SceneAppScorecard, -}; -use crate::services::automation_service::AutomationServiceState; -use crate::services::execution_tracker_service::ExecutionTracker; -use tauri::State; - -#[tauri::command] -pub async fn sceneapp_list_catalog() -> Result { - Ok(SceneAppService::list_catalog()) -} - -#[tauri::command] -pub async fn sceneapp_get_descriptor(id: String) -> Result, String> { - let normalized = id.trim(); - if normalized.is_empty() { - return Err("sceneapp id 不能为空".to_string()); - } - Ok(SceneAppService::get_descriptor(normalized)) -} - -#[tauri::command] -pub async fn sceneapp_plan_launch( - db: State<'_, DbConnection>, - intent: SceneAppLaunchIntent, -) -> Result { - SceneAppService::plan_launch(db.inner(), intent) -} - -#[tauri::command] -pub async fn sceneapp_save_context_baseline( - db: State<'_, DbConnection>, - intent: SceneAppLaunchIntent, -) -> Result { - SceneAppService::save_context_baseline(db.inner(), intent) -} - -#[tauri::command] -pub async fn sceneapp_create_automation_job( - automation_state: State<'_, AutomationServiceState>, - intent: SceneAppAutomationIntent, -) -> Result { - let service = automation_state.0.read().await; - SceneAppService::create_automation_job(&service, intent).await -} - -#[tauri::command] -pub async fn sceneapp_list_runs( - automation_state: State<'_, AutomationServiceState>, - db: State<'_, DbConnection>, - sceneapp_id: Option, -) -> Result, String> { - let service = automation_state.0.read().await; - let tracker = ExecutionTracker::new(db.inner().clone()); - SceneAppService::collect_runs(&tracker, &service, sceneapp_id.as_deref()) -} - -#[tauri::command] -pub async fn sceneapp_get_run_summary( - db: State<'_, DbConnection>, - run_id: String, -) -> Result, String> { - let normalized = run_id.trim(); - if normalized.is_empty() { - return Err("run_id 不能为空".to_string()); - } - let tracker = ExecutionTracker::new(db.inner().clone()); - if let Some(summary) = SceneAppService::get_run_summary_from_tracker(&tracker, normalized)? { - return Ok(Some(summary)); - } - Ok(SceneAppService::get_run_summary(normalized)) -} - -#[tauri::command] -pub async fn sceneapp_prepare_run_governance_artifact( - db: State<'_, DbConnection>, - run_id: String, - kind: SceneAppGovernanceArtifactKind, -) -> Result, String> { - let normalized = run_id.trim(); - if normalized.is_empty() { - return Err("run_id 不能为空".to_string()); - } - - let tracker = ExecutionTracker::new(db.inner().clone()); - SceneAppService::prepare_run_governance_artifact(&tracker, normalized, &kind) -} - -#[tauri::command] -pub async fn sceneapp_get_scorecard( - automation_state: State<'_, AutomationServiceState>, - db: State<'_, DbConnection>, - sceneapp_id: String, -) -> Result { - let normalized = sceneapp_id.trim(); - if normalized.is_empty() { - return Err("sceneapp_id 不能为空".to_string()); - } - let service = automation_state.0.read().await; - SceneAppService::get_scorecard(db.inner(), &service, normalized) -} diff --git a/src-tauri/src/commands/unified_memory_cmd.rs b/src-tauri/src/commands/unified_memory_cmd.rs index 90150ecc0..d4850a1db 100644 --- a/src-tauri/src/commands/unified_memory_cmd.rs +++ b/src-tauri/src/commands/unified_memory_cmd.rs @@ -789,31 +789,6 @@ pub(crate) fn get_memory_by_id( } } -pub(crate) fn load_unified_memories_by_ids( - conn: &rusqlite::Connection, - ids: &[String], -) -> Result, String> { - let normalized_ids = ids - .iter() - .map(|id| id.trim()) - .filter(|id| !id.is_empty()) - .fold(Vec::::new(), |mut acc, id| { - if !acc.iter().any(|existing| existing == id) { - acc.push(id.to_string()); - } - acc - }); - - let mut memories = Vec::new(); - for id in normalized_ids { - if let Some(memory) = get_memory_by_id(conn, id.as_str())? { - memories.push(memory); - } - } - - Ok(memories) -} - fn insert_unified_memory( conn: &rusqlite::Connection, memory: &UnifiedMemory, diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 226ce3f3c..3ece1c7d1 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -2,6 +2,7 @@ //! //! 将 HTTP 请求路由到现有的 Tauri 命令函数。 +mod agent_apps; mod agent_sessions; mod app_runtime; mod automation; @@ -21,7 +22,6 @@ mod models; mod project_resources; mod providers; mod runtime_queries; -mod sceneapp; mod skills; mod tray; mod voice; @@ -149,10 +149,6 @@ pub async fn handle_command( return Ok(result); } - if let Some(result) = sceneapp::try_handle(state, cmd, args.as_ref()).await? { - return Ok(result); - } - if let Some(result) = memory_runtime::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } @@ -161,6 +157,10 @@ pub async fn handle_command( return Ok(result); } + if let Some(result) = agent_apps::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + if let Some(result) = tray::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } @@ -1022,182 +1022,6 @@ mod tests { .contains("Provider 不存在: missing-provider")); } - #[tokio::test] - async fn sceneapp_list_catalog_is_bridged() { - let state = make_test_state(); - - let value = handle_command(&state, "sceneapp_list_catalog", None) - .await - .unwrap(); - - assert!(value["items"].is_array()); - assert!(value["version"].is_string()); - } - - #[tokio::test] - async fn sceneapp_get_descriptor_is_bridged() { - let state = make_test_state(); - - let value = handle_command( - &state, - "sceneapp_get_descriptor", - Some(serde_json::json!({ - "id": "story-video-suite" - })), - ) - .await - .unwrap(); - - assert_eq!(value["id"], "story-video-suite"); - assert_eq!(value["deliveryContract"], "project_pack"); - } - - #[tokio::test] - async fn sceneapp_plan_launch_is_bridged() { - let state = make_test_state(); - - let value = handle_command( - &state, - "sceneapp_plan_launch", - Some(serde_json::json!({ - "intent": { - "sceneappId": "story-video-suite", - "entrySource": "sceneapp_detail_preview", - "workspaceId": "workspace-default", - "projectId": "project-devbridge", - "userInput": "继续整理成 30 秒新品短视频方案", - "referenceMemoryIds": [], - "slots": {} - } - })), - ) - .await - .unwrap(); - - assert_eq!(value["descriptor"]["id"], "story-video-suite"); - assert!(value["contextOverlay"].is_object()); - assert!(value["projectPackPlan"].is_object()); - } - - #[tokio::test] - async fn sceneapp_save_context_baseline_is_bridged() { - let state = make_test_state(); - let temp_dir = TempDir::new().unwrap(); - let root_path = temp_dir.path().join("sceneapp-context-workspace"); - - let workspace_value = handle_command( - &state, - "workspace_create", - Some(serde_json::json!({ - "request": { - "name": "SceneApp Context Workspace", - "rootPath": root_path.to_string_lossy().to_string(), - "workspaceType": "general" - } - })), - ) - .await - .unwrap(); - let workspace_id = workspace_value["id"].as_str().unwrap().to_string(); - - let value = handle_command( - &state, - "sceneapp_save_context_baseline", - Some(serde_json::json!({ - "intent": { - "sceneappId": "story-video-suite", - "entrySource": "sceneapp_detail_save_context_baseline", - "workspaceId": workspace_id.clone(), - "projectId": workspace_id, - "userInput": "继续整理成 30 秒新品短视频方案", - "referenceMemoryIds": [], - "slots": {} - } - })), - ) - .await - .unwrap(); - - assert_eq!(value["descriptor"]["id"], "story-video-suite"); - assert!(value["contextOverlay"]["snapshot"]["referenceItems"].is_array()); - assert!(value["contextOverlay"]["compilerPlan"]["notes"] - .as_array() - .is_some_and(|notes| notes.iter().any(|note| { - note.as_str() - .is_some_and(|value| value.contains("已写入项目级 Context Snapshot")) - }))); - } - - #[tokio::test] - async fn sceneapp_list_runs_is_bridged() { - let state = make_test_state(); - - let error = handle_command( - &state, - "sceneapp_list_runs", - Some(serde_json::json!({ - "sceneappId": "story-video-suite" - })), - ) - .await - .expect_err("missing app handle should fail after bridge routing"); - - assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); - } - - #[tokio::test] - async fn sceneapp_get_run_summary_is_bridged() { - let state = make_test_state(); - - let value = handle_command( - &state, - "sceneapp_get_run_summary", - Some(serde_json::json!({ - "runId": "sceneapp-run-story-video-seed" - })), - ) - .await - .unwrap(); - - assert_eq!(value["runId"], "sceneapp-run-story-video-seed"); - assert_eq!(value["sceneappId"], "story-video-suite"); - } - - #[tokio::test] - async fn sceneapp_prepare_run_governance_artifact_is_bridged() { - let state = make_test_state(); - - let value = handle_command( - &state, - "sceneapp_prepare_run_governance_artifact", - Some(serde_json::json!({ - "runId": "sceneapp-run-story-video-seed", - "kind": "review_decision_json" - })), - ) - .await - .unwrap(); - - assert!(value.is_null()); - } - - #[tokio::test] - async fn sceneapp_get_scorecard_is_bridged() { - let state = make_test_state(); - - let error = handle_command( - &state, - "sceneapp_get_scorecard", - Some(serde_json::json!({ - "sceneappId": "story-video-suite" - })), - ) - .await - .expect_err("missing app handle should fail after bridge routing"); - - assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); - } - #[tokio::test] async fn gateway_channel_status_is_bridged() { let state = make_test_state(); diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_apps.rs b/src-tauri/src/dev_bridge/dispatcher/agent_apps.rs new file mode 100644 index 000000000..40ab18d3b --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/agent_apps.rs @@ -0,0 +1,170 @@ +use super::{args_or_default, parse_nested_arg, require_app_handle}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use tauri::Manager; + +pub async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, Box> { + let value = match cmd { + "agent_app_inspect_local_package" => { + let args = args_or_default(args); + let app_dir = args + .get("appDir") + .or_else(|| args.get("app_dir")) + .and_then(|value| value.as_str()) + .ok_or("缺少参数: appDir")? + .to_string(); + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_inspect_local_package(app_dir).await?, + )? + } + "agent_app_fetch_cloud_package" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_fetch_cloud_package(request).await?, + )? + } + "agent_app_save_installed_state" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_save_installed_state(request).await?, + )? + } + "agent_app_list_installed" => { + serde_json::to_value(crate::commands::agent_app_cmd::agent_app_list_installed().await?)? + } + "agent_app_set_disabled" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_set_disabled(request).await?, + )? + } + "agent_app_uninstall_rehearsal" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_uninstall_rehearsal(request).await?, + )? + } + "agent_app_uninstall" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_uninstall(request).await?, + )? + } + "agent_app_start_ui_runtime" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + let config = { state.server.read().await.config.clone() }; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_start_ui_runtime_for_dev_bridge( + request, + &config, + state.db.as_ref(), + Some(state.api_key_provider_service.as_ref()), + ) + .await?, + )? + } + "agent_app_get_ui_runtime_status" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_get_ui_runtime_status(request).await?, + )? + } + "agent_app_stop_ui_runtime" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + serde_json::to_value( + crate::commands::agent_app_cmd::agent_app_stop_ui_runtime(request).await?, + )? + } + "agent_app_runtime_start_task" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + let app_handle = require_app_handle(state)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + serde_json::to_value( + crate::commands::agent_app_runtime_cmd::agent_app_runtime_start_task( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_app_runtime_cancel_task" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + let app_handle = require_app_handle(state)?; + let aster_state = app_handle.state::(); + serde_json::to_value( + crate::commands::agent_app_runtime_cmd::agent_app_runtime_cancel_task( + app_handle.clone(), + aster_state, + request, + ) + .await?, + )? + } + "agent_app_runtime_get_task" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + let app_handle = require_app_handle(state)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + serde_json::to_value( + crate::commands::agent_app_runtime_cmd::agent_app_runtime_get_task( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_app_runtime_submit_host_response" => { + let request = parse_nested_arg(&args_or_default(args), "request")?; + let app_handle = require_app_handle(state)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + serde_json::to_value( + crate::commands::agent_app_runtime_cmd::agent_app_runtime_submit_host_response( + app_handle.clone(), + aster_state, + db, + request, + ) + .await?, + )? + } + _ => return Ok(None), + }; + + Ok(Some(value)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs b/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs deleted file mode 100644 index 4b658d4c1..000000000 --- a/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs +++ /dev/null @@ -1,121 +0,0 @@ -use super::{args_or_default, get_db, get_string_arg, parse_nested_arg, require_app_handle}; -use crate::dev_bridge::DevBridgeState; -use crate::sceneapp::application::SceneAppService; -use crate::services::execution_tracker_service::ExecutionTracker; -use serde_json::Value as JsonValue; -use tauri::Manager; - -type DynError = Box; - -pub(super) async fn try_handle( - state: &DevBridgeState, - cmd: &str, - args: Option<&JsonValue>, -) -> Result, DynError> { - if !matches!( - cmd, - "sceneapp_list_catalog" - | "sceneapp_get_descriptor" - | "sceneapp_plan_launch" - | "sceneapp_save_context_baseline" - | "sceneapp_create_automation_job" - | "sceneapp_list_runs" - | "sceneapp_get_run_summary" - | "sceneapp_prepare_run_governance_artifact" - | "sceneapp_get_scorecard" - ) { - return Ok(None); - } - - let args = args_or_default(args); - let result = match cmd { - "sceneapp_list_catalog" => { - serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_list_catalog().await?)? - } - "sceneapp_get_descriptor" => { - let id = get_string_arg(&args, "id", "id")?; - serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_get_descriptor(id).await?)? - } - "sceneapp_plan_launch" => { - let db = get_db(state)?; - let intent = - parse_nested_arg::(&args, "intent")?; - serde_json::to_value(SceneAppService::plan_launch(db, intent)?)? - } - "sceneapp_save_context_baseline" => { - let db = get_db(state)?; - let intent = - parse_nested_arg::(&args, "intent")?; - serde_json::to_value(SceneAppService::save_context_baseline(db, intent)?)? - } - "sceneapp_create_automation_job" => { - let app_handle = require_app_handle(state)?; - let automation_state = - app_handle.state::(); - let intent = parse_nested_arg::( - &args, "intent", - )?; - let service = automation_state.0.read().await; - serde_json::to_value(SceneAppService::create_automation_job(&service, intent).await?)? - } - "sceneapp_list_runs" => { - let db = get_db(state)?.clone(); - let app_handle = require_app_handle(state)?; - let automation_state = - app_handle.state::(); - let sceneapp_id = args - .get("sceneappId") - .or_else(|| args.get("sceneapp_id")) - .and_then(|value| value.as_str()) - .map(ToString::to_string); - let service = automation_state.0.read().await; - let tracker = ExecutionTracker::new(db); - serde_json::to_value(SceneAppService::collect_runs( - &tracker, - &service, - sceneapp_id.as_deref(), - )?)? - } - "sceneapp_get_run_summary" => { - let db = get_db(state)?.clone(); - let run_id = get_string_arg(&args, "runId", "run_id")?; - let tracker = ExecutionTracker::new(db); - if let Some(summary) = - SceneAppService::get_run_summary_from_tracker(&tracker, run_id.as_str())? - { - serde_json::to_value(Some(summary))? - } else { - serde_json::to_value(SceneAppService::get_run_summary(run_id.as_str()))? - } - } - "sceneapp_prepare_run_governance_artifact" => { - let db = get_db(state)?.clone(); - let run_id = get_string_arg(&args, "runId", "run_id")?; - let kind = parse_nested_arg::( - &args, "kind", - )?; - let tracker = ExecutionTracker::new(db); - serde_json::to_value(SceneAppService::prepare_run_governance_artifact( - &tracker, - run_id.as_str(), - &kind, - )?)? - } - "sceneapp_get_scorecard" => { - let db = get_db(state)?; - let app_handle = require_app_handle(state)?; - let automation_state = - app_handle.state::(); - let sceneapp_id = get_string_arg(&args, "sceneappId", "sceneapp_id")?; - let service = automation_state.0.read().await; - serde_json::to_value(SceneAppService::get_scorecard( - db, - &service, - sceneapp_id.as_str(), - )?)? - } - _ => unreachable!("已通过前置 matches! 过滤 sceneapp 命令"), - }; - - Ok(Some(result)) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65ac46b52..dfab0d067 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,7 +41,6 @@ pub use lime_mcp as mcp; pub mod agent; pub mod agent_tools; pub mod app; -pub mod sceneapp; pub mod services; pub mod skills; pub mod tray; diff --git a/src-tauri/src/sceneapp/adapters.rs b/src-tauri/src/sceneapp/adapters.rs deleted file mode 100644 index 52587c54b..000000000 --- a/src-tauri/src/sceneapp/adapters.rs +++ /dev/null @@ -1,2532 +0,0 @@ -use super::dto::*; -use crate::agent::AsterAgentWrapper; -use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; -use crate::database::dao::agent_run::{AgentRun, AgentRunStatus}; -use crate::database::DbConnection; -use crate::services::automation_service::{ - AutomationCycleResult, AutomationJobDraft, AutomationJobRecord, AutomationPayload, -}; -use crate::services::runtime_evidence_pack_service::{ - build_runtime_evidence_sceneapp_snapshot, export_runtime_evidence_pack, - resolve_runtime_export_workspace_root, RuntimeEvidenceSceneAppSnapshot, -}; -use crate::services::runtime_review_decision_service::export_runtime_review_decision_template; -use crate::services::thread_reliability_projection_service::sync_thread_reliability_projection; -use chrono::{DateTime, Utc}; -use lime_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; -use serde_json::{json, Map, Value}; -use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; - -const GENERAL_BROWSER_ASSIST_PROFILE_KEY: &str = "general_browser_assist"; -const SCENEAPP_RUNTIME_SESSION_RELATIVE_ROOT: &str = ".lime/harness/sessions"; -const SCENEAPP_EVIDENCE_SUMMARY_RELATIVE_PATH: &str = "evidence/summary.md"; -const SCENEAPP_REVIEW_DECISION_MARKDOWN_RELATIVE_PATH: &str = "review/review-decision.md"; -const SCENEAPP_REVIEW_DECISION_JSON_RELATIVE_PATH: &str = "review/review-decision.json"; - -struct SceneAppRunExportContext { - detail: crate::agent::SessionDetail, - thread_read: AgentRuntimeThreadReadModel, - workspace_root: PathBuf, -} - -fn normalize_optional_string(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn primary_binding_family(descriptor: &SceneAppDescriptor) -> SceneAppBindingFamily { - descriptor - .entry_bindings - .first() - .map(|binding| binding.binding_family.clone()) - .unwrap_or(SceneAppBindingFamily::AgentTurn) -} - -fn should_open_service_scene_session( - descriptor: &SceneAppDescriptor, - binding_family: &SceneAppBindingFamily, -) -> bool { - match binding_family { - SceneAppBindingFamily::ServiceSceneCompat => true, - SceneAppBindingFamily::AgentTurn => { - matches!( - descriptor.sceneapp_type, - SceneAppType::LocalInstant | SceneAppType::Hybrid - ) && (descriptor.linked_service_skill_id.is_some() - || descriptor.linked_scene_key.is_some()) - && descriptor.entry_bindings.iter().any(|binding| { - matches!( - binding.kind, - SceneAppEntryBindingKind::ServiceSkill | SceneAppEntryBindingKind::Scene - ) - }) - } - _ => false, - } -} - -fn default_sceneapp_schedule() -> TaskSchedule { - TaskSchedule::Every { every_secs: 3600 } -} - -fn default_sceneapp_delivery() -> DeliveryConfig { - DeliveryConfig::default() -} - -// 保持旧目录/旧 planner 的对外字符串合同;current 内部语义已改为 compat 命名。 -fn binding_family_to_string(binding: &SceneAppBindingFamily) -> &'static str { - match binding { - SceneAppBindingFamily::AgentTurn => "agent_turn", - SceneAppBindingFamily::BrowserAssist => "browser_assist", - SceneAppBindingFamily::AutomationJob => "automation_job", - SceneAppBindingFamily::ServiceSceneCompat => "cloud_scene", - SceneAppBindingFamily::NativeSkill => "native_skill", - } -} - -fn pattern_to_string(pattern: &SceneAppPattern) -> &'static str { - match pattern { - SceneAppPattern::Pipeline => "pipeline", - SceneAppPattern::Generator => "generator", - SceneAppPattern::Reviewer => "reviewer", - SceneAppPattern::Inversion => "inversion", - SceneAppPattern::ToolWrapper => "tool_wrapper", - } -} - -// 保持旧目录/旧 DTO 的对外字符串合同;current 内部语义已改为 compat 命名。 -fn sceneapp_type_to_string(sceneapp_type: &SceneAppType) -> &'static str { - match sceneapp_type { - SceneAppType::LocalInstant => "local_instant", - SceneAppType::LocalDurable => "local_durable", - SceneAppType::BrowserGrounded => "browser_grounded", - SceneAppType::DirectorySyncedCompat => "cloud_managed", - SceneAppType::Hybrid => "hybrid", - } -} - -fn delivery_contract_to_string(delivery_contract: &SceneAppDeliveryContract) -> &'static str { - match delivery_contract { - SceneAppDeliveryContract::ArtifactBundle => "artifact_bundle", - SceneAppDeliveryContract::ProjectPack => "project_pack", - SceneAppDeliveryContract::TableReport => "table_report", - } -} - -fn build_sceneapp_metadata_sceneapp_value(descriptor: &SceneAppDescriptor) -> Value { - json!({ - "id": descriptor.id.clone(), - "title": descriptor.title.clone(), - "sceneapp_type": sceneapp_type_to_string(&descriptor.sceneapp_type), - "pattern_primary": pattern_to_string(&descriptor.pattern_primary), - "pattern_stack": descriptor.pattern_stack.iter().map(pattern_to_string).collect::>(), - "infra_profile": descriptor.infra_profile.clone(), - "delivery_contract": delivery_contract_to_string(&descriptor.delivery_contract), - "source_package_id": descriptor.source_package_id.clone(), - "source_package_version": descriptor.source_package_version.clone(), - }) -} - -fn build_sceneapp_launch_harness_value( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, -) -> Value { - json!({ - "sceneapp_id": descriptor.id.clone(), - "sceneapp_type": sceneapp_type_to_string(&descriptor.sceneapp_type), - "pattern_primary": pattern_to_string(&descriptor.pattern_primary), - "pattern_stack": descriptor.pattern_stack.iter().map(pattern_to_string).collect::>(), - "infra_profile": descriptor.infra_profile.clone(), - "delivery_contract": delivery_contract_to_string(&descriptor.delivery_contract), - "entry_source": launch_intent.entry_source.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "linked_service_skill_id": descriptor.linked_service_skill_id.clone(), - "linked_scene_key": descriptor.linked_scene_key.clone(), - }) -} - -fn build_sceneapp_harness_map( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, -) -> Map { - let mut harness = Map::new(); - harness.insert("sceneapp_id".to_string(), json!(descriptor.id.clone())); - harness.insert( - "sceneapp_type".to_string(), - json!(sceneapp_type_to_string(&descriptor.sceneapp_type)), - ); - harness.insert( - "pattern_primary".to_string(), - json!(pattern_to_string(&descriptor.pattern_primary)), - ); - harness.insert( - "pattern_stack".to_string(), - json!(descriptor - .pattern_stack - .iter() - .map(pattern_to_string) - .collect::>()), - ); - harness.insert( - "infra_profile".to_string(), - json!(descriptor.infra_profile.clone()), - ); - harness.insert( - "entry_source".to_string(), - json!(launch_intent.entry_source.clone()), - ); - harness.insert( - "project_id".to_string(), - json!(launch_intent.project_id.clone()), - ); - harness.insert( - "workspace_id".to_string(), - json!(launch_intent.workspace_id.clone()), - ); - harness.insert( - "sceneapp_launch".to_string(), - build_sceneapp_launch_harness_value(descriptor, launch_intent), - ); - harness -} - -fn build_sceneapp_metadata_root( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, -) -> Map { - let mut root = Map::new(); - root.insert( - "sceneapp".to_string(), - build_sceneapp_metadata_sceneapp_value(descriptor), - ); - - if descriptor.linked_service_skill_id.is_some() || descriptor.linked_scene_key.is_some() { - root.insert( - "service_skill".to_string(), - json!({ - "id": descriptor.linked_service_skill_id.clone(), - "scene_key": descriptor.linked_scene_key.clone(), - }), - ); - } - - root.insert( - "harness".to_string(), - Value::Object(build_sceneapp_harness_map(descriptor, launch_intent)), - ); - - if !launch_intent.slots.is_empty() { - root.insert( - "sceneapp_slots".to_string(), - json!(launch_intent.slots.clone()), - ); - } - if !launch_intent.reference_memory_ids.is_empty() { - root.insert( - "sceneapp_reference_memory_ids".to_string(), - json!(launch_intent.reference_memory_ids.clone()), - ); - } - - root -} - -fn extract_launch_slot(launch_intent: &SceneAppLaunchIntent, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - launch_intent - .slots - .get(*key) - .map(String::as_str) - .and_then(|value| normalize_optional_string(Some(value))) - }) -} - -fn extract_first_url_candidate(value: &str) -> Option { - value - .split_whitespace() - .find(|segment| segment.starts_with("http://") || segment.starts_with("https://")) - .map(|segment| { - segment - .trim_end_matches(|ch: char| { - matches!( - ch, - '"' | '\'' | ')' | ']' | '}' | ',' | '.' | '>' | ',' | '。' | ')' - ) - }) - .to_string() - }) - .filter(|segment| !segment.is_empty()) -} - -fn browser_assist_adapter_name(descriptor: &SceneAppDescriptor) -> Option<&'static str> { - match descriptor.id.as_str() { - "x-article-export" => Some("x/article-export"), - _ => None, - } -} - -fn build_browser_assist_args( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, -) -> Map { - let mut args = Map::new(); - - if descriptor.id == "x-article-export" { - if let Some(url) = - extract_launch_slot(launch_intent, &["article_url", "url"]).or_else(|| { - launch_intent - .user_input - .as_deref() - .and_then(extract_first_url_candidate) - }) - { - args.insert("url".to_string(), json!(url)); - } - - if let Some(target_language) = - extract_launch_slot(launch_intent, &["target_language", "language"]) - { - args.insert("target_language".to_string(), json!(target_language)); - } - } - - if args.is_empty() { - if let Some(user_input) = normalize_optional_string(launch_intent.user_input.as_deref()) { - args.insert("prompt".to_string(), json!(user_input)); - } - if !launch_intent.slots.is_empty() { - args.insert("slots".to_string(), json!(launch_intent.slots.clone())); - } - } - - args -} - -fn build_sceneapp_runtime_request_metadata( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, - adapter_kind: &SceneAppBindingFamily, -) -> Map { - let mut root = build_sceneapp_metadata_root(descriptor, launch_intent); - let Some(Value::Object(harness)) = root.get_mut("harness") else { - return root; - }; - - if should_open_service_scene_session(descriptor, adapter_kind) { - harness.insert( - "service_scene_launch".to_string(), - json!({ - "kind": "local_service_skill", - "service_scene_run": { - "sceneapp_id": descriptor.id.clone(), - "scene_key": descriptor.linked_scene_key.clone(), - "linked_skill_id": descriptor.linked_service_skill_id.clone(), - "skill_id": descriptor.linked_service_skill_id.clone(), - "skill_title": descriptor.title.clone(), - "skill_summary": descriptor.summary.clone(), - "execution_kind": "local_service_skill", - "execution_location": "client_default", - "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - } - }), - ); - return root; - } - - match adapter_kind { - SceneAppBindingFamily::BrowserAssist => { - let adapter_name = browser_assist_adapter_name(descriptor) - .map(str::to_string) - .or_else(|| descriptor.linked_scene_key.clone()) - .unwrap_or_else(|| descriptor.id.clone()); - let args = build_browser_assist_args(descriptor, launch_intent); - - harness.insert("browser_requirement".to_string(), json!("required")); - harness.insert( - "browser_requirement_reason".to_string(), - json!("当前 SceneApp 依赖真实浏览器上下文与登录态,不应回退到纯 WebSearch。"), - ); - harness.insert( - "browser_assist".to_string(), - json!({ - "enabled": true, - "profile_key": GENERAL_BROWSER_ASSIST_PROFILE_KEY, - "preferred_backend": "lime_extension_bridge", - "auto_launch": false, - "stream_mode": "both", - }), - ); - harness.insert( - "service_skill_launch".to_string(), - json!({ - "kind": "site_adapter", - "skill_id": descriptor.linked_service_skill_id.clone(), - "skill_title": descriptor.title.clone(), - "adapter_name": adapter_name, - "args": Value::Object(args), - "save_mode": "project_resource", - "project_id": launch_intent.project_id.clone(), - }), - ); - } - SceneAppBindingFamily::AutomationJob => { - harness.insert( - "sceneapp_runtime_action".to_string(), - json!("create_automation_job"), - ); - } - SceneAppBindingFamily::NativeSkill => { - harness.insert( - "sceneapp_runtime_action".to_string(), - json!("launch_native_skill"), - ); - harness.insert( - "sceneapp_native_skill_launch".to_string(), - json!({ - "skill_id": descriptor.linked_service_skill_id.clone(), - "skill_key": descriptor.linked_scene_key.clone(), - "project_id": launch_intent.project_id.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }), - ); - } - SceneAppBindingFamily::AgentTurn => { - harness.insert( - "sceneapp_runtime_action".to_string(), - json!("submit_agent_turn"), - ); - } - SceneAppBindingFamily::ServiceSceneCompat => {} - } - - root -} - -fn run_status_from_agent_run(status: &AgentRunStatus) -> SceneAppRunStatus { - match status { - AgentRunStatus::Queued => SceneAppRunStatus::Queued, - AgentRunStatus::Running => SceneAppRunStatus::Running, - AgentRunStatus::Success => SceneAppRunStatus::Success, - AgentRunStatus::Error => SceneAppRunStatus::Error, - AgentRunStatus::Canceled => SceneAppRunStatus::Canceled, - AgentRunStatus::Timeout => SceneAppRunStatus::Timeout, - } -} - -fn run_status_from_job_status(status: Option<&str>) -> SceneAppRunStatus { - match status.unwrap_or("queued") { - "running" => SceneAppRunStatus::Running, - "success" => SceneAppRunStatus::Success, - "error" | "failed" => SceneAppRunStatus::Error, - "canceled" => SceneAppRunStatus::Canceled, - "timeout" => SceneAppRunStatus::Timeout, - _ => SceneAppRunStatus::Queued, - } -} - -#[derive(Debug, Clone, Default, PartialEq)] -struct SceneAppDeliveryState { - required_parts: Vec, - completed_parts: Vec, - missing_parts: Vec, - completion_rate: Option, - part_coverage_known: bool, - failure_signal: Option, -} - -fn round_percentage(value: f64) -> f64 { - (value * 10.0).round() / 10.0 -} - -fn count_delivery_artifacts(value: &Value) -> usize { - match value.get("delivery").and_then(Value::as_object) { - Some(delivery) - if delivery - .get("success") - .and_then(Value::as_bool) - .unwrap_or(false) => - { - 1 - } - _ => 0, - } -} - -fn extract_delivery_success_from_metadata_value(value: &Value) -> bool { - value - .get("delivery") - .and_then(Value::as_object) - .and_then(|delivery| delivery.get("success")) - .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn read_string_array(value: &Value, path: &[&str]) -> Vec { - extract_nested_value(value, path) - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .filter_map(|item| normalize_optional_string(Some(item))) - .collect::>() - }) - .unwrap_or_default() -} - -fn extract_artifact_paths_from_metadata_value(value: &Value) -> Vec { - let mut paths = Vec::new(); - for candidate in [ - read_string_array(value, &["artifact_paths"]), - read_string_array(value, &["request_metadata", "artifact_paths"]), - read_string_array(value, &["snapshot_metadata", "artifact_paths"]), - ] { - paths.extend(candidate); - } - - let mut deduped = BTreeSet::new(); - paths - .into_iter() - .filter(|path| deduped.insert(path.clone())) - .collect() -} - -fn extract_sceneapp_project_context_from_metadata_value( - parsed: &Value, -) -> (Option, Option) { - let candidates = [ - parsed.get("harness"), - extract_nested_value(parsed, &["request_metadata", "harness"]), - parsed.get("sceneapp_launch"), - parsed.get("sceneappLaunch"), - extract_nested_value(parsed, &["harness", "sceneapp_launch"]), - extract_nested_value(parsed, &["harness", "sceneappLaunch"]), - extract_nested_value(parsed, &["request_metadata", "sceneapp_launch"]), - extract_nested_value(parsed, &["request_metadata", "sceneappLaunch"]), - extract_nested_value(parsed, &["request_metadata", "harness", "sceneapp_launch"]), - extract_nested_value(parsed, &["request_metadata", "harness", "sceneappLaunch"]), - extract_nested_value(parsed, &["harness", "service_skill_launch"]), - extract_nested_value(parsed, &["harness", "serviceSkillLaunch"]), - extract_nested_value( - parsed, - &["request_metadata", "harness", "service_skill_launch"], - ), - extract_nested_value( - parsed, - &["request_metadata", "harness", "serviceSkillLaunch"], - ), - extract_nested_value(parsed, &["harness", "service_scene_launch"]), - extract_nested_value(parsed, &["harness", "serviceSceneLaunch"]), - extract_nested_value( - parsed, - &["request_metadata", "harness", "service_scene_launch"], - ), - extract_nested_value( - parsed, - &["request_metadata", "harness", "serviceSceneLaunch"], - ), - extract_nested_value(parsed, &["harness", "sceneapp_native_skill_launch"]), - extract_nested_value(parsed, &["harness", "sceneappNativeSkillLaunch"]), - extract_nested_value( - parsed, - &[ - "request_metadata", - "harness", - "sceneapp_native_skill_launch", - ], - ), - extract_nested_value( - parsed, - &["request_metadata", "harness", "sceneappNativeSkillLaunch"], - ), - ]; - - ( - read_first_string_from_value_objects(candidates.as_slice(), &["project_id", "projectId"]), - read_first_string_from_value_objects( - candidates.as_slice(), - &["workspace_id", "workspaceId"], - ), - ) -} - -fn normalize_match_token(value: &str) -> String { - value.trim().to_ascii_lowercase().replace('\\', "/") -} - -fn part_alias_candidates(part: &str) -> Vec { - let normalized = normalize_match_token(part); - if normalized.is_empty() { - return Vec::new(); - } - - let basename = normalized - .rsplit('/') - .next() - .map(str::to_string) - .unwrap_or_else(|| normalized.clone()); - let stem = basename - .split('.') - .next() - .map(str::to_string) - .unwrap_or_else(|| basename.clone()); - let token_aliases = normalized - .split(|ch: char| matches!(ch, '/' | '_' | '-' | '.')) - .map(str::trim) - .filter(|token| token.len() >= 3) - .filter(|token| !matches!(*token, "json" | "yaml" | "yml" | "txt" | "md")) - .map(str::to_string) - .collect::>(); - - let custom_aliases = match normalized.as_str() { - "brief" => vec!["summary", "outline", "task-brief"], - "storyboard" => vec!["wireframe", "shotlist", "scene-board"], - "script" => vec!["narration", "copy"], - "music_refs" => vec!["music", "bgm", "soundtrack"], - "video_draft" => vec!["video", "roughcut", "mp4", "movie"], - "review_note" => vec!["review", "qa", "feedback", "approval"], - "index.md" => vec!["index", "readme", "article"], - "meta.json" => vec!["meta", "metadata"], - "storyboard.json" => vec!["storyboard", "wireframe"], - "timeline.json" => vec!["timeline"], - "audio.mp3" => vec!["audio", "voice", "mp3"], - "media.json" => vec!["media"], - "form.json" => vec!["form"], - _ => Vec::new(), - }; - - let mut deduped = BTreeSet::new(); - std::iter::once(normalized) - .chain(std::iter::once(basename)) - .chain(std::iter::once(stem)) - .chain(token_aliases) - .chain(custom_aliases.into_iter().map(str::to_string)) - .filter(|alias| alias.len() >= 3) - .filter(|alias| deduped.insert(alias.clone())) - .collect() -} - -fn path_matches_part(path: &str, part: &str) -> bool { - let normalized_path = normalize_match_token(path); - if normalized_path.is_empty() { - return false; - } - - part_alias_candidates(part) - .into_iter() - .any(|candidate| normalized_path.contains(&candidate)) -} - -fn resolve_sceneapp_artifact_absolute_path( - workspace_root: Option<&str>, - path: &str, -) -> Option { - let normalized_path = normalize_optional_string(Some(path))?; - let candidate = PathBuf::from(&normalized_path); - if candidate.is_absolute() { - return Some(candidate.to_string_lossy().to_string()); - } - - let normalized_root = normalize_optional_string(workspace_root)?; - Some( - PathBuf::from(normalized_root) - .join(normalized_path.replace('/', std::path::MAIN_SEPARATOR_STR)) - .to_string_lossy() - .to_string(), - ) -} - -fn build_sceneapp_delivery_artifact_refs( - descriptor: Option<&SceneAppDescriptor>, - artifact_paths: &[String], - project_id: Option<&str>, - workspace_id: Option<&str>, - workspace_root: Option<&str>, - source: &str, -) -> Vec { - let required_parts = descriptor - .and_then(|item| item.delivery_profile.as_ref()) - .map(|profile| profile.required_parts.clone()) - .unwrap_or_default(); - let mut seen_paths = BTreeSet::new(); - - artifact_paths - .iter() - .filter_map(|path| { - let relative_path = normalize_optional_string(Some(path.as_str()))?; - if !seen_paths.insert(relative_path.clone()) { - return None; - } - - let part_key = required_parts - .iter() - .find(|part| path_matches_part(relative_path.as_str(), part.as_str())) - .cloned(); - - Some(SceneAppDeliveryArtifactRef { - relative_path: relative_path.clone(), - absolute_path: resolve_sceneapp_artifact_absolute_path( - workspace_root, - relative_path.as_str(), - ), - part_key, - project_id: normalize_optional_string(project_id), - workspace_id: normalize_optional_string(workspace_id), - source: source.to_string(), - }) - }) - .collect() -} - -fn build_sceneapp_session_relative_path(session_id: &str, suffix: &str) -> String { - format!("{SCENEAPP_RUNTIME_SESSION_RELATIVE_ROOT}/{session_id}/{suffix}") -} - -fn build_sceneapp_governance_artifact_ref( - kind: SceneAppGovernanceArtifactKind, - label: &str, - session_id: &str, - suffix: &str, - project_id: Option<&str>, - workspace_id: Option<&str>, - workspace_root: Option<&str>, -) -> SceneAppGovernanceArtifactRef { - let relative_path = build_sceneapp_session_relative_path(session_id, suffix); - - SceneAppGovernanceArtifactRef { - kind, - label: label.to_string(), - absolute_path: resolve_sceneapp_artifact_absolute_path( - workspace_root, - relative_path.as_str(), - ), - relative_path, - project_id: normalize_optional_string(project_id), - workspace_id: normalize_optional_string(workspace_id), - source: "session_governance".to_string(), - } -} - -fn build_sceneapp_governance_artifact_refs( - session_id: Option<&str>, - project_id: Option<&str>, - workspace_id: Option<&str>, - workspace_root: Option<&str>, -) -> Vec { - let Some(session_id) = normalize_optional_string(session_id) else { - return Vec::new(); - }; - - vec![ - build_sceneapp_governance_artifact_ref( - SceneAppGovernanceArtifactKind::EvidenceSummary, - "证据摘要", - session_id.as_str(), - SCENEAPP_EVIDENCE_SUMMARY_RELATIVE_PATH, - project_id, - workspace_id, - workspace_root, - ), - build_sceneapp_governance_artifact_ref( - SceneAppGovernanceArtifactKind::ReviewDecisionMarkdown, - "人工复核记录", - session_id.as_str(), - SCENEAPP_REVIEW_DECISION_MARKDOWN_RELATIVE_PATH, - project_id, - workspace_id, - workspace_root, - ), - build_sceneapp_governance_artifact_ref( - SceneAppGovernanceArtifactKind::ReviewDecisionJson, - "复核 JSON", - session_id.as_str(), - SCENEAPP_REVIEW_DECISION_JSON_RELATIVE_PATH, - project_id, - workspace_id, - workspace_root, - ), - ] -} - -fn is_review_part(part: &str) -> bool { - let normalized = normalize_match_token(part); - ["review", "qa", "approval", "approve", "feedback", "check"] - .iter() - .any(|token| normalized.contains(token)) -} - -fn is_planning_part(part: &str) -> bool { - let normalized = normalize_match_token(part); - [ - "brief", - "storyboard", - "script", - "outline", - "music", - "meta", - "timeline", - "plan", - "summary", - ] - .iter() - .any(|token| normalized.contains(token)) -} - -fn is_publish_part(part: &str) -> bool { - let normalized = normalize_match_token(part); - [ - "publish", "video", "audio", "poster", "cover", "media", "index", "article", "report", - "document", "output", "form", "draft", "final", - ] - .iter() - .any(|token| normalized.contains(token)) -} - -fn infer_review_parts(descriptor: &SceneAppDescriptor) -> Vec { - descriptor - .delivery_profile - .as_ref() - .map(|profile| { - profile - .required_parts - .iter() - .filter(|part| is_review_part(part)) - .cloned() - .collect::>() - }) - .unwrap_or_default() -} - -fn infer_publish_parts(descriptor: &SceneAppDescriptor) -> Vec { - let mut candidates = descriptor - .composition_profile - .as_ref() - .and_then(|profile| { - profile - .steps - .iter() - .rev() - .find(|step| !is_review_part(&step.id) && !is_planning_part(&step.id)) - .map(|step| vec![step.id.clone()]) - .or_else(|| { - profile - .steps - .iter() - .rev() - .find(|step| !is_review_part(&step.id)) - .map(|step| vec![step.id.clone()]) - }) - }) - .unwrap_or_default(); - - if candidates.is_empty() { - let required_parts = descriptor - .delivery_profile - .as_ref() - .map(|profile| profile.required_parts.clone()) - .unwrap_or_default(); - candidates = required_parts - .iter() - .filter(|part| !is_review_part(part) && is_publish_part(part)) - .cloned() - .collect::>(); - - if candidates.is_empty() { - if let Some(primary_part) = descriptor - .delivery_profile - .as_ref() - .and_then(|profile| profile.primary_part.clone()) - .filter(|part| !is_review_part(part)) - { - candidates.push(primary_part); - } else if let Some(last_non_review) = required_parts - .iter() - .rev() - .find(|part| !is_review_part(part)) - .cloned() - { - candidates.push(last_non_review); - } - } - } - - let mut deduped = BTreeSet::new(); - candidates - .into_iter() - .filter(|part| deduped.insert(part.clone())) - .collect() -} - -fn build_sceneapp_delivery_state( - descriptor: Option<&SceneAppDescriptor>, - status: &SceneAppRunStatus, - artifact_paths: &[String], - artifact_count: usize, - delivery_success: bool, - artifact_coverage_known: bool, - verification_failure_outcomes: &[String], -) -> SceneAppDeliveryState { - let Some(descriptor) = descriptor else { - return SceneAppDeliveryState::default(); - }; - - let required_parts = descriptor - .delivery_profile - .as_ref() - .map(|profile| profile.required_parts.clone()) - .unwrap_or_default(); - if required_parts.is_empty() { - return SceneAppDeliveryState { - failure_signal: if !verification_failure_outcomes.is_empty() - && descriptor.delivery_contract == SceneAppDeliveryContract::ProjectPack - { - Some("review_blocked".to_string()) - } else if matches!(status, SceneAppRunStatus::Timeout) - && descriptor - .scorecard_profile - .as_ref() - .is_some_and(|profile| { - profile - .failure_signals - .iter() - .any(|signal| signal == "automation_timeout") - }) - { - Some("automation_timeout".to_string()) - } else { - None - }, - ..SceneAppDeliveryState::default() - }; - } - - let mut completed_parts = required_parts - .iter() - .filter(|part| { - artifact_paths - .iter() - .any(|path| path_matches_part(path, part)) - }) - .cloned() - .collect::>(); - - let part_coverage_known = if artifact_coverage_known { - true - } else if artifact_paths.is_empty() { - if delivery_success && required_parts.len() == 1 { - completed_parts = vec![required_parts[0].clone()]; - true - } else { - false - } - } else { - true - }; - - let mut completed_dedup = BTreeSet::new(); - completed_parts.retain(|part| completed_dedup.insert(part.clone())); - - let missing_parts = if part_coverage_known { - required_parts - .iter() - .filter(|part| !completed_parts.contains(part)) - .cloned() - .collect::>() - } else { - Vec::new() - }; - - let completion_rate = if part_coverage_known { - Some(round_percentage( - (completed_parts.len() as f64 / required_parts.len() as f64) * 100.0, - )) - } else { - None - }; - - let review_parts = infer_review_parts(descriptor); - let publish_parts = infer_publish_parts(descriptor); - let missing_part_set = missing_parts.iter().cloned().collect::>(); - - let failure_signal = if descriptor.delivery_contract == SceneAppDeliveryContract::ProjectPack { - if !verification_failure_outcomes.is_empty() { - Some("review_blocked".to_string()) - } else if matches!(status, SceneAppRunStatus::Timeout) - && descriptor - .scorecard_profile - .as_ref() - .is_some_and(|profile| { - profile - .failure_signals - .iter() - .any(|signal| signal == "automation_timeout") - }) - { - Some("automation_timeout".to_string()) - } else if part_coverage_known && completed_parts.is_empty() { - Some("pack_incomplete".to_string()) - } else if review_parts - .iter() - .any(|part| missing_part_set.contains(part)) - { - Some("review_blocked".to_string()) - } else if publish_parts - .iter() - .any(|part| missing_part_set.contains(part)) - { - Some("publish_stalled".to_string()) - } else if part_coverage_known && !missing_parts.is_empty() { - Some("pack_incomplete".to_string()) - } else if !part_coverage_known - && (artifact_count == 0 - || matches!( - status, - SceneAppRunStatus::Error - | SceneAppRunStatus::Canceled - | SceneAppRunStatus::Timeout - )) - { - Some("pack_incomplete".to_string()) - } else { - None - } - } else if matches!(status, SceneAppRunStatus::Timeout) - && descriptor - .scorecard_profile - .as_ref() - .is_some_and(|profile| { - profile - .failure_signals - .iter() - .any(|signal| signal == "automation_timeout") - }) - { - Some("automation_timeout".to_string()) - } else if part_coverage_known && !missing_parts.is_empty() { - Some("pack_incomplete".to_string()) - } else { - None - }; - - SceneAppDeliveryState { - required_parts, - completed_parts, - missing_parts, - completion_rate, - part_coverage_known, - failure_signal, - } -} - -fn load_sceneapp_run_runtime_evidence( - db: &DbConnection, - run: &AgentRun, -) -> Option { - let context = load_sceneapp_run_export_context(db, run).ok()?; - - Some(build_runtime_evidence_sceneapp_snapshot( - &context.detail, - &context.thread_read, - Some(context.workspace_root.as_path()), - )) -} - -fn build_sceneapp_run_thread_read( - db: &DbConnection, - detail: &crate::agent::SessionDetail, -) -> AgentRuntimeThreadReadModel { - sync_thread_reliability_projection(db, detail) - .map(|projection| { - AgentRuntimeThreadReadModel::from_parts( - detail, - &[], - projection.pending_requests, - projection.last_outcome, - projection.incidents, - None, - ) - }) - .unwrap_or_else(|_| AgentRuntimeThreadReadModel::from_session_detail(detail, &[])) -} - -fn load_sceneapp_run_export_context( - db: &DbConnection, - run: &AgentRun, -) -> Result { - let session_id = normalize_optional_string(run.session_id.as_deref()) - .ok_or_else(|| "当前运行缺少 sessionId,无法导出治理制品".to_string())?; - let mut detail = AsterAgentWrapper::get_session_sync(db, session_id.as_str()) - .map_err(|error| format!("读取 SceneApp 运行会话失败: {error}"))?; - narrow_session_detail_to_run_window(&mut detail, run); - let thread_read = build_sceneapp_run_thread_read(db, &detail); - let workspace_root = resolve_runtime_export_workspace_root(db, &detail)?; - - Ok(SceneAppRunExportContext { - detail, - thread_read, - workspace_root, - }) -} - -pub fn prepare_sceneapp_run_governance_artifact( - db: &DbConnection, - run: &AgentRun, - kind: &SceneAppGovernanceArtifactKind, -) -> Result<(), String> { - let context = load_sceneapp_run_export_context(db, run)?; - - match kind { - SceneAppGovernanceArtifactKind::EvidenceSummary => { - export_runtime_evidence_pack( - &context.detail, - &context.thread_read, - context.workspace_root.as_path(), - )?; - } - SceneAppGovernanceArtifactKind::ReviewDecisionMarkdown - | SceneAppGovernanceArtifactKind::ReviewDecisionJson => { - export_runtime_review_decision_template( - &context.detail, - &context.thread_read, - context.workspace_root.as_path(), - )?; - } - } - - Ok(()) -} - -fn narrow_session_detail_to_run_window(detail: &mut crate::agent::SessionDetail, run: &AgentRun) { - let started_at = run.started_at.as_str(); - let finished_at = run.finished_at.as_deref(); - detail - .items - .retain(|item| timestamp_in_run_window(item.updated_at.as_str(), started_at, finished_at)); - - let allowed_turn_ids = detail - .items - .iter() - .map(|item| item.turn_id.clone()) - .collect::>(); - detail.turns.retain(|turn| { - allowed_turn_ids.contains(turn.id.as_str()) - || timestamp_in_run_window(turn.updated_at.as_str(), started_at, finished_at) - }); -} - -fn timestamp_in_run_window(timestamp: &str, started_at: &str, finished_at: Option<&str>) -> bool { - let Some(timestamp) = parse_rfc3339_utc(timestamp) else { - return true; - }; - let Some(started_at) = parse_rfc3339_utc(started_at) else { - return true; - }; - if timestamp < started_at { - return false; - } - - if let Some(finished_at) = finished_at.and_then(parse_rfc3339_utc) { - return timestamp <= finished_at; - } - - true -} - -fn parse_rfc3339_utc(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value.trim()) - .ok() - .map(|timestamp| timestamp.with_timezone(&Utc)) -} - -fn extract_nested_value<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - Some(current) -} - -fn read_first_string_from_value_objects( - candidates: &[Option<&Value>], - keys: &[&str], -) -> Option { - candidates.iter().find_map(|candidate| { - let object = candidate.and_then(|value| value.as_object())?; - keys.iter().find_map(|key| { - object - .get(*key) - .and_then(Value::as_str) - .and_then(|value| normalize_optional_string(Some(value))) - }) - }) -} - -fn read_string_map_from_value_objects( - candidates: &[Option<&Value>], - keys: &[&str], -) -> BTreeMap { - candidates - .iter() - .find_map(|candidate| { - let object = candidate.and_then(|value| value.as_object())?; - let slot_object = keys - .iter() - .find_map(|key| object.get(*key)) - .and_then(Value::as_object)?; - - let normalized = slot_object - .iter() - .filter_map(|(key, value)| { - let normalized_key = normalize_optional_string(Some(key.as_str()))?; - let normalized_value = - normalize_optional_string(value.as_str().map(str::trim))?; - Some((normalized_key, normalized_value)) - }) - .collect::>(); - - if normalized.is_empty() { - None - } else { - Some(normalized) - } - }) - .unwrap_or_default() -} - -fn read_nested_launch_context<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> { - let object = value.as_object()?; - keys.iter().find_map(|key| object.get(*key)) -} - -fn extract_service_scene_runtime_ref_from_metadata_value( - parsed: &Value, -) -> Option { - let launch_candidates = [ - parsed.get("service_scene_launch"), - parsed.get("serviceSceneLaunch"), - extract_nested_value(parsed, &["harness", "service_scene_launch"]), - extract_nested_value(parsed, &["harness", "serviceSceneLaunch"]), - extract_nested_value(parsed, &["request_metadata", "service_scene_launch"]), - extract_nested_value(parsed, &["request_metadata", "serviceSceneLaunch"]), - extract_nested_value( - parsed, - &["request_metadata", "harness", "service_scene_launch"], - ), - extract_nested_value( - parsed, - &["request_metadata", "harness", "serviceSceneLaunch"], - ), - ]; - let context_candidates = launch_candidates - .iter() - .map(|candidate| { - candidate.and_then(|value| { - read_nested_launch_context( - value, - &[ - "service_scene_run", - "serviceSceneRun", - "request_context", - "requestContext", - ], - ) - .or(Some(value)) - }) - }) - .collect::>(); - let candidates = context_candidates.as_slice(); - - let scene_key = read_first_string_from_value_objects(candidates, &["scene_key", "sceneKey"]); - let skill_id = read_first_string_from_value_objects( - candidates, - &["skill_id", "skillId", "linked_skill_id", "linkedSkillId"], - ); - let project_id = read_first_string_from_value_objects(candidates, &["project_id", "projectId"]); - let content_id = read_first_string_from_value_objects(candidates, &["content_id", "contentId"]); - let workspace_id = - read_first_string_from_value_objects(candidates, &["workspace_id", "workspaceId"]); - let entry_source = - read_first_string_from_value_objects(candidates, &["entry_source", "entrySource"]); - let user_input = read_first_string_from_value_objects(candidates, &["user_input", "userInput"]); - let slots = read_string_map_from_value_objects(candidates, &["slots"]); - - if scene_key.is_none() - && skill_id.is_none() - && project_id.is_none() - && content_id.is_none() - && workspace_id.is_none() - && entry_source.is_none() - && user_input.is_none() - && slots.is_empty() - { - return None; - } - - Some(SceneAppServiceSceneRuntimeRef { - scene_key, - skill_id, - project_id, - content_id, - workspace_id, - entry_source, - user_input, - slots, - }) -} - -fn extract_native_skill_runtime_ref_from_metadata_value( - parsed: &Value, -) -> Option { - let candidates = [ - parsed.get("sceneapp_native_skill_launch"), - parsed.get("sceneappNativeSkillLaunch"), - extract_nested_value(parsed, &["harness", "sceneapp_native_skill_launch"]), - extract_nested_value(parsed, &["harness", "sceneappNativeSkillLaunch"]), - extract_nested_value( - parsed, - &["request_metadata", "sceneapp_native_skill_launch"], - ), - extract_nested_value(parsed, &["request_metadata", "sceneappNativeSkillLaunch"]), - extract_nested_value( - parsed, - &[ - "request_metadata", - "harness", - "sceneapp_native_skill_launch", - ], - ), - extract_nested_value( - parsed, - &["request_metadata", "harness", "sceneappNativeSkillLaunch"], - ), - ]; - - let skill_id = - read_first_string_from_value_objects(candidates.as_slice(), &["skill_id", "skillId"]); - let skill_key = - read_first_string_from_value_objects(candidates.as_slice(), &["skill_key", "skillKey"]); - let project_id = - read_first_string_from_value_objects(candidates.as_slice(), &["project_id", "projectId"]); - let workspace_id = read_first_string_from_value_objects( - candidates.as_slice(), - &["workspace_id", "workspaceId"], - ); - let user_input = - read_first_string_from_value_objects(candidates.as_slice(), &["user_input", "userInput"]); - let slots = read_string_map_from_value_objects(candidates.as_slice(), &["slots"]); - - if skill_id.is_none() && skill_key.is_none() && slots.is_empty() { - return None; - } - - Some(SceneAppNativeSkillRuntimeRef { - skill_id, - skill_key, - project_id, - workspace_id, - user_input, - slots, - }) -} - -fn extract_browser_runtime_ref_from_metadata_value( - parsed: &Value, -) -> Option { - let candidates = [ - parsed.get("browser_runtime_ref"), - parsed.get("browserRuntimeRef"), - parsed.get("browser_session"), - parsed.get("browserSession"), - parsed.get("browser_assist"), - parsed.get("browserAssist"), - extract_nested_value(parsed, &["request_metadata", "browser_runtime_ref"]), - extract_nested_value(parsed, &["request_metadata", "browserRuntimeRef"]), - extract_nested_value(parsed, &["request_metadata", "browser_session"]), - extract_nested_value(parsed, &["request_metadata", "browserSession"]), - extract_nested_value(parsed, &["request_metadata", "browser_assist"]), - extract_nested_value(parsed, &["request_metadata", "browserAssist"]), - extract_nested_value(parsed, &["request_metadata", "harness", "browser_assist"]), - extract_nested_value(parsed, &["request_metadata", "harness", "browserAssist"]), - ]; - - let profile_key = - read_first_string_from_value_objects(&candidates, &["profile_key", "profileKey"]); - let session_id = - read_first_string_from_value_objects(&candidates, &["session_id", "sessionId"]); - let target_id = read_first_string_from_value_objects(&candidates, &["target_id", "targetId"]); - - if profile_key.is_none() && session_id.is_none() && target_id.is_none() { - return None; - } - - Some(SceneAppBrowserRuntimeRef { - profile_key, - session_id, - target_id, - }) -} - -fn ensure_automation_supported(descriptor: &SceneAppDescriptor) -> Result<(), String> { - let requires_browser = descriptor.launch_requirements.iter().any(|requirement| { - matches!( - requirement.kind, - SceneAppLaunchRequirementKind::BrowserSession - ) - }); - let uses_browser_binding = descriptor - .entry_bindings - .iter() - .any(|binding| matches!(binding.binding_family, SceneAppBindingFamily::BrowserAssist)); - - if requires_browser || uses_browser_binding { - return Err("当前 SceneApp 依赖浏览器上下文,暂不支持直接转为 automation job".to_string()); - } - - Ok(()) -} - -fn ensure_required_inputs( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, -) -> Result<(), String> { - let missing_user_input = - descriptor.launch_requirements.iter().any(|requirement| { - matches!(requirement.kind, SceneAppLaunchRequirementKind::UserInput) - }) && !launch_intent - .user_input - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - && !launch_intent - .slots - .values() - .any(|value| !value.trim().is_empty()); - if missing_user_input { - return Err("当前 SceneApp 缺少必填输入,无法创建自动化任务".to_string()); - } - - let missing_project = descriptor - .launch_requirements - .iter() - .any(|requirement| matches!(requirement.kind, SceneAppLaunchRequirementKind::Project)) - && !launch_intent - .project_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - if missing_project { - return Err("当前 SceneApp 需要 project_id 才能创建自动化任务".to_string()); - } - - if !launch_intent - .workspace_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - return Err("创建 SceneApp 自动化任务时必须提供 workspace_id".to_string()); - } - - Ok(()) -} - -fn build_sceneapp_request_metadata( - descriptor: &SceneAppDescriptor, - intent: &SceneAppAutomationIntent, -) -> Value { - Value::Object(build_sceneapp_metadata_root( - descriptor, - &intent.launch_intent, - )) -} - -fn build_sceneapp_runtime_action( - descriptor: &SceneAppDescriptor, - binding_family: &SceneAppBindingFamily, -) -> SceneAppRuntimeAction { - if should_open_service_scene_session(descriptor, binding_family) { - return SceneAppRuntimeAction::OpenServiceSceneSession; - } - - match binding_family { - SceneAppBindingFamily::AgentTurn => SceneAppRuntimeAction::SubmitAgentTurn, - SceneAppBindingFamily::BrowserAssist => SceneAppRuntimeAction::LaunchBrowserAssist, - SceneAppBindingFamily::AutomationJob => SceneAppRuntimeAction::CreateAutomationJob, - SceneAppBindingFamily::ServiceSceneCompat => SceneAppRuntimeAction::OpenServiceSceneSession, - SceneAppBindingFamily::NativeSkill => SceneAppRuntimeAction::LaunchNativeSkill, - } -} - -pub fn build_sceneapp_runtime_adapter_plan( - descriptor: &SceneAppDescriptor, - launch_intent: &SceneAppLaunchIntent, -) -> SceneAppRuntimeAdapterPlan { - let adapter_kind = primary_binding_family(descriptor); - let service_scene_session = should_open_service_scene_session(descriptor, &adapter_kind); - let runtime_action = build_sceneapp_runtime_action(descriptor, &adapter_kind); - let request_metadata = Value::Object(build_sceneapp_runtime_request_metadata( - descriptor, - launch_intent, - &adapter_kind, - )); - - let mut notes = vec![format!( - "当前 SceneApp 规划先映射到 {} 主链,再由后续 runtime adapter 负责真实执行。", - if service_scene_session { - "agent_turn" - } else { - binding_family_to_string(&adapter_kind) - } - )]; - - let preferred_profile_key = match adapter_kind { - SceneAppBindingFamily::BrowserAssist => { - Some(GENERAL_BROWSER_ASSIST_PROFILE_KEY.to_string()) - } - _ => None, - }; - - let target_ref = if service_scene_session { - descriptor - .linked_service_skill_id - .clone() - .or_else(|| descriptor.linked_scene_key.clone()) - .unwrap_or_else(|| descriptor.id.clone()) - } else { - match adapter_kind { - SceneAppBindingFamily::BrowserAssist => browser_assist_adapter_name(descriptor) - .map(str::to_string) - .or_else(|| descriptor.linked_scene_key.clone()) - .unwrap_or_else(|| descriptor.id.clone()), - SceneAppBindingFamily::ServiceSceneCompat - | SceneAppBindingFamily::NativeSkill - | SceneAppBindingFamily::AutomationJob => descriptor - .linked_service_skill_id - .clone() - .or_else(|| descriptor.linked_scene_key.clone()) - .unwrap_or_else(|| descriptor.id.clone()), - SceneAppBindingFamily::AgentTurn => descriptor.id.clone(), - } - }; - - let launch_payload = if service_scene_session { - if matches!(descriptor.sceneapp_type, SceneAppType::Hybrid) { - notes.push( - "当前 SceneApp 属于 hybrid,但首发执行会先进入场景技能入口;后续本地编排步骤由 composition blueprint 接续。" - .to_string(), - ); - } - - json!({ - "sceneapp_id": descriptor.id.clone(), - "scene_key": descriptor.linked_scene_key.clone(), - "service_skill_id": descriptor.linked_service_skill_id.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }) - } else { - match adapter_kind { - SceneAppBindingFamily::BrowserAssist => { - let args = build_browser_assist_args(descriptor, launch_intent); - if !args.contains_key("url") && descriptor.id == "x-article-export" { - notes.push( - "当前 planner 还无法仅凭 descriptor 判断 article_url 是否齐备;执行前应继续通过 scene gate 补齐目标链接。" - .to_string(), - ); - } - - json!({ - "sceneapp_id": descriptor.id.clone(), - "service_skill_id": descriptor.linked_service_skill_id.clone(), - "adapter_name": target_ref.clone(), - "profile_key": preferred_profile_key.clone(), - "args": Value::Object(args), - "project_id": launch_intent.project_id.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "save_mode": "project_resource", - }) - } - SceneAppBindingFamily::AutomationJob => { - notes.push("当前 planner 只生成 durable automation draft;具体 schedule、delivery 与 run-now 策略可继续由 UI 调整。".to_string()); - - json!({ - "sceneapp_id": descriptor.id.clone(), - "name": format!("{} 自动化", descriptor.title), - "enabled": true, - "execution_mode": "intelligent", - "schedule": { - "kind": "every", - "every_secs": 3600, - }, - "delivery": { - "mode": "none", - "channel": null, - "target": null, - "best_effort": false, - "output_schema": null, - "output_format": null, - }, - "launch_intent": { - "sceneapp_id": launch_intent.sceneapp_id.clone(), - "entry_source": launch_intent.entry_source.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - "runtime_context": launch_intent.runtime_context.clone(), - }, - }) - } - SceneAppBindingFamily::NativeSkill => { - notes.push( - "native_skill 目前仍建议由统一 SceneApp UI 继续补参后,再把 draft 投递给本地 skill 执行入口。" - .to_string(), - ); - - json!({ - "sceneapp_id": descriptor.id.clone(), - "service_skill_id": descriptor.linked_service_skill_id.clone(), - "skill_key": descriptor.linked_scene_key.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }) - } - SceneAppBindingFamily::AgentTurn => { - notes.push( - "agent_turn 类型 SceneApp 当前仍建议走统一聊天 turn,并把 sceneapp_launch metadata 合并进 request_metadata。" - .to_string(), - ); - - json!({ - "sceneapp_id": descriptor.id.clone(), - "message": launch_intent.user_input.clone().unwrap_or_default(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }) - } - SceneAppBindingFamily::ServiceSceneCompat => json!({ - "sceneapp_id": descriptor.id.clone(), - "scene_key": descriptor.linked_scene_key.clone(), - "service_skill_id": descriptor.linked_service_skill_id.clone(), - "workspace_id": launch_intent.workspace_id.clone(), - "project_id": launch_intent.project_id.clone(), - "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), - "user_input": launch_intent.user_input.clone(), - "reference_memory_ids": launch_intent.reference_memory_ids.clone(), - "slots": launch_intent.slots.clone(), - }), - } - }; - - SceneAppRuntimeAdapterPlan { - adapter_kind, - runtime_action, - target_ref, - target_label: descriptor.title.clone(), - linked_service_skill_id: descriptor.linked_service_skill_id.clone(), - linked_scene_key: descriptor.linked_scene_key.clone(), - preferred_profile_key, - request_metadata, - launch_payload, - notes, - } -} - -fn build_sceneapp_automation_prompt( - descriptor: &SceneAppDescriptor, - intent: &SceneAppAutomationIntent, -) -> String { - let mut sections = vec![ - format!("SceneApp:{}", descriptor.title.as_str()), - format!("场景摘要:{}", descriptor.summary.as_str()), - format!( - "运行形态:{}", - sceneapp_type_to_string(&descriptor.sceneapp_type) - ), - format!( - "模式组合:{}", - descriptor - .pattern_stack - .iter() - .map(pattern_to_string) - .collect::>() - .join(" + ") - ), - format!( - "交付合同:{}", - delivery_contract_to_string(&descriptor.delivery_contract) - ), - ]; - - if let Some(user_input) = normalize_optional_string(intent.launch_intent.user_input.as_deref()) - { - sections.push(format!("用户目标:{user_input}")); - } - - if !intent.launch_intent.slots.is_empty() { - let slot_lines = intent - .launch_intent - .slots - .iter() - .map(|(key, value)| format!("- {key}: {value}")) - .collect::>() - .join("\n"); - sections.push(format!("结构化补参:\n{slot_lines}")); - } - - if let Some(project_id) = normalize_optional_string(intent.launch_intent.project_id.as_deref()) - { - sections.push(format!("项目 ID:{project_id}")); - } - - sections.push("请严格围绕上述 SceneApp 合同执行任务,并把结果沉淀为可复用资产。".to_string()); - sections.join("\n\n") -} - -pub fn build_sceneapp_automation_draft( - descriptor: &SceneAppDescriptor, - intent: &SceneAppAutomationIntent, -) -> Result { - ensure_automation_supported(descriptor)?; - ensure_required_inputs(descriptor, &intent.launch_intent)?; - - let workspace_id = intent - .launch_intent - .workspace_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "创建 SceneApp 自动化任务时必须提供 workspace_id".to_string())? - .to_string(); - - let job_name = normalize_optional_string(intent.name.as_deref()) - .unwrap_or_else(|| format!("{} 自动化", descriptor.title.as_str())); - let description = normalize_optional_string(intent.description.as_deref()).or_else(|| { - Some(format!( - "由 SceneApp {} 派生的自动化任务,按统一 descriptor 与治理合同执行。", - descriptor.title.as_str() - )) - }); - - Ok(AutomationJobDraft { - name: job_name, - description, - enabled: intent.enabled.unwrap_or(true), - workspace_id, - execution_mode: intent - .execution_mode - .unwrap_or(AutomationExecutionMode::Intelligent), - schedule: intent - .schedule - .clone() - .unwrap_or_else(default_sceneapp_schedule), - payload: AutomationPayload::AgentTurn { - prompt: build_sceneapp_automation_prompt(descriptor, intent), - system_prompt: Some(format!( - "你正在执行 SceneApp 自动化任务。binding_family={},请优先遵守 SceneApp 合同和结果交付要求。", - descriptor - .entry_bindings - .first() - .map(|binding| binding_family_to_string(&binding.binding_family)) - .unwrap_or("agent_turn") - )), - web_search: false, - approval_policy: Some("never".to_string()), - sandbox_policy: Some("danger-full-access".to_string()), - request_metadata: Some(build_sceneapp_request_metadata(descriptor, intent)), - content_id: None, - }, - delivery: intent.delivery.clone().unwrap_or_else(default_sceneapp_delivery), - timeout_secs: intent.timeout_secs, - max_retries: intent.max_retries.unwrap_or(3), - }) -} - -pub fn build_sceneapp_automation_result( - descriptor: &SceneAppDescriptor, - job: &AutomationJobRecord, - run_now_result: Option, -) -> SceneAppAutomationResult { - SceneAppAutomationResult { - sceneapp_id: descriptor.id.clone(), - job_id: job.id.clone(), - job_name: job.name.clone(), - enabled: job.enabled, - workspace_id: job.workspace_id.clone(), - next_run_at: job.next_run_at.clone(), - run_now_result: run_now_result.map(|result| SceneAppAutomationRunResult { - job_count: result.job_count, - success_count: result.success_count, - failed_count: result.failed_count, - timeout_count: result.timeout_count, - }), - } -} - -pub fn extract_sceneapp_id_from_automation_job(job: &AutomationJobRecord) -> Option { - job.payload - .as_object() - .and_then(|payload| payload.get("request_metadata")) - .and_then(Value::as_object) - .and_then(|metadata| metadata.get("sceneapp")) - .and_then(Value::as_object) - .and_then(|sceneapp| sceneapp.get("id")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -pub fn extract_sceneapp_id_from_run_metadata(run: &AgentRun) -> Option { - let parsed = run - .metadata - .as_deref() - .and_then(|raw| serde_json::from_str::(raw).ok())?; - - parsed - .get("sceneapp") - .and_then(|value| value.get("id")) - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - parsed - .get("harness") - .and_then(|value| value.get("sceneapp_id")) - .and_then(Value::as_str) - .map(str::to_string) - }) -} - -fn build_sceneapp_run_summary_from_agent_run_inner( - run: &AgentRun, - descriptor: Option<&SceneAppDescriptor>, - sceneapp_id: String, - runtime_evidence: Option<&RuntimeEvidenceSceneAppSnapshot>, -) -> SceneAppRunSummary { - let parsed = run - .metadata - .as_deref() - .and_then(|raw| serde_json::from_str::(raw).ok()); - let status = run_status_from_agent_run(&run.status); - let metadata_artifact_paths = parsed - .as_ref() - .map(extract_artifact_paths_from_metadata_value) - .unwrap_or_default(); - let metadata_artifact_count = metadata_artifact_paths - .len() - .max(parsed.as_ref().map(count_delivery_artifacts).unwrap_or(0)); - let artifact_paths = runtime_evidence - .map(|snapshot| snapshot.recent_artifact_paths.clone()) - .unwrap_or_else(|| metadata_artifact_paths.clone()); - let artifact_count = runtime_evidence - .map(|snapshot| snapshot.recent_artifact_paths.len()) - .unwrap_or(metadata_artifact_count); - let (project_id, workspace_id) = parsed - .as_ref() - .map(extract_sceneapp_project_context_from_metadata_value) - .unwrap_or((None, None)); - let delivery_artifact_refs = build_sceneapp_delivery_artifact_refs( - descriptor, - &artifact_paths, - project_id.as_deref(), - workspace_id.as_deref(), - runtime_evidence.and_then(|snapshot| snapshot.workspace_root.as_deref()), - if runtime_evidence.is_some() { - "runtime_evidence" - } else { - "metadata_fallback" - }, - ); - let governance_artifact_refs = build_sceneapp_governance_artifact_refs( - run.session_id.as_deref(), - project_id.as_deref(), - workspace_id.as_deref(), - runtime_evidence.and_then(|snapshot| snapshot.workspace_root.as_deref()), - ); - let verification_failure_outcomes = runtime_evidence - .map(|snapshot| snapshot.verification_failure_outcomes.clone()) - .unwrap_or_default(); - let runtime_evidence_used = runtime_evidence.is_some(); - let delivery_state = build_sceneapp_delivery_state( - descriptor, - &status, - &artifact_paths, - artifact_count, - parsed - .as_ref() - .map(extract_delivery_success_from_metadata_value) - .unwrap_or(false), - runtime_evidence.is_some(), - verification_failure_outcomes.as_slice(), - ); - - SceneAppRunSummary { - run_id: run.id.clone(), - sceneapp_id, - status, - source: run.source.clone(), - source_ref: run.source_ref.clone(), - session_id: run.session_id.clone(), - browser_runtime_ref: parsed - .as_ref() - .and_then(extract_browser_runtime_ref_from_metadata_value), - service_scene_runtime_ref: parsed - .as_ref() - .and_then(extract_service_scene_runtime_ref_from_metadata_value), - native_skill_runtime_ref: parsed - .as_ref() - .and_then(extract_native_skill_runtime_ref_from_metadata_value), - started_at: run.started_at.clone(), - finished_at: run.finished_at.clone(), - artifact_count, - delivery_artifact_refs, - governance_artifact_refs, - delivery_required_parts: delivery_state.required_parts, - delivery_completed_parts: delivery_state.completed_parts, - delivery_missing_parts: delivery_state.missing_parts, - delivery_completion_rate: delivery_state.completion_rate, - delivery_part_coverage_known: delivery_state.part_coverage_known, - failure_signal: delivery_state.failure_signal, - runtime_evidence_used, - evidence_known_gaps: runtime_evidence - .map(|snapshot| snapshot.known_gaps.clone()) - .unwrap_or_default(), - verification_failure_outcomes, - request_telemetry_available: runtime_evidence - .map(|snapshot| snapshot.request_telemetry_available), - request_telemetry_matched_count: runtime_evidence - .map(|snapshot| snapshot.request_telemetry_matched_count), - artifact_validator_applicable: runtime_evidence - .map(|snapshot| snapshot.artifact_validator_applicable), - artifact_validator_issue_count: runtime_evidence - .map(|snapshot| snapshot.artifact_validator_issue_count), - artifact_validator_recovered_count: runtime_evidence - .map(|snapshot| snapshot.artifact_validator_recovered_count), - } -} - -pub fn build_sceneapp_run_summary_from_agent_run( - run: &AgentRun, - descriptor: Option<&SceneAppDescriptor>, - sceneapp_id: String, -) -> SceneAppRunSummary { - build_sceneapp_run_summary_from_agent_run_inner(run, descriptor, sceneapp_id, None) -} - -pub fn build_sceneapp_run_summary_from_agent_run_with_db( - db: &DbConnection, - run: &AgentRun, - descriptor: Option<&SceneAppDescriptor>, - sceneapp_id: String, -) -> SceneAppRunSummary { - let runtime_evidence = load_sceneapp_run_runtime_evidence(db, run); - build_sceneapp_run_summary_from_agent_run_inner( - run, - descriptor, - sceneapp_id, - runtime_evidence.as_ref(), - ) -} - -pub fn build_sceneapp_run_summary_from_automation_job( - job: &AutomationJobRecord, - descriptor: Option<&SceneAppDescriptor>, - sceneapp_id: String, -) -> SceneAppRunSummary { - let status = run_status_from_job_status(job.last_status.as_deref()); - let artifact_count = job - .last_delivery - .as_ref() - .filter(|delivery| delivery.success) - .map(|_| 1usize) - .unwrap_or(0); - let delivery_state = build_sceneapp_delivery_state( - descriptor, - &status, - &[], - artifact_count, - job.last_delivery - .as_ref() - .map(|delivery| delivery.success) - .unwrap_or(false), - false, - &[], - ); - - SceneAppRunSummary { - run_id: format!("automation-job:{}", job.id), - sceneapp_id, - status, - source: "automation".to_string(), - source_ref: Some(job.id.clone()), - session_id: None, - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: job - .last_run_at - .clone() - .unwrap_or_else(|| job.created_at.clone()), - finished_at: job.last_finished_at.clone(), - artifact_count, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: delivery_state.required_parts, - delivery_completed_parts: delivery_state.completed_parts, - delivery_missing_parts: delivery_state.missing_parts, - delivery_completion_rate: delivery_state.completion_rate, - delivery_part_coverage_known: delivery_state.part_coverage_known, - failure_signal: delivery_state.failure_signal, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::dao::agent_run::{AgentRun, AgentRunStatus}; - use crate::database::dao::automation_job::AutomationJob; - use crate::database::dao::automation_job::AutomationJobLastDelivery; - use crate::sceneapp::catalog::get_sceneapp_descriptor; - use lime_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; - - fn create_agent_run(metadata: Value) -> AgentRun { - AgentRun { - id: "run-1".to_string(), - source: "chat".to_string(), - source_ref: None, - session_id: Some("session-1".to_string()), - status: AgentRunStatus::Success, - started_at: "2026-04-15T00:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T00:01:00.000Z".to_string()), - duration_ms: Some(60_000), - error_code: None, - error_message: None, - metadata: Some(metadata.to_string()), - created_at: "2026-04-15T00:00:00.000Z".to_string(), - updated_at: "2026-04-15T00:01:00.000Z".to_string(), - } - } - - fn create_automation_job( - last_delivery: Option, - ) -> AutomationJobRecord { - AutomationJob { - id: "job-1".to_string(), - name: "SceneApp automation".to_string(), - description: None, - enabled: true, - workspace_id: "workspace-1".to_string(), - execution_mode: AutomationExecutionMode::Intelligent, - schedule: TaskSchedule::Every { every_secs: 3600 }, - payload: json!({}), - delivery: DeliveryConfig::default(), - timeout_secs: None, - max_retries: 1, - next_run_at: None, - last_status: Some("success".to_string()), - last_error: None, - last_run_at: Some("2026-04-15T00:00:00.000Z".to_string()), - last_finished_at: Some("2026-04-15T00:03:00.000Z".to_string()), - running_started_at: None, - consecutive_failures: 0, - last_retry_count: 0, - auto_disabled_until: None, - last_delivery, - created_at: "2026-04-15T00:00:00.000Z".to_string(), - updated_at: "2026-04-15T00:03:00.000Z".to_string(), - } - } - - fn create_runtime_evidence( - artifact_paths: &[&str], - verification_failure_outcomes: &[&str], - ) -> RuntimeEvidenceSceneAppSnapshot { - RuntimeEvidenceSceneAppSnapshot { - recent_artifact_paths: artifact_paths.iter().map(|path| path.to_string()).collect(), - workspace_root: Some("/tmp/workspace".to_string()), - known_gaps: Vec::new(), - verification_failure_outcomes: verification_failure_outcomes - .iter() - .map(|item| item.to_string()) - .collect(), - request_telemetry_available: false, - request_telemetry_matched_count: 0, - artifact_validator_applicable: false, - artifact_validator_issue_count: 0, - artifact_validator_recovered_count: 0, - } - } - - #[test] - fn build_sceneapp_run_summary_from_agent_run_should_extract_delivery_parts() { - let descriptor = - get_sceneapp_descriptor("story-video-suite").expect("descriptor should exist"); - let run = create_agent_run(json!({ - "sceneapp": { "id": "story-video-suite" }, - "artifact_paths": [ - "artifacts/brief.md", - "artifacts/storyboard.json", - "artifacts/video_draft.mp4" - ], - })); - - let summary = build_sceneapp_run_summary_from_agent_run( - &run, - Some(&descriptor), - "story-video-suite".to_string(), - ); - - assert_eq!(summary.artifact_count, 3); - assert_eq!( - summary.delivery_artifact_refs, - vec![ - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/brief.md".to_string(), - absolute_path: None, - part_key: Some("brief".to_string()), - project_id: None, - workspace_id: None, - source: "metadata_fallback".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/storyboard.json".to_string(), - absolute_path: None, - part_key: Some("storyboard".to_string()), - project_id: None, - workspace_id: None, - source: "metadata_fallback".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/video_draft.mp4".to_string(), - absolute_path: None, - part_key: Some("video_draft".to_string()), - project_id: None, - workspace_id: None, - source: "metadata_fallback".to_string(), - }, - ] - ); - assert_eq!(summary.source_ref, None); - assert_eq!(summary.session_id.as_deref(), Some("session-1")); - assert_eq!( - summary.governance_artifact_refs, - vec![ - SceneAppGovernanceArtifactRef { - kind: SceneAppGovernanceArtifactKind::EvidenceSummary, - label: "证据摘要".to_string(), - relative_path: ".lime/harness/sessions/session-1/evidence/summary.md" - .to_string(), - absolute_path: None, - project_id: None, - workspace_id: None, - source: "session_governance".to_string(), - }, - SceneAppGovernanceArtifactRef { - kind: SceneAppGovernanceArtifactKind::ReviewDecisionMarkdown, - label: "人工复核记录".to_string(), - relative_path: ".lime/harness/sessions/session-1/review/review-decision.md" - .to_string(), - absolute_path: None, - project_id: None, - workspace_id: None, - source: "session_governance".to_string(), - }, - SceneAppGovernanceArtifactRef { - kind: SceneAppGovernanceArtifactKind::ReviewDecisionJson, - label: "复核 JSON".to_string(), - relative_path: ".lime/harness/sessions/session-1/review/review-decision.json" - .to_string(), - absolute_path: None, - project_id: None, - workspace_id: None, - source: "session_governance".to_string(), - }, - ] - ); - assert_eq!(summary.browser_runtime_ref, None); - assert_eq!(summary.service_scene_runtime_ref, None); - assert_eq!(summary.native_skill_runtime_ref, None); - assert_eq!( - summary.delivery_completed_parts, - vec![ - "brief".to_string(), - "storyboard".to_string(), - "video_draft".to_string(), - ] - ); - assert_eq!( - summary.delivery_missing_parts, - vec![ - "script".to_string(), - "music_refs".to_string(), - "review_note".to_string(), - ] - ); - assert_eq!(summary.delivery_completion_rate, Some(50.0)); - assert!(summary.delivery_part_coverage_known); - assert_eq!(summary.failure_signal.as_deref(), Some("review_blocked")); - } - - #[test] - fn build_sceneapp_run_summary_from_agent_run_should_extract_browser_runtime_ref() { - let run = create_agent_run(json!({ - "sceneapp": { "id": "x-article-export" }, - "browser_runtime_ref": { - "session_id": "browser-session-1", - "target_id": "target-1" - }, - "request_metadata": { - "browser_assist": { - "profile_key": "general_browser_assist" - } - } - })); - - let summary = - build_sceneapp_run_summary_from_agent_run(&run, None, "x-article-export".to_string()); - - assert_eq!( - summary.browser_runtime_ref, - Some(SceneAppBrowserRuntimeRef { - profile_key: Some("general_browser_assist".to_string()), - session_id: Some("browser-session-1".to_string()), - target_id: Some("target-1".to_string()), - }) - ); - assert_eq!(summary.service_scene_runtime_ref, None); - assert_eq!(summary.native_skill_runtime_ref, None); - } - - #[test] - fn build_sceneapp_run_summary_from_agent_run_should_extract_service_scene_runtime_ref() { - let run = create_agent_run(json!({ - "sceneapp": { "id": "story-video-suite" }, - "request_metadata": { - "harness": { - "service_scene_launch": { - "kind": "local_service_skill", - "service_scene_run": { - "scene_key": "story-video-suite", - "skill_id": "sceneapp-service-story-video", - "project_id": "project-video", - "content_id": "content-video-1", - "workspace_id": "workspace-video", - "entry_source": "sceneapp_plan", - "user_input": "生成一版产品短视频", - "slots": { - "duration": "30 秒" - } - } - } - } - } - })); - - let summary = - build_sceneapp_run_summary_from_agent_run(&run, None, "story-video-suite".to_string()); - - assert_eq!( - summary.service_scene_runtime_ref, - Some(SceneAppServiceSceneRuntimeRef { - scene_key: Some("story-video-suite".to_string()), - skill_id: Some("sceneapp-service-story-video".to_string()), - project_id: Some("project-video".to_string()), - content_id: Some("content-video-1".to_string()), - workspace_id: Some("workspace-video".to_string()), - entry_source: Some("sceneapp_plan".to_string()), - user_input: Some("生成一版产品短视频".to_string()), - slots: BTreeMap::from([("duration".to_string(), "30 秒".to_string())]), - }) - ); - assert_eq!(summary.native_skill_runtime_ref, None); - } - - #[test] - fn build_sceneapp_run_summary_from_agent_run_should_extract_native_skill_runtime_ref() { - let run = create_agent_run(json!({ - "sceneapp": { "id": "project-analysis-copilot" }, - "request_metadata": { - "harness": { - "sceneapp_native_skill_launch": { - "skill_id": "sceneapp-service-analysis", - "skill_key": "project-analysis", - "project_id": "project-analysis", - "workspace_id": "workspace-analysis", - "user_input": "请分析当前项目结构", - "slots": { - "focus": "架构", - "depth": "高" - } - } - } - } - })); - - let summary = build_sceneapp_run_summary_from_agent_run( - &run, - None, - "project-analysis-copilot".to_string(), - ); - - assert_eq!( - summary.native_skill_runtime_ref, - Some(SceneAppNativeSkillRuntimeRef { - skill_id: Some("sceneapp-service-analysis".to_string()), - skill_key: Some("project-analysis".to_string()), - project_id: Some("project-analysis".to_string()), - workspace_id: Some("workspace-analysis".to_string()), - user_input: Some("请分析当前项目结构".to_string()), - slots: BTreeMap::from([ - ("depth".to_string(), "高".to_string()), - ("focus".to_string(), "架构".to_string()), - ]), - }) - ); - } - - #[test] - fn build_sceneapp_run_summary_should_prefer_runtime_evidence_artifacts() { - let descriptor = - get_sceneapp_descriptor("story-video-suite").expect("descriptor should exist"); - let run = create_agent_run(json!({ - "sceneapp": { "id": "story-video-suite" }, - "artifact_paths": ["artifacts/brief.md"], - })); - let runtime_evidence = create_runtime_evidence( - &[ - "artifacts/brief.md", - "artifacts/storyboard.json", - "artifacts/script.md", - "artifacts/music_refs.md", - "artifacts/video_draft.mp4", - "artifacts/review_note.md", - ], - &[], - ); - - let summary = build_sceneapp_run_summary_from_agent_run_inner( - &run, - Some(&descriptor), - "story-video-suite".to_string(), - Some(&runtime_evidence), - ); - - assert_eq!(summary.artifact_count, 6); - assert_eq!( - summary.delivery_completed_parts, - vec![ - "brief".to_string(), - "storyboard".to_string(), - "script".to_string(), - "music_refs".to_string(), - "video_draft".to_string(), - "review_note".to_string(), - ] - ); - assert!(summary.delivery_missing_parts.is_empty()); - assert_eq!(summary.delivery_completion_rate, Some(100.0)); - assert!(summary.delivery_part_coverage_known); - assert_eq!(summary.failure_signal, None); - assert!(summary.runtime_evidence_used); - assert_eq!( - summary.delivery_artifact_refs, - vec![ - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/brief.md".to_string(), - absolute_path: Some("/tmp/workspace/artifacts/brief.md".to_string()), - part_key: Some("brief".to_string()), - project_id: None, - workspace_id: None, - source: "runtime_evidence".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/storyboard.json".to_string(), - absolute_path: Some("/tmp/workspace/artifacts/storyboard.json".to_string()), - part_key: Some("storyboard".to_string()), - project_id: None, - workspace_id: None, - source: "runtime_evidence".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/script.md".to_string(), - absolute_path: Some("/tmp/workspace/artifacts/script.md".to_string()), - part_key: Some("script".to_string()), - project_id: None, - workspace_id: None, - source: "runtime_evidence".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/music_refs.md".to_string(), - absolute_path: Some("/tmp/workspace/artifacts/music_refs.md".to_string()), - part_key: Some("music_refs".to_string()), - project_id: None, - workspace_id: None, - source: "runtime_evidence".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/video_draft.mp4".to_string(), - absolute_path: Some("/tmp/workspace/artifacts/video_draft.mp4".to_string()), - part_key: Some("video_draft".to_string()), - project_id: None, - workspace_id: None, - source: "runtime_evidence".to_string(), - }, - SceneAppDeliveryArtifactRef { - relative_path: "artifacts/review_note.md".to_string(), - absolute_path: Some("/tmp/workspace/artifacts/review_note.md".to_string()), - part_key: Some("review_note".to_string()), - project_id: None, - workspace_id: None, - source: "runtime_evidence".to_string(), - }, - ] - ); - assert_eq!( - summary.governance_artifact_refs, - vec![ - SceneAppGovernanceArtifactRef { - kind: SceneAppGovernanceArtifactKind::EvidenceSummary, - label: "证据摘要".to_string(), - relative_path: ".lime/harness/sessions/session-1/evidence/summary.md" - .to_string(), - absolute_path: Some( - "/tmp/workspace/.lime/harness/sessions/session-1/evidence/summary.md" - .to_string() - ), - project_id: None, - workspace_id: None, - source: "session_governance".to_string(), - }, - SceneAppGovernanceArtifactRef { - kind: SceneAppGovernanceArtifactKind::ReviewDecisionMarkdown, - label: "人工复核记录".to_string(), - relative_path: - ".lime/harness/sessions/session-1/review/review-decision.md" - .to_string(), - absolute_path: Some( - "/tmp/workspace/.lime/harness/sessions/session-1/review/review-decision.md" - .to_string() - ), - project_id: None, - workspace_id: None, - source: "session_governance".to_string(), - }, - SceneAppGovernanceArtifactRef { - kind: SceneAppGovernanceArtifactKind::ReviewDecisionJson, - label: "复核 JSON".to_string(), - relative_path: - ".lime/harness/sessions/session-1/review/review-decision.json" - .to_string(), - absolute_path: Some( - "/tmp/workspace/.lime/harness/sessions/session-1/review/review-decision.json" - .to_string() - ), - project_id: None, - workspace_id: None, - source: "session_governance".to_string(), - }, - ] - ); - assert_eq!(summary.request_telemetry_available, Some(false)); - assert_eq!(summary.request_telemetry_matched_count, Some(0)); - } - - #[test] - fn build_sceneapp_run_summary_should_mark_review_blocked_on_runtime_verification_failure() { - let descriptor = - get_sceneapp_descriptor("story-video-suite").expect("descriptor should exist"); - let run = create_agent_run(json!({ - "sceneapp": { "id": "story-video-suite" }, - })); - let runtime_evidence = create_runtime_evidence( - &[ - "artifacts/brief.md", - "artifacts/storyboard.json", - "artifacts/script.md", - "artifacts/music_refs.md", - "artifacts/video_draft.mp4", - "artifacts/review_note.md", - ], - &["Artifact 校验存在 1 条未恢复 issues。"], - ); - - let summary = build_sceneapp_run_summary_from_agent_run_inner( - &run, - Some(&descriptor), - "story-video-suite".to_string(), - Some(&runtime_evidence), - ); - - assert_eq!(summary.delivery_completion_rate, Some(100.0)); - assert_eq!(summary.failure_signal.as_deref(), Some("review_blocked")); - assert_eq!( - summary.verification_failure_outcomes, - vec!["Artifact 校验存在 1 条未恢复 issues。".to_string()] - ); - } - - #[test] - fn build_sceneapp_run_summary_from_automation_job_should_reflect_delivery_artifact() { - let descriptor = - get_sceneapp_descriptor("daily-trend-briefing").expect("descriptor should exist"); - let summary = build_sceneapp_run_summary_from_automation_job( - &create_automation_job(Some(AutomationJobLastDelivery { - success: true, - message: "ok".to_string(), - channel: None, - target: None, - output_kind: "document".to_string(), - output_schema: "markdown".to_string(), - output_format: "md".to_string(), - output_preview: "preview".to_string(), - delivery_attempt_id: None, - run_id: None, - execution_retry_count: 0, - delivery_attempts: 1, - attempted_at: "2026-04-15T00:03:00.000Z".to_string(), - })), - Some(&descriptor), - "daily-trend-briefing".to_string(), - ); - - assert_eq!(summary.artifact_count, 1); - assert_eq!(summary.source_ref.as_deref(), Some("job-1")); - assert_eq!(summary.session_id, None); - assert_eq!(summary.browser_runtime_ref, None); - assert_eq!(summary.service_scene_runtime_ref, None); - assert_eq!(summary.native_skill_runtime_ref, None); - assert_eq!( - summary.delivery_required_parts, - vec!["brief".to_string(), "review_note".to_string()] - ); - assert!(!summary.delivery_part_coverage_known); - } -} diff --git a/src-tauri/src/sceneapp/application.rs b/src-tauri/src/sceneapp/application.rs deleted file mode 100644 index d9d654901..000000000 --- a/src-tauri/src/sceneapp/application.rs +++ /dev/null @@ -1,1453 +0,0 @@ -use super::adapters::{ - build_sceneapp_automation_draft, build_sceneapp_automation_result, - build_sceneapp_run_summary_from_agent_run_with_db, - build_sceneapp_run_summary_from_automation_job, extract_sceneapp_id_from_automation_job, - extract_sceneapp_id_from_run_metadata, prepare_sceneapp_run_governance_artifact, -}; -use super::catalog::{get_sceneapp_descriptor, seeded_sceneapp_catalog}; -use super::context::compiler::build_reference_library_items; -use super::context::dto::{ReferenceItem, TasteProfile}; -use super::context::store::{ - build_persisted_sceneapp_context, load_persisted_sceneapp_context, - save_persisted_sceneapp_context, PersistedSceneAppContext, -}; -use super::dto::*; -use super::governance::build_sceneapp_scorecard_from_runs; -use super::runtime::build_launch_plan; -use crate::commands::unified_memory_cmd::load_unified_memories_by_ids; -use crate::database::DbConnection; -use crate::services::automation_service::AutomationService; -use crate::services::execution_tracker_service::ExecutionTracker; -use crate::services::runtime_review_decision_service::RuntimeReviewDecisionContent; -use chrono::Utc; -use std::collections::BTreeSet; - -pub struct SceneAppService; - -const SCENEAPP_TRACKER_RUN_LIMIT: usize = 200; - -fn sort_and_dedupe_runs(runs: &mut Vec) { - runs.sort_by(|left, right| { - right - .started_at - .cmp(&left.started_at) - .then_with(|| right.run_id.cmp(&left.run_id)) - }); - runs.dedup_by(|left, right| left.run_id == right.run_id); -} - -fn normalize_reference_memory_ids(ids: &[String]) -> Vec { - ids.iter() - .map(|id| id.trim()) - .filter(|id| !id.is_empty()) - .fold(Vec::::new(), |mut acc, id| { - if !acc.iter().any(|existing| existing == id) { - acc.push(id.to_string()); - } - acc - }) -} - -fn load_selected_reference_items( - db: &DbConnection, - ids: &[String], -) -> Result, String> { - let normalized_ids = normalize_reference_memory_ids(ids); - if normalized_ids.is_empty() { - return Ok(Vec::new()); - } - - let conn = db - .lock() - .map_err(|error| format!("数据库锁定失败: {error}"))?; - let memories = load_unified_memories_by_ids(&conn, normalized_ids.as_slice())?; - Ok(build_reference_library_items(memories.as_slice())) -} - -fn build_sceneapp_plan_result( - db: &DbConnection, - intent: &SceneAppLaunchIntent, -) -> Result { - let sceneapp_id = intent.sceneapp_id.clone(); - let workspace_id = intent.workspace_id.clone(); - let project_id = intent.project_id.clone(); - let requested_reference_memory_count = - normalize_reference_memory_ids(intent.reference_memory_ids.as_slice()).len(); - let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()) - .ok_or_else(|| format!("未找到 SceneApp: {sceneapp_id}"))?; - - let persisted_context = match load_persisted_sceneapp_context( - db, - sceneapp_id.as_str(), - workspace_id.as_deref(), - project_id.as_deref(), - ) { - Ok(context) => context, - Err(error) => { - let mut result = build_launch_plan(descriptor, intent.clone(), None, &[]); - result.plan.warnings.push(format!( - "读取项目级 Context Snapshot 失败,本次先按最新输入继续 planning:{error}" - )); - return Ok(result); - } - }; - - let explicit_reference_items = - match load_selected_reference_items(db, intent.reference_memory_ids.as_slice()) { - Ok(items) => items, - Err(error) => { - let mut result = - build_launch_plan(descriptor, intent.clone(), persisted_context.as_ref(), &[]); - result.plan.warnings.push(format!( - "读取灵感库参考失败,本次先按当前输入继续 planning:{error}" - )); - return Ok(result); - } - }; - - let mut result = build_launch_plan( - descriptor, - intent.clone(), - persisted_context.as_ref(), - explicit_reference_items.as_slice(), - ); - let missing_reference_count = - requested_reference_memory_count.saturating_sub(explicit_reference_items.len()); - if missing_reference_count > 0 { - result.plan.warnings.push(format!( - "已选中的 {} 条灵感条目未找到,planning 仅继续使用当前可解析的参考。", - missing_reference_count - )); - } - - Ok(result) -} - -fn normalize_optional_id(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn run_feedback_timestamp(run: &SceneAppRunSummary) -> &str { - run.finished_at - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or(run.started_at.as_str()) -} - -fn should_sync_feedback_from_run(run: &SceneAppRunSummary) -> bool { - matches!( - run.status, - SceneAppRunStatus::Success - | SceneAppRunStatus::Error - | SceneAppRunStatus::Canceled - | SceneAppRunStatus::Timeout - ) || run.artifact_count > 0 - || run.failure_signal.is_some() - || !run.evidence_known_gaps.is_empty() - || !run.verification_failure_outcomes.is_empty() -} - -fn failure_signal_label(signal: &str) -> &str { - match signal { - "pack_incomplete" => "整包不完整", - "review_blocked" => "复核阻塞", - "publish_stalled" => "发布卡点", - "automation_timeout" => "自动化超时", - "dependency_failure" => "外部依赖与会话稳定性", - "adoption_failure" => "人工中断与补参", - "runtime_failure" => "运行链稳定性", - "artifact_validation_issue" => "结果结构校验问题", - "telemetry_missing" => "请求遥测缺失", - "runtime_evidence_gap" => "会话证据缺口", - "verification_failure" => "结果校验失败", - _ => signal, - } -} - -fn recommended_action_label(action: &SceneAppRecommendedAction) -> &'static str { - match action { - SceneAppRecommendedAction::Launch => "继续补样本", - SceneAppRecommendedAction::Keep => "继续保留", - SceneAppRecommendedAction::Optimize => "优先优化", - SceneAppRecommendedAction::Retire => "考虑收口", - } -} - -fn build_reference_feedback_label( - run: &SceneAppRunSummary, - scorecard: Option<&SceneAppScorecard>, -) -> String { - if let Some(signal) = run - .failure_signal - .as_deref() - .or_else(|| scorecard.and_then(|item| item.top_failure_signal.as_deref())) - { - return failure_signal_label(signal).to_string(); - } - - if let Some(scorecard) = scorecard { - return recommended_action_label(&scorecard.recommended_action).to_string(); - } - - match run.status { - SceneAppRunStatus::Success => "可继续复用".to_string(), - SceneAppRunStatus::Canceled => "人工中断".to_string(), - SceneAppRunStatus::Timeout => "运行超时".to_string(), - SceneAppRunStatus::Error => "运行受阻".to_string(), - SceneAppRunStatus::Queued => "等待执行".to_string(), - SceneAppRunStatus::Running => "运行中".to_string(), - } -} - -fn push_feedback_signal(signals: &mut BTreeSet, value: Option<&str>) { - let Some(value) = normalize_optional_id(value) else { - return; - }; - signals.insert(value); -} - -fn collect_feedback_signals( - run: &SceneAppRunSummary, - scorecard: Option<&SceneAppScorecard>, -) -> Vec { - let mut signals = BTreeSet::new(); - push_feedback_signal(&mut signals, run.failure_signal.as_deref()); - push_feedback_signal( - &mut signals, - scorecard.and_then(|item| item.top_failure_signal.as_deref()), - ); - - if let Some(scorecard) = scorecard { - for signal in scorecard.observed_failure_signals.iter() { - push_feedback_signal(&mut signals, Some(signal.as_str())); - } - } - - if run.artifact_validator_issue_count.unwrap_or(0) > 0 { - signals.insert("artifact_validation_issue".to_string()); - } - if run.request_telemetry_available == Some(false) { - signals.insert("telemetry_missing".to_string()); - } - if !run.evidence_known_gaps.is_empty() { - signals.insert("runtime_evidence_gap".to_string()); - } - if !run.verification_failure_outcomes.is_empty() { - signals.insert("verification_failure".to_string()); - } - if run.failure_signal.is_none() && !run.delivery_missing_parts.is_empty() { - signals.insert("pack_incomplete".to_string()); - } - - signals.into_iter().collect() -} - -fn build_feedback_summary( - run: &SceneAppRunSummary, - scorecard: Option<&SceneAppScorecard>, -) -> String { - let delivery_sentence = - if run.delivery_part_coverage_known && !run.delivery_required_parts.is_empty() { - let delivered = run.delivery_completed_parts.len(); - let total = run.delivery_required_parts.len(); - if run.delivery_missing_parts.is_empty() { - format!("最近一次运行已交齐 {delivered}/{total} 个必含部件。") - } else { - format!("最近一次运行已交付 {delivered}/{total} 个必含部件。") - } - } else if run.artifact_count > 0 { - format!("最近一次运行已回流 {} 份结果。", run.artifact_count) - } else { - "最近一次运行还没有回流可复盘结果。".to_string() - }; - - let failure_sentence = if let Some(signal) = run - .failure_signal - .as_deref() - .or_else(|| scorecard.and_then(|item| item.top_failure_signal.as_deref())) - { - format!("当前主要卡点是{}。", failure_signal_label(signal)) - } else if !run.evidence_known_gaps.is_empty() { - "当前仍有会话证据缺口需要补齐。".to_string() - } else if !run.verification_failure_outcomes.is_empty() { - "当前仍有结果校验问题需要回头处理。".to_string() - } else { - "当前还没有出现明显阻塞。".to_string() - }; - - let action_sentence = scorecard - .map(|item| { - format!( - "经营上建议{}。", - recommended_action_label(&item.recommended_action) - ) - }) - .unwrap_or_else(|| match run.status { - SceneAppRunStatus::Success => "经营上可以继续复用这轮结果。".to_string(), - SceneAppRunStatus::Canceled => "经营上更适合先确认人工中断原因。".to_string(), - SceneAppRunStatus::Timeout | SceneAppRunStatus::Error => { - "经营上更适合先修运行链再继续放大。".to_string() - } - SceneAppRunStatus::Queued | SceneAppRunStatus::Running => { - "经营上先等待这轮运行稳定收口。".to_string() - } - }); - - format!("{delivery_sentence}{failure_sentence}{action_sentence}") -} - -fn normalize_optional_text(value: &str) -> Option { - let normalized = value.trim(); - if normalized.is_empty() { - None - } else { - Some(normalized.to_string()) - } -} - -fn ensure_feedback_sentence(value: &str) -> String { - let trimmed = value.trim(); - if trimmed.is_empty() { - return String::new(); - } - - if matches!( - trimmed.chars().last(), - Some('。' | '!' | '?' | '.' | '!' | '?') - ) { - trimmed.to_string() - } else { - format!("{trimmed}。") - } -} - -fn review_decision_status_label(status: &str) -> &'static str { - match status { - "accepted" => "人工接受", - "deferred" => "人工延后", - "rejected" => "人工否决", - "needs_more_evidence" => "人工补证据", - "pending_review" => "待人工审核", - _ => "人工复核", - } -} - -fn review_decision_status_signal(status: &str) -> Option<&'static str> { - match status { - "accepted" => Some("review_decision_accepted"), - "deferred" => Some("review_decision_deferred"), - "rejected" => Some("review_decision_rejected"), - "needs_more_evidence" => Some("review_decision_needs_more_evidence"), - "pending_review" => Some("review_decision_pending_review"), - _ => None, - } -} - -fn review_decision_risk_label(risk_level: &str) -> Option<&'static str> { - match risk_level { - "low" => Some("低风险"), - "medium" => Some("中风险"), - "high" => Some("高风险"), - _ => None, - } -} - -fn review_decision_risk_signal(risk_level: &str) -> Option<&'static str> { - match risk_level { - "low" => Some("review_risk_low"), - "medium" => Some("review_risk_medium"), - "high" => Some("review_risk_high"), - _ => None, - } -} - -fn review_decision_feedback_timestamp( - run: &SceneAppRunSummary, - decision: &RuntimeReviewDecisionContent, -) -> String { - decision - .reviewed_at - .as_deref() - .and_then(|value| normalize_optional_text(value)) - .unwrap_or_else(|| run_feedback_timestamp(run).to_string()) -} - -fn build_review_decision_feedback_label(decision: &RuntimeReviewDecisionContent) -> String { - let status_label = review_decision_status_label(decision.decision_status.as_str()); - match review_decision_risk_label(decision.risk_level.as_str()) { - Some(risk_label) => format!("{status_label} · {risk_label}"), - None => status_label.to_string(), - } -} - -fn collect_review_decision_feedback_signals( - run: &SceneAppRunSummary, - decision: &RuntimeReviewDecisionContent, -) -> Vec { - let mut signals = collect_feedback_signals(run, None) - .into_iter() - .collect::>(); - if let Some(signal) = review_decision_status_signal(decision.decision_status.as_str()) { - signals.insert(signal.to_string()); - } - if let Some(signal) = review_decision_risk_signal(decision.risk_level.as_str()) { - signals.insert(signal.to_string()); - } - signals.into_iter().collect() -} - -fn build_review_decision_feedback_summary( - run: &SceneAppRunSummary, - decision: &RuntimeReviewDecisionContent, -) -> String { - let mut sentences = vec![format!( - "人工复核结论:{}。", - build_review_decision_feedback_label(decision) - )]; - - if let Some(summary) = normalize_optional_text(decision.decision_summary.as_str()) { - sentences.push(ensure_feedback_sentence(summary.as_str())); - } - if let Some(strategy) = normalize_optional_text(decision.chosen_fix_strategy.as_str()) { - sentences.push(format!("处理策略:{}。", strategy)); - } - if !decision.followup_actions.is_empty() { - sentences.push(format!( - "后续动作:{}。", - decision.followup_actions.join(";") - )); - } - if !decision.regression_requirements.is_empty() { - sentences.push(format!( - "回归要求:{}。", - decision.regression_requirements.join(";") - )); - } - if let Some(reviewer) = normalize_optional_text(decision.human_reviewer.as_str()) { - sentences.push(format!("审核人:{}。", reviewer)); - } - if let Some(notes) = normalize_optional_text(decision.notes.as_str()) { - sentences.push(format!("备注:{}。", notes)); - } - if let Some(signal) = run.failure_signal.as_deref() { - sentences.push(format!( - "这次运行原始卡点:{}。", - failure_signal_label(signal) - )); - } - - sentences.join("") -} - -fn resolve_feedback_scope_from_run(run: &SceneAppRunSummary) -> (Option, Option) { - let project_id = run - .delivery_artifact_refs - .iter() - .find_map(|artifact| normalize_optional_id(artifact.project_id.as_deref())) - .or_else(|| { - run.governance_artifact_refs - .iter() - .find_map(|artifact| normalize_optional_id(artifact.project_id.as_deref())) - }) - .or_else(|| { - run.service_scene_runtime_ref - .as_ref() - .and_then(|runtime| normalize_optional_id(runtime.project_id.as_deref())) - }) - .or_else(|| { - run.native_skill_runtime_ref - .as_ref() - .and_then(|runtime| normalize_optional_id(runtime.project_id.as_deref())) - }); - - let workspace_id = run - .delivery_artifact_refs - .iter() - .find_map(|artifact| normalize_optional_id(artifact.workspace_id.as_deref())) - .or_else(|| { - run.governance_artifact_refs - .iter() - .find_map(|artifact| normalize_optional_id(artifact.workspace_id.as_deref())) - }) - .or_else(|| { - run.service_scene_runtime_ref - .as_ref() - .and_then(|runtime| normalize_optional_id(runtime.workspace_id.as_deref())) - }) - .or_else(|| { - run.native_skill_runtime_ref - .as_ref() - .and_then(|runtime| normalize_optional_id(runtime.workspace_id.as_deref())) - }); - - (project_id, workspace_id) -} - -fn build_default_taste_profile(context: &PersistedSceneAppContext) -> TasteProfile { - TasteProfile { - profile_id: format!("taste-{}", context.sceneapp_id), - summary: "当前 TasteProfile 已根据项目级反馈自动恢复。".to_string(), - keywords: Vec::new(), - avoid_keywords: Vec::new(), - derived_from_reference_ids: context - .reference_items - .iter() - .filter(|item| item.selected) - .map(|item| item.id.clone()) - .collect(), - confidence: None, - feedback_summary: None, - feedback_signals: Vec::new(), - last_feedback_at: None, - } -} - -fn apply_run_feedback_to_context( - context: &mut PersistedSceneAppContext, - run: &SceneAppRunSummary, - scorecard: Option<&SceneAppScorecard>, -) -> bool { - if !should_sync_feedback_from_run(run) { - return false; - } - - let feedback_at = run_feedback_timestamp(run).to_string(); - let same_run = context.last_feedback_run_id.as_deref() == Some(run.run_id.as_str()); - let existing_feedback_at = context - .taste_profile - .as_ref() - .and_then(|profile| profile.last_feedback_at.as_deref()) - .filter(|value| !value.trim().is_empty()); - if !same_run && existing_feedback_at.is_some_and(|value| value > feedback_at.as_str()) { - return false; - } - - let before = context.clone(); - let derived_reference_ids = context - .taste_profile - .as_ref() - .map(|profile| { - profile - .derived_from_reference_ids - .iter() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - let feedback_label = build_reference_feedback_label(run, scorecard); - - for item in context.reference_items.iter_mut() { - let should_update = if !derived_reference_ids.is_empty() { - derived_reference_ids.contains(item.id.as_str()) - } else { - item.selected - }; - if !should_update { - continue; - } - - if !same_run { - item.usage_count = Some(item.usage_count.unwrap_or(0).saturating_add(1)); - } - item.last_used_at = Some(feedback_at.clone()); - item.last_feedback_label = Some(feedback_label.clone()); - } - - let mut taste_profile = context - .taste_profile - .clone() - .unwrap_or_else(|| build_default_taste_profile(context)); - if taste_profile.derived_from_reference_ids.is_empty() { - taste_profile.derived_from_reference_ids = context - .reference_items - .iter() - .filter(|item| item.selected) - .map(|item| item.id.clone()) - .collect(); - } - if !same_run || scorecard.is_some() || taste_profile.feedback_summary.is_none() { - taste_profile.feedback_summary = Some(build_feedback_summary(run, scorecard)); - taste_profile.feedback_signals = collect_feedback_signals(run, scorecard); - } - taste_profile.last_feedback_at = Some(feedback_at); - context.taste_profile = Some(taste_profile); - context.last_feedback_run_id = Some(run.run_id.clone()); - - *context != before -} - -fn apply_review_decision_to_context( - context: &mut PersistedSceneAppContext, - run: &SceneAppRunSummary, - decision: &RuntimeReviewDecisionContent, -) -> bool { - if !should_sync_feedback_from_run(run) { - return false; - } - - let reviewed_at = review_decision_feedback_timestamp(run, decision); - let same_run = context.last_feedback_run_id.as_deref() == Some(run.run_id.as_str()); - let existing_feedback_at = context - .taste_profile - .as_ref() - .and_then(|profile| profile.last_feedback_at.as_deref()) - .filter(|value| !value.trim().is_empty()); - if !same_run && existing_feedback_at.is_some_and(|value| value > reviewed_at.as_str()) { - return false; - } - - let before = context.clone(); - let _ = apply_run_feedback_to_context(context, run, None); - let derived_reference_ids = context - .taste_profile - .as_ref() - .map(|profile| { - profile - .derived_from_reference_ids - .iter() - .cloned() - .collect::>() - }) - .unwrap_or_default(); - let feedback_label = build_review_decision_feedback_label(decision); - - for item in context.reference_items.iter_mut() { - let should_update = if !derived_reference_ids.is_empty() { - derived_reference_ids.contains(item.id.as_str()) - } else { - item.selected - }; - if !should_update { - continue; - } - - item.last_used_at = Some(reviewed_at.clone()); - item.last_feedback_label = Some(feedback_label.clone()); - } - - let mut taste_profile = context - .taste_profile - .clone() - .unwrap_or_else(|| build_default_taste_profile(context)); - if taste_profile.derived_from_reference_ids.is_empty() { - taste_profile.derived_from_reference_ids = context - .reference_items - .iter() - .filter(|item| item.selected) - .map(|item| item.id.clone()) - .collect(); - } - taste_profile.feedback_summary = Some(build_review_decision_feedback_summary(run, decision)); - taste_profile.feedback_signals = collect_review_decision_feedback_signals(run, decision); - taste_profile.last_feedback_at = Some(reviewed_at); - context.taste_profile = Some(taste_profile); - context.last_feedback_run_id = Some(run.run_id.clone()); - - *context != before -} - -fn sync_persisted_context_feedback( - db: &DbConnection, - run: &SceneAppRunSummary, - scorecard: Option<&SceneAppScorecard>, -) -> Result, String> { - let (project_id, workspace_id) = resolve_feedback_scope_from_run(run); - if project_id.is_none() && workspace_id.is_none() { - return Ok(None); - } - - let mut context = load_persisted_sceneapp_context( - db, - run.sceneapp_id.as_str(), - workspace_id.as_deref(), - project_id.as_deref(), - )? - .unwrap_or(PersistedSceneAppContext { - sceneapp_id: run.sceneapp_id.clone(), - workspace_id: workspace_id.clone(), - project_id: project_id.clone(), - reference_items: Vec::new(), - taste_profile: None, - last_feedback_run_id: None, - }); - - if context.workspace_id.is_none() { - context.workspace_id = workspace_id; - } - if context.project_id.is_none() { - context.project_id = project_id; - } - - if !apply_run_feedback_to_context(&mut context, run, scorecard) { - return Ok(Some(context)); - } - - save_persisted_sceneapp_context(db, &context)?; - Ok(Some(context)) -} - -fn sync_persisted_context_review_decision( - db: &DbConnection, - run: &SceneAppRunSummary, - decision: &RuntimeReviewDecisionContent, -) -> Result, String> { - let (project_id, workspace_id) = resolve_feedback_scope_from_run(run); - if project_id.is_none() && workspace_id.is_none() { - return Ok(None); - } - - let mut context = load_persisted_sceneapp_context( - db, - run.sceneapp_id.as_str(), - workspace_id.as_deref(), - project_id.as_deref(), - )? - .unwrap_or(PersistedSceneAppContext { - sceneapp_id: run.sceneapp_id.clone(), - workspace_id: workspace_id.clone(), - project_id: project_id.clone(), - reference_items: Vec::new(), - taste_profile: None, - last_feedback_run_id: None, - }); - - if context.workspace_id.is_none() { - context.workspace_id = workspace_id; - } - if context.project_id.is_none() { - context.project_id = project_id; - } - - if !apply_review_decision_to_context(&mut context, run, decision) { - return Ok(Some(context)); - } - - save_persisted_sceneapp_context(db, &context)?; - Ok(Some(context)) -} - -impl SceneAppService { - pub fn list_catalog() -> SceneAppCatalog { - seeded_sceneapp_catalog() - } - - pub fn get_descriptor(id: &str) -> Option { - get_sceneapp_descriptor(id) - } - - pub fn plan_launch( - db: &DbConnection, - intent: SceneAppLaunchIntent, - ) -> Result { - build_sceneapp_plan_result(db, &intent) - } - - pub fn save_context_baseline( - db: &DbConnection, - intent: SceneAppLaunchIntent, - ) -> Result { - if normalize_optional_id(intent.project_id.as_deref()).is_none() - && normalize_optional_id(intent.workspace_id.as_deref()).is_none() - { - return Err("当前还没有绑定项目工作区,无法写入场景基线。".to_string()); - } - - let sceneapp_id = intent.sceneapp_id.clone(); - let mut result = build_sceneapp_plan_result(db, &intent)?; - let Some(context_overlay) = result.context_overlay.as_mut() else { - return Err("当前还没有可写入的场景上下文基线。".to_string()); - }; - - let used_at = Utc::now().to_rfc3339(); - for item in context_overlay.snapshot.reference_items.iter_mut() { - if !item.selected { - continue; - } - item.usage_count = Some(item.usage_count.unwrap_or(0).saturating_add(1)); - item.last_used_at = Some(used_at.clone()); - } - context_overlay.compiler_plan.notes.push( - "当前场景基线已写入项目级 Context Snapshot,后续 planning 会优先复用。".to_string(), - ); - - let persisted_context = - build_persisted_sceneapp_context(sceneapp_id.as_str(), &context_overlay.snapshot); - - match save_persisted_sceneapp_context(db, &persisted_context) { - Ok(Some(_)) => Ok(result), - Ok(None) => Err("当前未解析到项目目录,无法写入场景基线。".to_string()), - Err(error) => Err(format!("写入项目级 Context Snapshot 失败:{error}")), - } - } - - fn seeded_runs() -> Vec { - vec![ - SceneAppRunSummary { - run_id: "sceneapp-run-story-video-seed".to_string(), - sceneapp_id: "story-video-suite".to_string(), - status: SceneAppRunStatus::Success, - source: "catalog_seed".to_string(), - source_ref: None, - session_id: None, - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T00:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T00:08:00.000Z".to_string()), - artifact_count: 3, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: vec![ - "brief".to_string(), - "storyboard".to_string(), - "script".to_string(), - "music_refs".to_string(), - "video_draft".to_string(), - "review_note".to_string(), - ], - delivery_completed_parts: vec![ - "brief".to_string(), - "storyboard".to_string(), - "script".to_string(), - ], - delivery_missing_parts: vec![ - "music_refs".to_string(), - "video_draft".to_string(), - "review_note".to_string(), - ], - delivery_completion_rate: Some(50.0), - delivery_part_coverage_known: true, - failure_signal: Some("review_blocked".to_string()), - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - SceneAppRunSummary { - run_id: "sceneapp-run-article-export-seed".to_string(), - sceneapp_id: "x-article-export".to_string(), - status: SceneAppRunStatus::Queued, - source: "catalog_seed".to_string(), - source_ref: None, - session_id: None, - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T00:12:00.000Z".to_string(), - finished_at: None, - artifact_count: 0, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: vec!["index.md".to_string(), "meta.json".to_string()], - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - ] - } - - pub fn list_runs(sceneapp_id: Option<&str>) -> Vec { - let candidate_runs = Self::seeded_runs(); - let mut runs = match sceneapp_id.map(str::trim).filter(|value| !value.is_empty()) { - Some(sceneapp_id) => candidate_runs - .into_iter() - .filter(|run| run.sceneapp_id == sceneapp_id) - .collect(), - None => candidate_runs, - }; - sort_and_dedupe_runs(&mut runs); - runs - } - - pub fn get_run_summary(run_id: &str) -> Option { - Self::seeded_runs() - .into_iter() - .find(|run| run.run_id == run_id.trim()) - } - - pub async fn create_automation_job( - automation_service: &AutomationService, - intent: SceneAppAutomationIntent, - ) -> Result { - let descriptor = get_sceneapp_descriptor(intent.launch_intent.sceneapp_id.as_str()) - .ok_or_else(|| format!("未找到 SceneApp: {}", intent.launch_intent.sceneapp_id))?; - let run_now = intent.run_now.unwrap_or(false); - let draft = build_sceneapp_automation_draft(&descriptor, &intent)?; - let job = automation_service.create_job(draft)?; - let run_now_result = if run_now { - Some(automation_service.run_job_now(job.id.as_str()).await?) - } else { - None - }; - - Ok(build_sceneapp_automation_result( - &descriptor, - &job, - run_now_result, - )) - } - - pub fn list_runs_from_automation( - db: &DbConnection, - automation_service: &AutomationService, - sceneapp_id: Option<&str>, - ) -> Result, String> { - let sceneapp_id_filter = sceneapp_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let jobs = automation_service.list_jobs()?; - let mut runs = Vec::new(); - - for job in jobs { - let Some(job_sceneapp_id) = extract_sceneapp_id_from_automation_job(&job) else { - continue; - }; - if sceneapp_id_filter - .as_deref() - .is_some_and(|value| value != job_sceneapp_id) - { - continue; - } - - let job_runs = automation_service.get_job_runs(job.id.as_str(), 20)?; - if job_runs.is_empty() { - let descriptor = get_sceneapp_descriptor(job_sceneapp_id.as_str()); - runs.push(build_sceneapp_run_summary_from_automation_job( - &job, - descriptor.as_ref(), - job_sceneapp_id.clone(), - )); - continue; - } - - runs.extend(job_runs.into_iter().map(|run| { - let descriptor = get_sceneapp_descriptor(job_sceneapp_id.as_str()); - build_sceneapp_run_summary_from_agent_run_with_db( - db, - &run, - descriptor.as_ref(), - job_sceneapp_id.clone(), - ) - })); - } - - sort_and_dedupe_runs(&mut runs); - Ok(runs) - } - - pub fn list_runs_from_tracker( - tracker: &ExecutionTracker, - sceneapp_id: Option<&str>, - ) -> Result, String> { - let sceneapp_id_filter = sceneapp_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut runs = tracker - .list_runs(SCENEAPP_TRACKER_RUN_LIMIT, 0)? - .into_iter() - .filter_map(|run| { - let run_sceneapp_id = extract_sceneapp_id_from_run_metadata(&run)?; - if sceneapp_id_filter - .as_deref() - .is_some_and(|value| value != run_sceneapp_id) - { - return None; - } - let descriptor = get_sceneapp_descriptor(run_sceneapp_id.as_str()); - Some(build_sceneapp_run_summary_from_agent_run_with_db( - tracker.db(), - &run, - descriptor.as_ref(), - run_sceneapp_id, - )) - }) - .collect::>(); - sort_and_dedupe_runs(&mut runs); - Ok(runs) - } - - pub fn collect_runs( - tracker: &ExecutionTracker, - automation_service: &AutomationService, - sceneapp_id: Option<&str>, - ) -> Result, String> { - let mut live_runs = Self::list_runs_from_tracker(tracker, sceneapp_id)?; - live_runs.extend(Self::list_runs_from_automation( - tracker.db(), - automation_service, - sceneapp_id, - )?); - sort_and_dedupe_runs(&mut live_runs); - - if !live_runs.is_empty() { - return Ok(live_runs); - } - - Ok(Self::list_runs(sceneapp_id)) - } - - pub fn get_run_summary_from_tracker( - tracker: &ExecutionTracker, - run_id: &str, - ) -> Result, String> { - let Some(run) = tracker.get_run(run_id)? else { - return Ok(None); - }; - let Some(sceneapp_id) = extract_sceneapp_id_from_run_metadata(&run) else { - return Ok(None); - }; - let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()); - let summary = build_sceneapp_run_summary_from_agent_run_with_db( - tracker.db(), - &run, - descriptor.as_ref(), - sceneapp_id, - ); - let _ = sync_persisted_context_feedback(tracker.db(), &summary, None); - Ok(Some(summary)) - } - - pub fn prepare_run_governance_artifact( - tracker: &ExecutionTracker, - run_id: &str, - kind: &SceneAppGovernanceArtifactKind, - ) -> Result, String> { - let Some(run) = tracker.get_run(run_id)? else { - return Ok(None); - }; - let Some(sceneapp_id) = extract_sceneapp_id_from_run_metadata(&run) else { - return Ok(None); - }; - prepare_sceneapp_run_governance_artifact(tracker.db(), &run, kind)?; - let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()); - let summary = build_sceneapp_run_summary_from_agent_run_with_db( - tracker.db(), - &run, - descriptor.as_ref(), - sceneapp_id, - ); - let _ = sync_persisted_context_feedback(tracker.db(), &summary, None); - Ok(Some(summary)) - } - - pub fn get_scorecard( - db: &DbConnection, - automation_service: &AutomationService, - sceneapp_id: &str, - ) -> Result { - let descriptor = get_sceneapp_descriptor(sceneapp_id) - .ok_or_else(|| format!("未找到 SceneApp scorecard: {sceneapp_id}"))?; - let tracker = ExecutionTracker::new(db.clone()); - let runs = Self::collect_runs(&tracker, automation_service, Some(sceneapp_id))?; - let scorecard = build_sceneapp_scorecard_from_runs(&descriptor, &runs); - if let Some(run) = runs.iter().find(|run| should_sync_feedback_from_run(run)) { - let _ = sync_persisted_context_feedback(db, run, Some(&scorecard)); - } - Ok(scorecard) - } - - pub fn sync_review_decision_feedback_for_session( - tracker: &ExecutionTracker, - session_id: &str, - decision: &RuntimeReviewDecisionContent, - ) -> Result, String> { - let normalized_session_id = session_id.trim(); - if normalized_session_id.is_empty() { - return Ok(None); - } - - let Some(run) = tracker - .list_runs_by_session(normalized_session_id, SCENEAPP_TRACKER_RUN_LIMIT)? - .into_iter() - .find_map(|run| { - let sceneapp_id = extract_sceneapp_id_from_run_metadata(&run)?; - let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()); - Some(build_sceneapp_run_summary_from_agent_run_with_db( - tracker.db(), - &run, - descriptor.as_ref(), - sceneapp_id, - )) - }) - else { - return Ok(None); - }; - - sync_persisted_context_review_decision(tracker.db(), &run, decision) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::schema::create_tables; - use crate::workspace::{WorkspaceManager, WorkspaceType}; - use rusqlite::Connection; - use std::sync::{Arc, Mutex}; - use tempfile::tempdir; - - fn setup_test_db() -> DbConnection { - let conn = Connection::open_in_memory().expect("创建内存数据库失败"); - create_tables(&conn).expect("初始化表结构失败"); - Arc::new(Mutex::new(conn)) - } - - fn create_workspace(db: &DbConnection) -> String { - let temp_dir = tempdir().expect("创建临时目录失败"); - let workspace_root = temp_dir.path().join("sceneapp-feedback-workspace"); - std::fs::create_dir_all(&workspace_root).expect("创建 workspace 根目录失败"); - let workspace = WorkspaceManager::new(db.clone()) - .create_with_type( - "SceneApp Feedback".to_string(), - workspace_root, - WorkspaceType::General, - ) - .expect("创建 workspace 失败"); - std::mem::forget(temp_dir); - workspace.id - } - - fn create_persisted_context(sceneapp_id: &str, workspace_id: &str) -> PersistedSceneAppContext { - PersistedSceneAppContext { - sceneapp_id: sceneapp_id.to_string(), - workspace_id: Some(workspace_id.to_string()), - project_id: Some(workspace_id.to_string()), - reference_items: vec![ReferenceItem { - id: "memory:ref-1".to_string(), - label: "竞品视频".to_string(), - source_kind: - crate::sceneapp::context::dto::ContextLayerSourceKind::ReferenceLibrary, - content_type: "video".to_string(), - uri: None, - summary: Some("保留强节奏和结论前置。".to_string()), - selected: true, - usage_count: None, - last_used_at: None, - last_feedback_label: None, - }], - taste_profile: Some(TasteProfile { - profile_id: format!("taste-{sceneapp_id}"), - summary: "偏好快节奏、强结论。".to_string(), - keywords: vec!["快节奏".to_string()], - avoid_keywords: vec!["铺垫过长".to_string()], - derived_from_reference_ids: vec!["memory:ref-1".to_string()], - confidence: Some(0.68), - feedback_summary: None, - feedback_signals: Vec::new(), - last_feedback_at: None, - }), - last_feedback_run_id: None, - } - } - - fn create_run_summary(sceneapp_id: &str, workspace_id: &str) -> SceneAppRunSummary { - SceneAppRunSummary { - run_id: "sceneapp-run-feedback-1".to_string(), - sceneapp_id: sceneapp_id.to_string(), - status: SceneAppRunStatus::Success, - source: "automation".to_string(), - source_ref: Some("job-story-video".to_string()), - session_id: Some("session-story-video".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: Some(SceneAppServiceSceneRuntimeRef { - scene_key: Some("story-video".to_string()), - skill_id: Some("skill-story-video".to_string()), - project_id: Some(workspace_id.to_string()), - content_id: None, - workspace_id: Some(workspace_id.to_string()), - entry_source: Some("sceneapp_detail_preview".to_string()), - user_input: Some("生成短视频".to_string()), - slots: Default::default(), - }), - native_skill_runtime_ref: None, - started_at: "2026-04-17T08:00:00.000Z".to_string(), - finished_at: Some("2026-04-17T08:02:00.000Z".to_string()), - artifact_count: 2, - delivery_artifact_refs: vec![SceneAppDeliveryArtifactRef { - relative_path: "exports/story-video/brief.md".to_string(), - absolute_path: None, - part_key: Some("brief".to_string()), - project_id: Some(workspace_id.to_string()), - workspace_id: Some(workspace_id.to_string()), - source: "runtime_evidence".to_string(), - }], - governance_artifact_refs: Vec::new(), - delivery_required_parts: vec![ - "brief".to_string(), - "video_draft".to_string(), - "review_note".to_string(), - ], - delivery_completed_parts: vec!["brief".to_string(), "video_draft".to_string()], - delivery_missing_parts: vec!["review_note".to_string()], - delivery_completion_rate: Some(66.7), - delivery_part_coverage_known: true, - failure_signal: Some("review_blocked".to_string()), - runtime_evidence_used: true, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: Some(true), - request_telemetry_matched_count: Some(2), - artifact_validator_applicable: Some(true), - artifact_validator_issue_count: Some(1), - artifact_validator_recovered_count: Some(0), - } - } - - fn create_scorecard(sceneapp_id: &str) -> SceneAppScorecard { - SceneAppScorecard { - sceneapp_id: sceneapp_id.to_string(), - updated_at: "2026-04-17T08:05:00.000Z".to_string(), - summary: "当前更适合优先优化。".to_string(), - metrics: Vec::new(), - recommended_action: SceneAppRecommendedAction::Optimize, - observed_failure_signals: vec!["review_blocked".to_string()], - top_failure_signal: Some("review_blocked".to_string()), - } - } - - #[test] - fn should_only_preview_context_during_plan_launch() { - let db = setup_test_db(); - let workspace_id = create_workspace(&db); - - let result = SceneAppService::plan_launch( - &db, - SceneAppLaunchIntent { - sceneapp_id: "story-video-suite".to_string(), - entry_source: Some("sceneapp_detail_preview".to_string()), - workspace_id: Some(workspace_id.clone()), - project_id: Some(workspace_id.clone()), - user_input: Some("根据发布会亮点生成 30 秒短视频草稿".to_string()), - reference_memory_ids: Vec::new(), - slots: Default::default(), - runtime_context: Some(SceneAppRuntimeContext::default()), - }, - ) - .expect("planning 失败"); - - assert!(result.context_overlay.is_some()); - let loaded = load_persisted_sceneapp_context( - &db, - "story-video-suite", - Some(workspace_id.as_str()), - Some(workspace_id.as_str()), - ) - .expect("读取 context 失败"); - assert!(loaded.is_none()); - } - - #[test] - fn should_save_context_baseline_explicitly() { - let db = setup_test_db(); - let workspace_id = create_workspace(&db); - - let result = SceneAppService::save_context_baseline( - &db, - SceneAppLaunchIntent { - sceneapp_id: "story-video-suite".to_string(), - entry_source: Some("sceneapp_detail_save_context_baseline".to_string()), - workspace_id: Some(workspace_id.clone()), - project_id: Some(workspace_id.clone()), - user_input: Some("根据发布会亮点生成 30 秒短视频草稿".to_string()), - reference_memory_ids: Vec::new(), - slots: Default::default(), - runtime_context: Some(SceneAppRuntimeContext::default()), - }, - ) - .expect("写入场景基线失败"); - - let note_list = &result - .context_overlay - .as_ref() - .expect("应返回 context overlay") - .compiler_plan - .notes; - assert!(note_list - .iter() - .any(|note| note.contains("已写入项目级 Context Snapshot"))); - - let loaded = load_persisted_sceneapp_context( - &db, - "story-video-suite", - Some(workspace_id.as_str()), - Some(workspace_id.as_str()), - ) - .expect("读取 context 失败") - .expect("应读取到已写入的 context"); - - assert_eq!(loaded.reference_items.len(), 1); - assert_eq!(loaded.reference_items[0].usage_count, Some(1)); - assert!(loaded.reference_items[0].last_used_at.is_some()); - } - - #[test] - fn should_sync_run_feedback_into_persisted_context() { - let db = setup_test_db(); - let workspace_id = create_workspace(&db); - let persisted = create_persisted_context("story-video-suite", workspace_id.as_str()); - save_persisted_sceneapp_context(&db, &persisted) - .expect("写入初始 context 失败") - .expect("应返回 context 路径"); - - let run = create_run_summary("story-video-suite", workspace_id.as_str()); - let scorecard = create_scorecard("story-video-suite"); - - sync_persisted_context_feedback(&db, &run, Some(&scorecard)) - .expect("同步反馈失败") - .expect("应返回回写后的 context"); - - let loaded = load_persisted_sceneapp_context( - &db, - "story-video-suite", - Some(workspace_id.as_str()), - Some(workspace_id.as_str()), - ) - .expect("读取回写后的 context 失败") - .expect("应能读取到已回写 context"); - - assert_eq!( - loaded.last_feedback_run_id.as_deref(), - Some(run.run_id.as_str()) - ); - assert_eq!(loaded.reference_items[0].usage_count, Some(1)); - assert_eq!( - loaded.reference_items[0].last_feedback_label.as_deref(), - Some("复核阻塞") - ); - assert_eq!( - loaded.reference_items[0].last_used_at.as_deref(), - run.finished_at.as_deref() - ); - assert!(loaded.taste_profile.as_ref().is_some_and(|profile| profile - .feedback_summary - .as_deref() - .is_some_and(|summary| summary.contains("经营上建议优先优化")) - && profile - .feedback_signals - .iter() - .any(|signal| signal == "review_blocked") - && profile - .feedback_signals - .iter() - .any(|signal| signal == "artifact_validation_issue"))); - } - - #[test] - fn should_not_double_count_same_run_feedback_when_scorecard_arrives_later() { - let db = setup_test_db(); - let workspace_id = create_workspace(&db); - let persisted = create_persisted_context("story-video-suite", workspace_id.as_str()); - save_persisted_sceneapp_context(&db, &persisted) - .expect("写入初始 context 失败") - .expect("应返回 context 路径"); - - let run = create_run_summary("story-video-suite", workspace_id.as_str()); - sync_persisted_context_feedback(&db, &run, None) - .expect("首次同步反馈失败") - .expect("应返回首次回写后的 context"); - sync_persisted_context_feedback(&db, &run, Some(&create_scorecard("story-video-suite"))) - .expect("二次同步反馈失败") - .expect("应返回二次回写后的 context"); - - let loaded = load_persisted_sceneapp_context( - &db, - "story-video-suite", - Some(workspace_id.as_str()), - Some(workspace_id.as_str()), - ) - .expect("读取回写后的 context 失败") - .expect("应能读取到已回写 context"); - - assert_eq!(loaded.reference_items[0].usage_count, Some(1)); - assert!(loaded.taste_profile.as_ref().is_some_and(|profile| profile - .feedback_summary - .as_deref() - .is_some_and(|summary| summary.contains("经营上建议优先优化")))); - } - - #[test] - fn should_apply_manual_review_decision_into_existing_context() { - let db = setup_test_db(); - let workspace_id = create_workspace(&db); - let persisted = create_persisted_context("story-video-suite", workspace_id.as_str()); - save_persisted_sceneapp_context(&db, &persisted) - .expect("写入初始 context 失败") - .expect("应返回 context 路径"); - - let run = create_run_summary("story-video-suite", workspace_id.as_str()); - sync_persisted_context_feedback(&db, &run, Some(&create_scorecard("story-video-suite"))) - .expect("先同步自动反馈失败") - .expect("应返回自动反馈后的 context"); - - let decision = RuntimeReviewDecisionContent { - decision_status: "accepted".to_string(), - decision_summary: "这轮结果已经可以进入发布前微调".to_string(), - chosen_fix_strategy: "只补封面和字幕排版".to_string(), - risk_level: "high".to_string(), - risk_tags: Vec::new(), - human_reviewer: "Robin".to_string(), - reviewed_at: Some("2026-04-17T08:06:00.000Z".to_string()), - followup_actions: vec!["补一版封面".to_string(), "复查字幕".to_string()], - regression_requirements: vec!["确认视频导出尺寸".to_string()], - notes: "先不要扩大发布范围".to_string(), - }; - - sync_persisted_context_review_decision(&db, &run, &decision) - .expect("同步人工复核失败") - .expect("应返回人工复核后的 context"); - - let loaded = load_persisted_sceneapp_context( - &db, - "story-video-suite", - Some(workspace_id.as_str()), - Some(workspace_id.as_str()), - ) - .expect("读取回写后的 context 失败") - .expect("应能读取到人工复核后的 context"); - - assert_eq!(loaded.reference_items[0].usage_count, Some(1)); - assert_eq!( - loaded.reference_items[0].last_feedback_label.as_deref(), - Some("人工接受 · 高风险") - ); - assert_eq!( - loaded.reference_items[0].last_used_at.as_deref(), - Some("2026-04-17T08:06:00.000Z") - ); - assert!(loaded.taste_profile.as_ref().is_some_and(|profile| profile - .feedback_summary - .as_deref() - .is_some_and(|summary| summary.contains("人工复核结论:人工接受 · 高风险。")) - && profile - .feedback_summary - .as_deref() - .is_some_and(|summary| summary.contains("处理策略:只补封面和字幕排版。")) - && profile - .feedback_signals - .iter() - .any(|signal| signal == "review_decision_accepted") - && profile - .feedback_signals - .iter() - .any(|signal| signal == "review_risk_high"))); - } -} diff --git a/src-tauri/src/sceneapp/catalog.rs b/src-tauri/src/sceneapp/catalog.rs deleted file mode 100644 index 45e2bb656..000000000 --- a/src-tauri/src/sceneapp/catalog.rs +++ /dev/null @@ -1,463 +0,0 @@ -use super::dto::*; - -const SEEDED_SCENEAPP_CATALOG_VERSION: &str = "2026-04-15"; -const SEEDED_SCENEAPP_GENERATED_AT: &str = "2026-04-15T00:00:00.000Z"; -const SEEDED_SCENEAPP_PACKAGE_ID: &str = "lime-core-sceneapps"; - -fn service_skill_binding( - binding_family: SceneAppBindingFamily, - service_skill_id: &str, - skill_key: &str, - aliases: &[&str], -) -> SceneAppEntryBinding { - SceneAppEntryBinding { - kind: SceneAppEntryBindingKind::ServiceSkill, - binding_family, - service_skill_id: Some(service_skill_id.to_string()), - skill_key: Some(skill_key.to_string()), - scene_key: None, - command_prefix: None, - aliases: aliases.iter().map(|alias| alias.to_string()).collect(), - } -} - -fn scene_binding( - binding_family: SceneAppBindingFamily, - scene_key: &str, - command_prefix: &str, - aliases: &[&str], -) -> SceneAppEntryBinding { - SceneAppEntryBinding { - kind: SceneAppEntryBindingKind::Scene, - binding_family, - service_skill_id: None, - skill_key: None, - scene_key: Some(scene_key.to_string()), - command_prefix: Some(command_prefix.to_string()), - aliases: aliases.iter().map(|alias| alias.to_string()).collect(), - } -} - -fn requirement(kind: SceneAppLaunchRequirementKind, message: &str) -> SceneAppLaunchRequirement { - SceneAppLaunchRequirement { - kind, - message: message.to_string(), - } -} - -fn delivery_profile( - artifact_profile_ref: &str, - viewer_kind: &str, - required_parts: &[&str], - primary_part: &str, -) -> SceneAppDeliveryProfile { - SceneAppDeliveryProfile { - artifact_profile_ref: Some(artifact_profile_ref.to_string()), - viewer_kind: Some(viewer_kind.to_string()), - required_parts: required_parts.iter().map(|part| part.to_string()).collect(), - primary_part: Some(primary_part.to_string()), - } -} - -fn composition_step( - id: &str, - order: usize, - binding_profile_ref: &str, - binding_family: SceneAppBindingFamily, -) -> SceneAppCompositionStepDescriptor { - SceneAppCompositionStepDescriptor { - id: id.to_string(), - order, - binding_profile_ref: Some(binding_profile_ref.to_string()), - binding_family: Some(binding_family), - } -} - -fn composition_profile( - blueprint_ref: &str, - steps: Vec, -) -> SceneAppCompositionProfile { - SceneAppCompositionProfile { - blueprint_ref: Some(blueprint_ref.to_string()), - step_count: steps.len(), - steps, - } -} - -fn scorecard_profile( - profile_ref: &str, - metric_keys: &[&str], - failure_signals: &[&str], -) -> SceneAppScorecardProfile { - SceneAppScorecardProfile { - profile_ref: Some(profile_ref.to_string()), - metric_keys: metric_keys - .iter() - .map(|metric| metric.to_string()) - .collect(), - failure_signals: failure_signals - .iter() - .map(|signal| signal.to_string()) - .collect(), - } -} - -fn seeded_story_video_suite() -> SceneAppDescriptor { - SceneAppDescriptor { - id: "story-video-suite".to_string(), - title: "短视频编排".to_string(), - summary: "把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。".to_string(), - category: "Scene Apps".to_string(), - sceneapp_type: SceneAppType::Hybrid, - pattern_primary: SceneAppPattern::Pipeline, - pattern_stack: vec![ - SceneAppPattern::Pipeline, - SceneAppPattern::Inversion, - SceneAppPattern::Generator, - SceneAppPattern::Reviewer, - ], - capability_refs: vec![ - "agent_turn".to_string(), - "native_skill".to_string(), - "workspace_storage".to_string(), - "artifact_viewer".to_string(), - ], - infra_profile: vec![ - "composition_blueprint".to_string(), - "project_pack".to_string(), - "workspace_storage".to_string(), - "agent_turn".to_string(), - "timeline".to_string(), - ], - delivery_contract: SceneAppDeliveryContract::ProjectPack, - artifact_kind: Some("artifact_bundle".to_string()), - output_hint: "短视频项目包".to_string(), - entry_bindings: vec![ - service_skill_binding( - SceneAppBindingFamily::AgentTurn, - "sceneapp-service-story-video", - "story-video-suite", - &["story-video", "mv-pipeline"], - ), - scene_binding( - SceneAppBindingFamily::AgentTurn, - "story-video-suite", - "/story-video-suite", - &["story-video-scene"], - ), - ], - launch_requirements: vec![ - requirement( - SceneAppLaunchRequirementKind::UserInput, - "需要主题、风格或脚本线索作为场景输入。", - ), - requirement( - SceneAppLaunchRequirementKind::Project, - "需要项目目录承接线框图、脚本和媒体结果。", - ), - ], - linked_service_skill_id: Some("sceneapp-service-story-video".to_string()), - linked_scene_key: Some("story-video-suite".to_string()), - delivery_profile: Some(delivery_profile( - "story-video-artifacts", - "artifact_bundle", - &[ - "brief", - "storyboard", - "script", - "music_refs", - "video_draft", - "review_note", - ], - "brief", - )), - composition_profile: Some(composition_profile( - "story-video-blueprint", - vec![ - composition_step( - "brief", - 1, - "story-video-native-binding", - SceneAppBindingFamily::NativeSkill, - ), - composition_step( - "storyboard", - 2, - "story-video-native-binding", - SceneAppBindingFamily::NativeSkill, - ), - composition_step( - "script", - 3, - "story-video-native-binding", - SceneAppBindingFamily::NativeSkill, - ), - composition_step( - "music_refs", - 4, - "story-video-cloud-binding", - SceneAppBindingFamily::AgentTurn, - ), - composition_step( - "video_draft", - 5, - "story-video-cloud-binding", - SceneAppBindingFamily::AgentTurn, - ), - composition_step( - "review_note", - 6, - "story-video-native-binding", - SceneAppBindingFamily::NativeSkill, - ), - ], - )), - scorecard_profile: Some(scorecard_profile( - "story-video-scorecard", - &[ - "complete_pack_rate", - "review_pass_rate", - "publish_conversion_rate", - ], - &["pack_incomplete", "review_blocked", "publish_stalled"], - )), - aliases: vec![ - "story-video".to_string(), - "mv-pipeline".to_string(), - "short-video-suite".to_string(), - ], - source_package_id: SEEDED_SCENEAPP_PACKAGE_ID.to_string(), - source_package_version: SEEDED_SCENEAPP_CATALOG_VERSION.to_string(), - } -} - -fn seeded_article_export() -> SceneAppDescriptor { - SceneAppDescriptor { - id: "x-article-export".to_string(), - title: "网页导出".to_string(), - summary: "在真实浏览器上下文中抓取网页正文、图片与元信息,并沉淀为项目内 Markdown 资料包。" - .to_string(), - category: "Scene Apps".to_string(), - sceneapp_type: SceneAppType::BrowserGrounded, - pattern_primary: SceneAppPattern::Pipeline, - pattern_stack: vec![ - SceneAppPattern::Pipeline, - SceneAppPattern::ToolWrapper, - SceneAppPattern::Generator, - SceneAppPattern::Inversion, - ], - capability_refs: vec![ - "browser_assist".to_string(), - "workspace_storage".to_string(), - "artifact_viewer".to_string(), - ], - infra_profile: vec![ - "browser_connector".to_string(), - "site_adapter".to_string(), - "workspace_storage".to_string(), - "artifact_bundle".to_string(), - ], - delivery_contract: SceneAppDeliveryContract::ProjectPack, - artifact_kind: Some("document".to_string()), - output_hint: "网页资料包".to_string(), - entry_bindings: vec![ - service_skill_binding( - SceneAppBindingFamily::BrowserAssist, - "sceneapp-service-article-export", - "x-article-export", - &["article-export"], - ), - scene_binding( - SceneAppBindingFamily::BrowserAssist, - "x-article-export", - "/x-article-export", - &["web-article-export"], - ), - ], - launch_requirements: vec![ - requirement( - SceneAppLaunchRequirementKind::BrowserSession, - "需要真实网页上下文或浏览器附着会话。", - ), - requirement( - SceneAppLaunchRequirementKind::Project, - "需要项目目录来保存 Markdown 与图片资源。", - ), - ], - linked_service_skill_id: Some("sceneapp-service-article-export".to_string()), - linked_scene_key: Some("x-article-export".to_string()), - delivery_profile: Some(delivery_profile( - "article-export-artifacts", - "document", - &["index.md", "meta.json"], - "index.md", - )), - composition_profile: None, - scorecard_profile: Some(scorecard_profile( - "article-export-scorecard", - &["success_rate", "reuse_rate"], - &["pack_incomplete"], - )), - aliases: vec![ - "article-export".to_string(), - "web-article-export".to_string(), - ], - source_package_id: SEEDED_SCENEAPP_PACKAGE_ID.to_string(), - source_package_version: SEEDED_SCENEAPP_CATALOG_VERSION.to_string(), - } -} - -fn seeded_voice_runtime() -> SceneAppDescriptor { - SceneAppDescriptor { - id: "voice-runtime".to_string(), - title: "配音生成".to_string(), - summary: "把文稿或视频内容交给客户端里的服务技能完成配音,并回流成可试听的媒体产物。" - .to_string(), - category: "Scene Apps".to_string(), - sceneapp_type: SceneAppType::LocalInstant, - pattern_primary: SceneAppPattern::Pipeline, - pattern_stack: vec![SceneAppPattern::Pipeline, SceneAppPattern::Generator], - capability_refs: vec![ - "agent_turn".to_string(), - "workspace_storage".to_string(), - "artifact_viewer".to_string(), - ], - infra_profile: vec![ - "agent_turn".to_string(), - "workspace_storage".to_string(), - "artifact_bundle".to_string(), - "json_snapshot".to_string(), - ], - delivery_contract: SceneAppDeliveryContract::ArtifactBundle, - artifact_kind: Some("report".to_string()), - output_hint: "媒体结果".to_string(), - entry_bindings: vec![service_skill_binding( - SceneAppBindingFamily::AgentTurn, - "sceneapp-service-voice-runtime", - "voice-runtime", - &["voiceover", "tts-scene"], - )], - launch_requirements: vec![requirement( - SceneAppLaunchRequirementKind::UserInput, - "需要文稿正文或待配音素材说明。", - )], - linked_service_skill_id: Some("sceneapp-service-voice-runtime".to_string()), - linked_scene_key: Some("voice-runtime".to_string()), - delivery_profile: Some(delivery_profile( - "voice-runtime-artifacts", - "artifact_bundle", - &["audio.mp3", "review_note"], - "audio.mp3", - )), - composition_profile: None, - scorecard_profile: Some(scorecard_profile( - "voice-runtime-scorecard", - &["success_rate", "reuse_rate"], - &["pack_incomplete"], - )), - aliases: vec!["voiceover".to_string(), "tts-scene".to_string()], - source_package_id: SEEDED_SCENEAPP_PACKAGE_ID.to_string(), - source_package_version: SEEDED_SCENEAPP_CATALOG_VERSION.to_string(), - } -} - -fn seeded_daily_trend_briefing() -> SceneAppDescriptor { - SceneAppDescriptor { - id: "daily-trend-briefing".to_string(), - title: "每日趋势摘要".to_string(), - summary: "把研究主题转成可持续运行的本地 durable 场景,并定时回流结果和失败原因。" - .to_string(), - category: "Scene Apps".to_string(), - sceneapp_type: SceneAppType::LocalDurable, - pattern_primary: SceneAppPattern::Pipeline, - pattern_stack: vec![SceneAppPattern::Pipeline, SceneAppPattern::Reviewer], - capability_refs: vec![ - "automation_job".to_string(), - "workspace_storage".to_string(), - "timeline".to_string(), - ], - infra_profile: vec![ - "automation_schedule".to_string(), - "db_store".to_string(), - "json_snapshot".to_string(), - ], - delivery_contract: SceneAppDeliveryContract::TableReport, - artifact_kind: Some("table_report".to_string()), - output_hint: "趋势摘要".to_string(), - entry_bindings: vec![service_skill_binding( - SceneAppBindingFamily::AutomationJob, - "sceneapp-service-daily-trend", - "daily-trend-briefing", - &["trend-briefing", "growth-monitor"], - )], - launch_requirements: vec![ - requirement( - SceneAppLaunchRequirementKind::Project, - "需要工作区或项目目录保存运行历史与结果快照。", - ), - requirement( - SceneAppLaunchRequirementKind::Automation, - "需要可用的自动化调度能力。", - ), - ], - linked_service_skill_id: Some("sceneapp-service-daily-trend".to_string()), - linked_scene_key: Some("daily-trend-briefing".to_string()), - delivery_profile: Some(delivery_profile( - "daily-trend-artifacts", - "table_report", - &["brief", "review_note"], - "brief", - )), - composition_profile: None, - scorecard_profile: Some(scorecard_profile( - "daily-trend-scorecard", - &["success_rate", "reuse_rate"], - &["automation_timeout"], - )), - aliases: vec!["trend-briefing".to_string(), "growth-monitor".to_string()], - source_package_id: SEEDED_SCENEAPP_PACKAGE_ID.to_string(), - source_package_version: SEEDED_SCENEAPP_CATALOG_VERSION.to_string(), - } -} - -pub fn seeded_sceneapp_descriptors() -> Vec { - vec![ - seeded_story_video_suite(), - seeded_article_export(), - seeded_voice_runtime(), - seeded_daily_trend_briefing(), - ] -} - -pub fn seeded_sceneapp_catalog() -> SceneAppCatalog { - SceneAppCatalog { - version: SEEDED_SCENEAPP_CATALOG_VERSION.to_string(), - generated_at: SEEDED_SCENEAPP_GENERATED_AT.to_string(), - items: seeded_sceneapp_descriptors(), - } -} - -pub fn get_sceneapp_descriptor(id: &str) -> Option { - let normalized = id.trim(); - if normalized.is_empty() { - return None; - } - - seeded_sceneapp_descriptors() - .into_iter() - .find(|descriptor| { - descriptor.id == normalized - || descriptor - .aliases - .iter() - .any(|alias| alias.eq_ignore_ascii_case(normalized)) - || descriptor - .linked_scene_key - .as_deref() - .is_some_and(|scene_key| scene_key == normalized) - || descriptor - .linked_service_skill_id - .as_deref() - .is_some_and(|service_skill_id| service_skill_id == normalized) - }) -} diff --git a/src-tauri/src/sceneapp/context/compiler.rs b/src-tauri/src/sceneapp/context/compiler.rs deleted file mode 100644 index 17baffa8c..000000000 --- a/src-tauri/src/sceneapp/context/compiler.rs +++ /dev/null @@ -1,689 +0,0 @@ -use super::dto::{ - ContextCompilerPlan, ContextLayerSnapshot, ContextLayerSourceKind, ReferenceItem, - SceneAppContextOverlay, TasteProfile, -}; -use super::store::PersistedSceneAppContext; -use crate::sceneapp::dto::{SceneAppDescriptor, SceneAppLaunchIntent, SceneAppPattern}; -use lime_memory::UnifiedMemory; -use std::collections::{hash_map::DefaultHasher, BTreeSet}; -use std::hash::{Hash, Hasher}; - -fn push_unique(values: &mut Vec, value: Option) { - let Some(value) = value.map(|item| item.trim().to_string()) else { - return; - }; - if value.is_empty() || values.iter().any(|item| item == &value) { - return; - } - values.push(value); -} - -fn truncate_summary(value: &str, max_chars: usize) -> String { - let trimmed = value.trim(); - if trimmed.chars().count() <= max_chars { - return trimmed.to_string(); - } - let mut summary = trimmed.chars().take(max_chars).collect::(); - summary.push('…'); - summary -} - -fn extract_url_candidate(text: &str) -> Option { - text.split_whitespace() - .find(|segment| segment.starts_with("http://") || segment.starts_with("https://")) - .map(|segment| { - segment.trim_end_matches(|char: char| { - matches!( - char, - '"' | '\'' | ')' | ']' | '}' | ',' | '.' | '>' | ',' | '。' | ')' - ) - }) - }) - .map(str::to_string) - .filter(|segment| !segment.is_empty()) -} - -fn stable_reference_item_id(prefix: &str, key: &str, value: &str) -> String { - let mut hasher = DefaultHasher::new(); - prefix.hash(&mut hasher); - key.hash(&mut hasher); - value.trim().hash(&mut hasher); - format!("{prefix}-{key}-{:x}", hasher.finish()) -} - -fn stable_memory_reference_item_id(memory_id: &str) -> String { - format!("memory:{memory_id}") -} - -fn build_reference_content_type(memory: &UnifiedMemory) -> String { - if extract_url_candidate(memory.content.as_str()).is_some() { - return "url".to_string(); - } - - match memory.category { - lime_memory::MemoryCategory::Identity => "style_memory".to_string(), - lime_memory::MemoryCategory::Preference => "preference_memory".to_string(), - lime_memory::MemoryCategory::Context => "reference_memory".to_string(), - lime_memory::MemoryCategory::Experience => "outcome_memory".to_string(), - lime_memory::MemoryCategory::Activity => "collection_memory".to_string(), - } -} - -pub fn build_reference_library_items(memories: &[UnifiedMemory]) -> Vec { - memories - .iter() - .map(|memory| ReferenceItem { - id: stable_memory_reference_item_id(memory.id.as_str()), - label: memory.title.trim().to_string(), - source_kind: ContextLayerSourceKind::ReferenceLibrary, - content_type: build_reference_content_type(memory), - uri: extract_url_candidate(memory.content.as_str()), - summary: Some(if memory.summary.trim().is_empty() { - truncate_summary(memory.content.as_str(), 80) - } else { - truncate_summary(memory.summary.as_str(), 80) - }), - selected: true, - usage_count: None, - last_used_at: None, - last_feedback_label: None, - }) - .collect() -} - -fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec { - let mut items = Vec::new(); - - if let Some(user_input) = intent.user_input.as_deref().map(str::trim) { - if !user_input.is_empty() { - items.push(ReferenceItem { - id: stable_reference_item_id("user-input", "input", user_input), - label: "用户输入".to_string(), - source_kind: ContextLayerSourceKind::UserInput, - content_type: if extract_url_candidate(user_input).is_some() { - "url".to_string() - } else { - "text".to_string() - }, - uri: extract_url_candidate(user_input), - summary: Some(truncate_summary(user_input, 80)), - selected: true, - usage_count: None, - last_used_at: None, - last_feedback_label: None, - }); - } - } - - items.extend(intent.slots.iter().filter_map(|(key, value)| { - let normalized = value.trim(); - if normalized.is_empty() { - return None; - } - Some(ReferenceItem { - id: stable_reference_item_id("slot", key.as_str(), normalized), - label: key.clone(), - source_kind: ContextLayerSourceKind::Slot, - content_type: if extract_url_candidate(normalized).is_some() { - "url".to_string() - } else { - "slot".to_string() - }, - uri: extract_url_candidate(normalized), - summary: Some(truncate_summary(normalized, 80)), - selected: true, - usage_count: None, - last_used_at: None, - last_feedback_label: None, - }) - })); - - items -} - -fn merge_reference_item_feedback( - mut item: ReferenceItem, - persisted_item: &ReferenceItem, -) -> ReferenceItem { - if item.summary.is_none() { - item.summary = persisted_item.summary.clone(); - } - if item.uri.is_none() { - item.uri = persisted_item.uri.clone(); - } - if item.usage_count.is_none() { - item.usage_count = persisted_item.usage_count; - } - if item.last_used_at.is_none() { - item.last_used_at = persisted_item.last_used_at.clone(); - } - if item.last_feedback_label.is_none() { - item.last_feedback_label = persisted_item.last_feedback_label.clone(); - } - item.selected = item.selected || persisted_item.selected; - item -} - -fn merge_reference_items( - explicit_reference_items: Vec, - input_items: Vec, - persisted_context: Option<&PersistedSceneAppContext>, -) -> Vec { - let mut seen = BTreeSet::new(); - let mut merged = Vec::new(); - - for mut item in explicit_reference_items { - if let Some(persisted_item) = persisted_context.and_then(|context| { - context - .reference_items - .iter() - .find(|existing| existing.id == item.id) - }) { - item = merge_reference_item_feedback(item, persisted_item); - } - if seen.insert(item.id.clone()) { - merged.push(item); - } - } - - for mut item in input_items { - if let Some(persisted_item) = persisted_context.and_then(|context| { - context - .reference_items - .iter() - .find(|existing| existing.id == item.id) - }) { - item = merge_reference_item_feedback(item, persisted_item); - } - if seen.insert(item.id.clone()) { - merged.push(item); - } - } - - if let Some(context) = persisted_context { - for item in context.reference_items.iter() { - if !seen.insert(item.id.clone()) { - continue; - } - let mut restored = item.clone(); - restored.source_kind = ContextLayerSourceKind::ReferenceLibrary; - merged.push(restored); - } - } - - merged -} - -fn build_memory_refs(intent: &SceneAppLaunchIntent) -> Vec { - let mut refs = Vec::new(); - - push_unique( - &mut refs, - intent - .workspace_id - .as_ref() - .map(|workspace_id| format!("workspace:{workspace_id}")), - ); - push_unique( - &mut refs, - intent - .project_id - .as_ref() - .map(|project_id| format!("project:{project_id}")), - ); - if intent - .user_input - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - push_unique(&mut refs, Some("memory_profile:user_input".to_string())); - } - - refs -} - -fn build_tool_refs(descriptor: &SceneAppDescriptor, intent: &SceneAppLaunchIntent) -> Vec { - let mut refs = descriptor.capability_refs.clone(); - - if let Some(runtime_context) = intent.runtime_context.as_ref() { - if runtime_context.browser_session_attached { - push_unique(&mut refs, Some("browser_session".to_string())); - } - if runtime_context.automation_enabled { - push_unique(&mut refs, Some("automation".to_string())); - } - } - - refs -} - -fn build_skill_refs(descriptor: &SceneAppDescriptor) -> Vec { - let mut refs = Vec::new(); - - push_unique(&mut refs, Some(descriptor.id.clone())); - push_unique(&mut refs, descriptor.linked_service_skill_id.clone()); - push_unique(&mut refs, descriptor.linked_scene_key.clone()); - push_unique( - &mut refs, - descriptor - .composition_profile - .as_ref() - .and_then(|profile| profile.blueprint_ref.clone()), - ); - - refs -} - -fn build_taste_profile( - descriptor: &SceneAppDescriptor, - intent: &SceneAppLaunchIntent, - reference_items: &[ReferenceItem], - persisted_context: Option<&PersistedSceneAppContext>, -) -> Option { - let has_user_input = intent - .user_input - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - let persisted_taste_profile = - persisted_context.and_then(|context| context.taste_profile.as_ref()); - if reference_items.is_empty() && !has_user_input { - return persisted_taste_profile.cloned(); - } - - let mut keywords = Vec::new(); - if let Some(profile) = persisted_taste_profile { - for keyword in profile.keywords.iter() { - push_unique(&mut keywords, Some(keyword.clone())); - } - } - for item in reference_items.iter().filter(|item| { - matches!( - item.source_kind, - ContextLayerSourceKind::ReferenceLibrary | ContextLayerSourceKind::Project - ) - }) { - push_unique(&mut keywords, Some(item.label.clone())); - } - for alias in descriptor.aliases.iter().take(2) { - push_unique(&mut keywords, Some(alias.clone())); - } - for (key, value) in intent.slots.iter() { - let normalized_key = key.trim().to_lowercase(); - if !matches!( - normalized_key.as_str(), - "style" | "tone" | "mood" | "platform" | "target_language" | "duration" - ) { - continue; - } - push_unique( - &mut keywords, - Some(format!("{key}:{}", truncate_summary(value, 24))), - ); - } - if matches!(descriptor.pattern_primary, SceneAppPattern::Reviewer) { - push_unique(&mut keywords, Some("review-first".to_string())); - } - push_unique(&mut keywords, Some(descriptor.output_hint.clone())); - - let mut avoid_keywords = Vec::new(); - if let Some(profile) = persisted_taste_profile { - for keyword in profile.avoid_keywords.iter() { - push_unique(&mut avoid_keywords, Some(keyword.clone())); - } - } - if !reference_items.is_empty() { - push_unique(&mut avoid_keywords, Some("偏离参考素材".to_string())); - } - - Some(TasteProfile { - profile_id: format!("taste-{}", descriptor.id), - summary: if persisted_taste_profile.is_some() && !reference_items.is_empty() { - format!( - "当前 TasteProfile 已在项目沉淀基础上,结合 {} 条参考输入更新启发式摘要。", - reference_items.len() - ) - } else if persisted_taste_profile.is_some() { - "当前 TasteProfile 已从项目上下文恢复。".to_string() - } else if reference_items.is_empty() { - "当前 TasteProfile 先基于用户输入与场景画像生成启发式摘要。".to_string() - } else { - format!( - "当前 TasteProfile 先基于 {} 条参考输入与场景画像生成启发式摘要。", - reference_items.len() - ) - }, - keywords, - avoid_keywords, - derived_from_reference_ids: reference_items.iter().map(|item| item.id.clone()).collect(), - confidence: Some( - if persisted_taste_profile.is_some() && !reference_items.is_empty() { - 0.72 - } else if persisted_taste_profile.is_some() { - 0.64 - } else if reference_items.is_empty() { - 0.38 - } else { - 0.56 - }, - ), - feedback_summary: persisted_taste_profile - .and_then(|profile| profile.feedback_summary.clone()), - feedback_signals: persisted_taste_profile - .map(|profile| profile.feedback_signals.clone()) - .unwrap_or_default(), - last_feedback_at: persisted_taste_profile - .and_then(|profile| profile.last_feedback_at.clone()), - }) -} - -pub fn build_sceneapp_context_overlay( - descriptor: &SceneAppDescriptor, - intent: &SceneAppLaunchIntent, - persisted_context: Option<&PersistedSceneAppContext>, - explicit_reference_items: &[ReferenceItem], -) -> SceneAppContextOverlay { - let input_reference_items = build_reference_items(intent); - let reference_items = merge_reference_items( - explicit_reference_items.to_vec(), - input_reference_items.clone(), - persisted_context, - ); - let memory_refs = build_memory_refs(intent); - let tool_refs = build_tool_refs(descriptor, intent); - let skill_refs = build_skill_refs(descriptor); - let taste_profile = - build_taste_profile(descriptor, intent, &reference_items, persisted_context); - - let mut active_layers = vec!["skill".to_string(), "tool".to_string()]; - if !memory_refs.is_empty() { - active_layers.push("memory".to_string()); - } - if !reference_items.is_empty() { - active_layers.push("reference".to_string()); - } - if taste_profile.is_some() { - active_layers.push("taste".to_string()); - } - - let mut notes = Vec::new(); - if !memory_refs.is_empty() { - notes.push(format!("已装配 {} 条 memory 引用。", memory_refs.len())); - } - let restored_reference_count = persisted_context - .map(|context| context.reference_items.len()) - .unwrap_or(0); - if restored_reference_count > 0 { - notes.push(format!( - "已从项目上下文恢复 {} 条历史参考。", - restored_reference_count - )); - } - if explicit_reference_items.is_empty() - && input_reference_items.is_empty() - && reference_items.is_empty() - { - notes.push("当前尚未选中显式参考素材,将主要依赖用户输入与场景画像。".to_string()); - } else if !explicit_reference_items.is_empty() && !input_reference_items.is_empty() { - notes.push(format!( - "本次显式带入 {} 条灵感对象,并新增 {} 条输入参考,当前 planning 共带上 {} 条参考。", - explicit_reference_items.len(), - input_reference_items.len(), - reference_items.len() - )); - } else if !explicit_reference_items.is_empty() { - notes.push(format!( - "本次显式带入 {} 条灵感对象,当前 planning 共带上 {} 条参考。", - explicit_reference_items.len(), - reference_items.len() - )); - } else if !input_reference_items.is_empty() { - notes.push(format!( - "本次新增 {} 条参考输入,当前 planning 共带上 {} 条参考。", - input_reference_items.len(), - reference_items.len() - )); - } else if !reference_items.is_empty() { - notes.push(format!( - "当前 planning 直接复用了 {} 条项目级参考。", - reference_items.len() - )); - } - if persisted_context - .and_then(|context| context.taste_profile.as_ref()) - .is_some() - { - notes.push("当前已复用项目级 TasteProfile,并按最新输入继续更新。".to_string()); - } else if taste_profile.is_some() { - notes - .push("当前 TasteProfile 为启发式摘要,可继续通过场景基线与运行反馈沉淀。".to_string()); - } - - SceneAppContextOverlay { - compiler_plan: ContextCompilerPlan { - active_layers, - memory_refs: memory_refs.clone(), - tool_refs: tool_refs.clone(), - reference_count: reference_items.len(), - notes, - }, - snapshot: ContextLayerSnapshot { - workspace_id: intent.workspace_id.clone(), - project_id: intent.project_id.clone(), - skill_refs, - memory_refs, - tool_refs, - reference_items, - taste_profile, - }, - } -} - -#[cfg(test)] -mod tests { - use super::{build_reference_library_items, build_sceneapp_context_overlay}; - use crate::sceneapp::catalog::get_sceneapp_descriptor; - use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile}; - use crate::sceneapp::context::store::PersistedSceneAppContext; - use crate::sceneapp::dto::{SceneAppLaunchIntent, SceneAppRuntimeContext}; - use lime_memory::UnifiedMemory; - use std::collections::BTreeMap; - - #[test] - fn should_build_reference_and_taste_overlay_for_sceneapp_launch() { - let descriptor = get_sceneapp_descriptor("x-article-export") - .expect("x-article-export descriptor should exist"); - let mut slots = BTreeMap::new(); - slots.insert( - "article_url".to_string(), - "https://x.com/openai/article/123".to_string(), - ); - slots.insert("target_language".to_string(), "中文".to_string()); - - let overlay = build_sceneapp_context_overlay( - &descriptor, - &SceneAppLaunchIntent { - sceneapp_id: "x-article-export".to_string(), - entry_source: Some("sceneapp_card".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-export".to_string()), - user_input: Some("请导出这篇文章并保持原始语气".to_string()), - reference_memory_ids: Vec::new(), - slots, - runtime_context: Some(SceneAppRuntimeContext { - browser_session_attached: true, - ..SceneAppRuntimeContext::default() - }), - }, - None, - &[], - ); - - assert!(overlay - .compiler_plan - .active_layers - .contains(&"reference".to_string())); - assert!(overlay - .compiler_plan - .active_layers - .contains(&"taste".to_string())); - assert_eq!(overlay.compiler_plan.reference_count, 3); - assert!(overlay - .snapshot - .tool_refs - .contains(&"browser_session".to_string())); - assert!(overlay.snapshot.taste_profile.is_some()); - } - - #[test] - fn should_merge_persisted_sceneapp_context_into_overlay() { - let descriptor = get_sceneapp_descriptor("story-video-suite") - .expect("story-video-suite descriptor should exist"); - let mut slots = BTreeMap::new(); - slots.insert("style".to_string(), "科技感、快节奏".to_string()); - let persisted_context = PersistedSceneAppContext { - sceneapp_id: "story-video-suite".to_string(), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-video".to_string()), - reference_items: vec![ReferenceItem { - id: "saved-reference-1".to_string(), - label: "竞品拆解".to_string(), - source_kind: ContextLayerSourceKind::ReferenceLibrary, - content_type: "text".to_string(), - uri: None, - summary: Some("保留结论前置和对比镜头。".to_string()), - selected: true, - usage_count: Some(2), - last_used_at: Some("2026-04-16T12:00:00.000Z".to_string()), - last_feedback_label: Some("复核阻塞".to_string()), - }], - taste_profile: Some(TasteProfile { - profile_id: "taste-story-video-suite".to_string(), - summary: "偏好快节奏科技感表达。".to_string(), - keywords: vec!["快节奏".to_string()], - avoid_keywords: vec!["冗长铺垫".to_string()], - derived_from_reference_ids: vec!["saved-reference-1".to_string()], - confidence: Some(0.66), - feedback_summary: Some("最近一次运行卡在复核环节。".to_string()), - feedback_signals: vec!["review_blocked".to_string()], - last_feedback_at: Some("2026-04-16T12:00:00.000Z".to_string()), - }), - last_feedback_run_id: Some("sceneapp-run-42".to_string()), - }; - - let overlay = build_sceneapp_context_overlay( - &descriptor, - &SceneAppLaunchIntent { - sceneapp_id: "story-video-suite".to_string(), - entry_source: Some("sceneapp_detail_preview".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-video".to_string()), - user_input: Some("做一个 30 秒新品短视频".to_string()), - reference_memory_ids: Vec::new(), - slots, - runtime_context: None, - }, - Some(&persisted_context), - &[], - ); - - assert_eq!(overlay.compiler_plan.reference_count, 3); - assert!(overlay - .compiler_plan - .notes - .iter() - .any(|note| note.contains("恢复 1 条历史参考"))); - assert!(overlay - .compiler_plan - .notes - .iter() - .any(|note| note.contains("复用项目级 TasteProfile"))); - assert!(overlay - .snapshot - .reference_items - .iter() - .any(|item| item.label == "竞品拆解" - && matches!(item.source_kind, ContextLayerSourceKind::ReferenceLibrary) - && item.usage_count == Some(2) - && item.last_feedback_label.as_deref() == Some("复核阻塞"))); - assert!(overlay - .snapshot - .taste_profile - .as_ref() - .is_some_and(|profile| profile.summary.contains("项目沉淀基础") - && profile.feedback_summary.as_deref() == Some("最近一次运行卡在复核环节。") - && profile - .feedback_signals - .iter() - .any(|signal| signal == "review_blocked"))); - } - - #[test] - fn should_promote_selected_memory_entries_into_reference_items() { - let descriptor = get_sceneapp_descriptor("story-video-suite") - .expect("story-video-suite descriptor should exist"); - let explicit_reference_items = build_reference_library_items(&[UnifiedMemory { - id: "memory-1".to_string(), - session_id: "session-1".to_string(), - memory_type: lime_memory::MemoryType::Conversation, - category: lime_memory::MemoryCategory::Identity, - title: "夏日短视频语气".to_string(), - content: "保留轻盈、结论前置和更强的节奏推进。".to_string(), - summary: "轻盈、结论前置、快节奏。".to_string(), - tags: vec!["小红书".to_string()], - metadata: lime_memory::MemoryMetadata { - confidence: 0.86, - importance: 7, - access_count: 0, - last_accessed_at: None, - source: lime_memory::MemorySource::Manual, - embedding: None, - }, - created_at: 1_712_345_678_900, - updated_at: 1_712_345_678_900, - archived: false, - }]); - - let overlay = build_sceneapp_context_overlay( - &descriptor, - &SceneAppLaunchIntent { - sceneapp_id: "story-video-suite".to_string(), - entry_source: Some("memory_page".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-video".to_string()), - user_input: Some("继续整理成 30 秒短视频方案".to_string()), - reference_memory_ids: vec!["memory-1".to_string()], - slots: BTreeMap::new(), - runtime_context: Some(SceneAppRuntimeContext { - directory_session_ready_compat: true, - ..SceneAppRuntimeContext::default() - }), - }, - None, - explicit_reference_items.as_slice(), - ); - - assert!(overlay - .snapshot - .reference_items - .iter() - .any(|item| item.id == "memory:memory-1" && item.label == "夏日短视频语气")); - assert!(!overlay - .snapshot - .tool_refs - .iter() - .any(|item| item == "cloud_session")); - assert!(overlay - .snapshot - .taste_profile - .as_ref() - .is_some_and(|profile| profile - .keywords - .iter() - .any(|keyword| keyword == "夏日短视频语气"))); - assert!(overlay - .compiler_plan - .notes - .iter() - .any(|note| note.contains("显式带入 1 条灵感对象"))); - } -} diff --git a/src-tauri/src/sceneapp/context/dto.rs b/src-tauri/src/sceneapp/context/dto.rs deleted file mode 100644 index bb20c39e1..000000000 --- a/src-tauri/src/sceneapp/context/dto.rs +++ /dev/null @@ -1,77 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ContextLayerSourceKind { - UserInput, - Slot, - Project, - Workspace, - MemoryProfile, - ReferenceLibrary, - ToolReadiness, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ReferenceItem { - pub id: String, - pub label: String, - pub source_kind: ContextLayerSourceKind, - pub content_type: String, - pub uri: Option, - pub summary: Option, - pub selected: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_used_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_feedback_label: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct TasteProfile { - pub profile_id: String, - pub summary: String, - pub keywords: Vec, - pub avoid_keywords: Vec, - pub derived_from_reference_ids: Vec, - pub confidence: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub feedback_summary: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub feedback_signals: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_feedback_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ContextCompilerPlan { - pub active_layers: Vec, - pub memory_refs: Vec, - pub tool_refs: Vec, - pub reference_count: usize, - pub notes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct ContextLayerSnapshot { - pub workspace_id: Option, - pub project_id: Option, - pub skill_refs: Vec, - pub memory_refs: Vec, - pub tool_refs: Vec, - pub reference_items: Vec, - pub taste_profile: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppContextOverlay { - pub compiler_plan: ContextCompilerPlan, - pub snapshot: ContextLayerSnapshot, -} diff --git a/src-tauri/src/sceneapp/context/mod.rs b/src-tauri/src/sceneapp/context/mod.rs deleted file mode 100644 index 1364267df..000000000 --- a/src-tauri/src/sceneapp/context/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod compiler; -pub mod dto; -pub mod store; diff --git a/src-tauri/src/sceneapp/context/store.rs b/src-tauri/src/sceneapp/context/store.rs deleted file mode 100644 index f3df96ccb..000000000 --- a/src-tauri/src/sceneapp/context/store.rs +++ /dev/null @@ -1,250 +0,0 @@ -use super::dto::{ContextLayerSnapshot, ReferenceItem, TasteProfile}; -use crate::database::DbConnection; -use crate::workspace::WorkspaceManager; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -const SCENEAPP_CONTEXT_RELATIVE_ROOT: &str = ".lime/sceneapp/context"; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct PersistedSceneAppContext { - pub sceneapp_id: String, - pub workspace_id: Option, - pub project_id: Option, - #[serde(default)] - pub reference_items: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub taste_profile: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_feedback_run_id: Option, -} - -fn normalize_optional_id(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn sanitize_sceneapp_file_stem(sceneapp_id: &str) -> String { - let mut sanitized = String::new(); - let mut last_was_dash = false; - - for ch in sceneapp_id.trim().chars() { - let normalized = if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { - ch - } else { - '-' - }; - if normalized == '-' { - if last_was_dash { - continue; - } - last_was_dash = true; - } else { - last_was_dash = false; - } - sanitized.push(normalized); - } - - let trimmed = sanitized.trim_matches('-'); - if trimmed.is_empty() { - "sceneapp-context".to_string() - } else { - trimmed.to_string() - } -} - -fn resolve_workspace_root(db: &DbConnection, workspace_id: &str) -> Option { - WorkspaceManager::new(db.clone()) - .get(&workspace_id.to_string()) - .ok() - .flatten() - .map(|workspace| workspace.root_path) -} - -pub fn resolve_sceneapp_context_root( - db: &DbConnection, - workspace_id: Option<&str>, - project_id: Option<&str>, -) -> Option { - normalize_optional_id(project_id) - .and_then(|workspace_id| resolve_workspace_root(db, workspace_id.as_str())) - .or_else(|| { - normalize_optional_id(workspace_id) - .and_then(|workspace_id| resolve_workspace_root(db, workspace_id.as_str())) - }) -} - -pub fn resolve_sceneapp_context_path( - db: &DbConnection, - sceneapp_id: &str, - workspace_id: Option<&str>, - project_id: Option<&str>, -) -> Option { - let root = resolve_sceneapp_context_root(db, workspace_id, project_id)?; - let file_name = format!("{}.json", sanitize_sceneapp_file_stem(sceneapp_id)); - Some(root.join(SCENEAPP_CONTEXT_RELATIVE_ROOT).join(file_name)) -} - -pub fn build_persisted_sceneapp_context( - sceneapp_id: &str, - snapshot: &ContextLayerSnapshot, -) -> PersistedSceneAppContext { - PersistedSceneAppContext { - sceneapp_id: sceneapp_id.trim().to_string(), - workspace_id: snapshot.workspace_id.clone(), - project_id: snapshot.project_id.clone(), - reference_items: snapshot.reference_items.clone(), - taste_profile: snapshot.taste_profile.clone(), - last_feedback_run_id: None, - } -} - -pub fn load_persisted_sceneapp_context( - db: &DbConnection, - sceneapp_id: &str, - workspace_id: Option<&str>, - project_id: Option<&str>, -) -> Result, String> { - let Some(path) = resolve_sceneapp_context_path(db, sceneapp_id, workspace_id, project_id) - else { - return Ok(None); - }; - if !path.exists() { - return Ok(None); - } - - let raw = fs::read_to_string(&path).map_err(|error| { - format!( - "读取 SceneApp Context Snapshot 失败({}): {error}", - path.display() - ) - })?; - let document = - serde_json::from_str::(raw.as_str()).map_err(|error| { - format!( - "解析 SceneApp Context Snapshot 失败({}): {error}", - path.display() - ) - })?; - Ok(Some(document)) -} - -pub fn save_persisted_sceneapp_context( - db: &DbConnection, - context: &PersistedSceneAppContext, -) -> Result, String> { - let Some(path) = resolve_sceneapp_context_path( - db, - context.sceneapp_id.as_str(), - context.workspace_id.as_deref(), - context.project_id.as_deref(), - ) else { - return Ok(None); - }; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 SceneApp Context Snapshot 目录失败({}): {error}", - parent.display() - ) - })?; - } - - let serialized = serde_json::to_string_pretty(context) - .map_err(|error| format!("序列化 SceneApp Context Snapshot 失败: {error}"))?; - fs::write(&path, serialized.as_bytes()).map_err(|error| { - format!( - "写入 SceneApp Context Snapshot 失败({}): {error}", - path.display() - ) - })?; - - Ok(Some(path)) -} - -#[cfg(test)] -mod tests { - use super::{ - build_persisted_sceneapp_context, load_persisted_sceneapp_context, - save_persisted_sceneapp_context, - }; - use crate::database::schema::create_tables; - use crate::database::DbConnection; - use crate::workspace::{WorkspaceManager, WorkspaceType}; - use rusqlite::Connection; - use std::sync::{Arc, Mutex}; - use tempfile::tempdir; - - fn setup_test_db() -> DbConnection { - let conn = Connection::open_in_memory().expect("创建内存数据库失败"); - create_tables(&conn).expect("初始化表结构失败"); - Arc::new(Mutex::new(conn)) - } - - #[test] - fn should_save_and_load_sceneapp_context_snapshot_under_workspace_root() { - let db = setup_test_db(); - let temp_dir = tempdir().expect("创建临时目录失败"); - let workspace_root = temp_dir.path().join("sceneapp-context-workspace"); - let workspace = WorkspaceManager::new(db.clone()) - .create_with_type( - "SceneApp Context".to_string(), - workspace_root.clone(), - WorkspaceType::General, - ) - .expect("创建 workspace 失败"); - - let snapshot = crate::sceneapp::context::dto::ContextLayerSnapshot { - workspace_id: Some(workspace.id.clone()), - project_id: Some(workspace.id.clone()), - skill_refs: vec!["story-video-suite".to_string()], - memory_refs: vec![format!("workspace:{}", workspace.id)], - tool_refs: vec!["workspace_storage".to_string()], - reference_items: vec![crate::sceneapp::context::dto::ReferenceItem { - id: "slot-style-a1b2".to_string(), - label: "style".to_string(), - source_kind: - crate::sceneapp::context::dto::ContextLayerSourceKind::ReferenceLibrary, - content_type: "slot".to_string(), - uri: None, - summary: Some("科技感,快节奏".to_string()), - selected: true, - usage_count: Some(1), - last_used_at: Some("2026-04-17T00:00:00.000Z".to_string()), - last_feedback_label: Some("可继续复用".to_string()), - }], - taste_profile: Some(crate::sceneapp::context::dto::TasteProfile { - profile_id: "taste-story-video-suite".to_string(), - summary: "偏好快节奏科技感表达。".to_string(), - keywords: vec!["快节奏".to_string(), "科技感".to_string()], - avoid_keywords: vec!["冗长铺垫".to_string()], - derived_from_reference_ids: vec!["slot-style-a1b2".to_string()], - confidence: Some(0.72), - feedback_summary: Some("最近一次运行已沉淀为正向风格反馈。".to_string()), - feedback_signals: vec!["publish_ready".to_string()], - last_feedback_at: Some("2026-04-17T00:00:00.000Z".to_string()), - }), - }; - let persisted = build_persisted_sceneapp_context("story-video-suite", &snapshot); - - let path = save_persisted_sceneapp_context(&db, &persisted) - .expect("写入 SceneApp Context Snapshot 失败") - .expect("应返回写入路径"); - assert!(path.exists()); - - let loaded = load_persisted_sceneapp_context( - &db, - "story-video-suite", - Some(workspace.id.as_str()), - Some(workspace.id.as_str()), - ) - .expect("读取 SceneApp Context Snapshot 失败"); - - assert_eq!(loaded, Some(persisted)); - } -} diff --git a/src-tauri/src/sceneapp/dto.rs b/src-tauri/src/sceneapp/dto.rs deleted file mode 100644 index 614020b66..000000000 --- a/src-tauri/src/sceneapp/dto.rs +++ /dev/null @@ -1,466 +0,0 @@ -use super::context::dto::SceneAppContextOverlay; -use lime_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeMap; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppType { - LocalInstant, - LocalDurable, - BrowserGrounded, - // legacy compat only:current 不再把 SceneApp 解释成云端托管执行面。 - #[serde(rename = "cloud_managed")] - DirectorySyncedCompat, - Hybrid, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppPattern { - Pipeline, - Generator, - Reviewer, - Inversion, - ToolWrapper, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppBindingFamily { - AgentTurn, - BrowserAssist, - AutomationJob, - // legacy compat only:current 执行仍收敛到本地主链,cloud_scene 只保留历史目录输入。 - #[serde(rename = "cloud_scene")] - ServiceSceneCompat, - NativeSkill, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppDeliveryContract { - ArtifactBundle, - ProjectPack, - TableReport, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppEntryBindingKind { - ServiceSkill, - Scene, - Mention, - WorkspaceCard, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppLaunchRequirementKind { - UserInput, - Project, - BrowserSession, - // legacy compat only:current 不再把云端会话当成启动门槛。 - #[serde(rename = "cloud_session")] - DirectorySessionCompat, - Automation, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppRecommendedAction { - Launch, - Keep, - Optimize, - Retire, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppMetricStatus { - Good, - Watch, - Risk, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppEntryBinding { - pub kind: SceneAppEntryBindingKind, - pub binding_family: SceneAppBindingFamily, - pub service_skill_id: Option, - pub skill_key: Option, - pub scene_key: Option, - pub command_prefix: Option, - pub aliases: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppLaunchRequirement { - pub kind: SceneAppLaunchRequirementKind, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppDeliveryProfile { - pub artifact_profile_ref: Option, - pub viewer_kind: Option, - pub required_parts: Vec, - pub primary_part: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppCompositionStepDescriptor { - pub id: String, - pub order: usize, - pub binding_profile_ref: Option, - pub binding_family: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppCompositionProfile { - pub blueprint_ref: Option, - pub step_count: usize, - pub steps: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppScorecardProfile { - pub profile_ref: Option, - pub metric_keys: Vec, - pub failure_signals: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppDescriptor { - pub id: String, - pub title: String, - pub summary: String, - pub category: String, - pub sceneapp_type: SceneAppType, - pub pattern_primary: SceneAppPattern, - pub pattern_stack: Vec, - pub capability_refs: Vec, - pub infra_profile: Vec, - pub delivery_contract: SceneAppDeliveryContract, - pub artifact_kind: Option, - pub output_hint: String, - pub entry_bindings: Vec, - pub launch_requirements: Vec, - pub linked_service_skill_id: Option, - pub linked_scene_key: Option, - pub delivery_profile: Option, - pub composition_profile: Option, - pub scorecard_profile: Option, - pub aliases: Vec, - pub source_package_id: String, - pub source_package_version: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppCatalog { - pub version: String, - pub generated_at: String, - pub items: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppRuntimeContext { - pub browser_session_attached: bool, - // legacy compat only:保留旧目录/旧计划输入,不参与 current readiness。 - #[serde(default, rename = "cloudSessionReady", alias = "cloud_session_ready")] - pub directory_session_ready_compat: bool, - pub automation_enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppLaunchIntent { - pub sceneapp_id: String, - pub entry_source: Option, - pub workspace_id: Option, - pub project_id: Option, - pub user_input: Option, - #[serde(default)] - pub reference_memory_ids: Vec, - #[serde(default)] - pub slots: BTreeMap, - pub runtime_context: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppAutomationIntent { - pub launch_intent: SceneAppLaunchIntent, - pub name: Option, - pub description: Option, - pub schedule: Option, - pub enabled: Option, - pub execution_mode: Option, - pub delivery: Option, - pub timeout_secs: Option, - pub max_retries: Option, - pub run_now: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppExecutionPlanStep { - pub id: String, - pub title: String, - pub binding_family: SceneAppBindingFamily, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppExecutionPlan { - pub sceneapp_id: String, - pub executor_kind: SceneAppBindingFamily, - pub binding_family: SceneAppBindingFamily, - pub step_plan: Vec, - pub adapter_plan: SceneAppRuntimeAdapterPlan, - pub storage_strategy: String, - pub artifact_contract: SceneAppDeliveryContract, - pub governance_hooks: Vec, - pub warnings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppReadiness { - pub ready: bool, - pub unmet_requirements: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppProjectPackPlan { - pub pack_kind: SceneAppDeliveryContract, - pub primary_part: Option, - pub required_parts: Vec, - pub viewer_kind: Option, - pub completion_strategy: String, - pub notes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppPlanResult { - pub descriptor: SceneAppDescriptor, - pub readiness: SceneAppReadiness, - pub plan: SceneAppExecutionPlan, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_overlay: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project_pack_plan: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppAutomationRunResult { - pub job_count: usize, - pub success_count: usize, - pub failed_count: usize, - pub timeout_count: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppAutomationResult { - pub sceneapp_id: String, - pub job_id: String, - pub job_name: String, - pub enabled: bool, - pub workspace_id: String, - pub next_run_at: Option, - pub run_now_result: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppRunStatus { - Queued, - Running, - Success, - Error, - Canceled, - Timeout, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppRuntimeAction { - SubmitAgentTurn, - LaunchBrowserAssist, - CreateAutomationJob, - #[serde(alias = "launch_cloud_scene")] - OpenServiceSceneSession, - LaunchNativeSkill, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppRuntimeAdapterPlan { - pub adapter_kind: SceneAppBindingFamily, - pub runtime_action: SceneAppRuntimeAction, - pub target_ref: String, - pub target_label: String, - pub linked_service_skill_id: Option, - pub linked_scene_key: Option, - pub preferred_profile_key: Option, - pub request_metadata: Value, - pub launch_payload: Value, - pub notes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppBrowserRuntimeRef { - pub profile_key: Option, - pub session_id: Option, - pub target_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppServiceSceneRuntimeRef { - pub scene_key: Option, - pub skill_id: Option, - pub project_id: Option, - pub content_id: Option, - pub workspace_id: Option, - pub entry_source: Option, - pub user_input: Option, - #[serde(default)] - pub slots: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppNativeSkillRuntimeRef { - pub skill_id: Option, - pub skill_key: Option, - pub project_id: Option, - pub workspace_id: Option, - pub user_input: Option, - #[serde(default)] - pub slots: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppDeliveryArtifactRef { - pub relative_path: String, - pub absolute_path: Option, - pub part_key: Option, - pub project_id: Option, - pub workspace_id: Option, - pub source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SceneAppGovernanceArtifactKind { - EvidenceSummary, - ReviewDecisionMarkdown, - ReviewDecisionJson, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppGovernanceArtifactRef { - pub kind: SceneAppGovernanceArtifactKind, - pub label: String, - pub relative_path: String, - pub absolute_path: Option, - pub project_id: Option, - pub workspace_id: Option, - pub source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppRunSummary { - pub run_id: String, - pub sceneapp_id: String, - pub status: SceneAppRunStatus, - pub source: String, - pub source_ref: Option, - pub session_id: Option, - pub browser_runtime_ref: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "cloudSceneRuntimeRef", - alias = "cloud_scene_runtime_ref" - )] - pub service_scene_runtime_ref: Option, - pub native_skill_runtime_ref: Option, - pub started_at: String, - pub finished_at: Option, - pub artifact_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delivery_artifact_refs: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub governance_artifact_refs: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delivery_required_parts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delivery_completed_parts: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delivery_missing_parts: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub delivery_completion_rate: Option, - #[serde(default)] - pub delivery_part_coverage_known: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub failure_signal: Option, - #[serde(default)] - pub runtime_evidence_used: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub evidence_known_gaps: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub verification_failure_outcomes: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_telemetry_available: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub request_telemetry_matched_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_validator_applicable: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_validator_issue_count: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_validator_recovered_count: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppScorecardMetric { - pub key: String, - pub label: String, - pub value: f64, - pub status: SceneAppMetricStatus, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SceneAppScorecard { - pub sceneapp_id: String, - pub updated_at: String, - pub summary: String, - pub metrics: Vec, - pub recommended_action: SceneAppRecommendedAction, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub observed_failure_signals: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub top_failure_signal: Option, -} diff --git a/src-tauri/src/sceneapp/governance.rs b/src-tauri/src/sceneapp/governance.rs deleted file mode 100644 index 9a5f3f6d5..000000000 --- a/src-tauri/src/sceneapp/governance.rs +++ /dev/null @@ -1,893 +0,0 @@ -use super::catalog::get_sceneapp_descriptor; -use super::dto::*; - -fn metric( - key: &str, - label: &str, - value: f64, - status: SceneAppMetricStatus, -) -> SceneAppScorecardMetric { - SceneAppScorecardMetric { - key: key.to_string(), - label: label.to_string(), - value, - status, - } -} - -fn round_percentage(value: f64) -> f64 { - (value * 10.0).round() / 10.0 -} - -fn percentage(numerator: usize, denominator: usize) -> f64 { - if denominator == 0 { - 0.0 - } else { - round_percentage((numerator as f64 / denominator as f64) * 100.0) - } -} - -fn metric_status(value: f64, good_threshold: f64, watch_threshold: f64) -> SceneAppMetricStatus { - if value >= good_threshold { - SceneAppMetricStatus::Good - } else if value >= watch_threshold { - SceneAppMetricStatus::Watch - } else { - SceneAppMetricStatus::Risk - } -} - -fn sceneapp_type_label(sceneapp_type: &SceneAppType) -> &'static str { - match sceneapp_type { - SceneAppType::LocalInstant => "本地即时", - SceneAppType::LocalDurable => "本地 durable", - SceneAppType::BrowserGrounded => "浏览器依赖", - SceneAppType::DirectorySyncedCompat => "目录同步", - SceneAppType::Hybrid => "多模态组合", - } -} - -fn recommended_action_label(action: &SceneAppRecommendedAction) -> &'static str { - match action { - SceneAppRecommendedAction::Launch => "继续扩大样本", - SceneAppRecommendedAction::Keep => "继续保留", - SceneAppRecommendedAction::Optimize => "优先优化", - SceneAppRecommendedAction::Retire => "考虑收口", - } -} - -fn classify_failure_bucket( - descriptor: &SceneAppDescriptor, - run: &SceneAppRunSummary, -) -> Option<&'static str> { - match run.status { - SceneAppRunStatus::Timeout => Some("dependency_failure"), - SceneAppRunStatus::Canceled => Some("adoption_failure"), - SceneAppRunStatus::Error => Some(match descriptor.sceneapp_type { - SceneAppType::BrowserGrounded | SceneAppType::DirectorySyncedCompat => { - "dependency_failure" - } - _ => "runtime_failure", - }), - SceneAppRunStatus::Queued | SceneAppRunStatus::Running | SceneAppRunStatus::Success => None, - } -} - -fn failure_signal_label(signal: &str) -> &str { - match signal { - "pack_incomplete" => "整包不完整", - "review_blocked" => "复核阻塞", - "publish_stalled" => "发布卡点", - "automation_timeout" => "自动化超时", - "dependency_failure" => "外部依赖与会话稳定性", - "adoption_failure" => "补参与人工中断", - "runtime_failure" => "运行链稳定性", - _ => signal, - } -} - -fn collect_observed_failure_signals( - descriptor: &SceneAppDescriptor, - runs: &[SceneAppRunSummary], -) -> Vec { - let mut counts = std::collections::BTreeMap::::new(); - for run in runs { - let signal = run - .failure_signal - .clone() - .or_else(|| classify_failure_bucket(descriptor, run).map(str::to_string)); - if let Some(signal) = signal { - *counts.entry(signal).or_insert(0) += 1; - } - } - - let mut ordered = counts.into_iter().collect::>(); - ordered.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); - ordered.into_iter().map(|(signal, _)| signal).collect() -} - -fn metric_label(key: &str) -> &str { - match key { - "complete_pack_rate" => "整包交付率", - "review_pass_rate" => "复核通过率", - "publish_conversion_rate" => "发布转化率", - "success_rate" | "run_success_rate" => "执行成功率", - "reuse_rate" | "repeat_use_rate" => "复用率", - "artifact_output_rate" => "结果产出率", - "sample_coverage" => "运行样本充足度", - "delivery_readiness" => "交付就绪度", - "reuse_potential" => "结果复用潜力", - _ => key, - } -} - -fn metric_status_for_key(key: &str, value: f64) -> SceneAppMetricStatus { - match key { - "complete_pack_rate" => metric_status(value, 80.0, 50.0), - "review_pass_rate" => metric_status(value, 75.0, 45.0), - "publish_conversion_rate" => metric_status(value, 60.0, 30.0), - "success_rate" | "run_success_rate" => metric_status(value, 80.0, 50.0), - "reuse_rate" | "repeat_use_rate" => metric_status(value, 35.0, 15.0), - "artifact_output_rate" => metric_status(value, 60.0, 30.0), - "sample_coverage" => metric_status(value, 80.0, 40.0), - "delivery_readiness" => metric_status(value, 80.0, 50.0), - "reuse_potential" => metric_status(value, 70.0, 40.0), - _ => metric_status(value, 70.0, 40.0), - } -} - -fn build_seeded_sceneapp_scorecard(descriptor: &SceneAppDescriptor) -> SceneAppScorecard { - let (summary, recommended_action, metrics) = match descriptor.sceneapp_type { - SceneAppType::Hybrid => ( - "多模态组合场景已经具备经营价值,但最需要继续打磨交付稳定性与跨执行器治理。" - .to_string(), - SceneAppRecommendedAction::Optimize, - vec![ - metric( - "delivery_readiness", - "交付就绪度", - 78.0, - SceneAppMetricStatus::Watch, - ), - metric( - "reuse_potential", - "结果复用潜力", - 86.0, - SceneAppMetricStatus::Good, - ), - metric( - "operational_risk", - "运行复杂度风险", - 64.0, - SceneAppMetricStatus::Watch, - ), - ], - ), - SceneAppType::BrowserGrounded => ( - "浏览器依赖型场景适合继续保留,但应优先降低登录态和页面结构波动带来的阻塞。" - .to_string(), - SceneAppRecommendedAction::Keep, - vec![ - metric( - "delivery_readiness", - "交付就绪度", - 80.0, - SceneAppMetricStatus::Good, - ), - metric( - "reuse_potential", - "结果复用潜力", - 82.0, - SceneAppMetricStatus::Good, - ), - metric( - "operational_risk", - "运行复杂度风险", - 58.0, - SceneAppMetricStatus::Watch, - ), - ], - ), - SceneAppType::DirectorySyncedCompat => ( - "目录同步型场景仍可能带入历史兼容目录输入,但 current 执行已经回到本地主链,应持续关注目录质量、完成率和单位成功成本。" - .to_string(), - SceneAppRecommendedAction::Launch, - vec![ - metric( - "delivery_readiness", - "交付就绪度", - 84.0, - SceneAppMetricStatus::Good, - ), - metric( - "reuse_potential", - "结果复用潜力", - 75.0, - SceneAppMetricStatus::Watch, - ), - metric( - "operational_risk", - "运行复杂度风险", - 42.0, - SceneAppMetricStatus::Good, - ), - ], - ), - SceneAppType::LocalDurable => ( - "本地 durable 场景适合保留,下一步重点是提高调度透明度和失败可恢复性。".to_string(), - SceneAppRecommendedAction::Keep, - vec![ - metric( - "delivery_readiness", - "交付就绪度", - 76.0, - SceneAppMetricStatus::Watch, - ), - metric( - "reuse_potential", - "结果复用潜力", - 81.0, - SceneAppMetricStatus::Good, - ), - metric( - "operational_risk", - "运行复杂度风险", - 47.0, - SceneAppMetricStatus::Good, - ), - ], - ), - SceneAppType::LocalInstant => ( - "本地即时场景适合继续保留,并优先作为新能力的装配试验田。".to_string(), - SceneAppRecommendedAction::Keep, - vec![ - metric( - "delivery_readiness", - "交付就绪度", - 79.0, - SceneAppMetricStatus::Good, - ), - metric( - "reuse_potential", - "结果复用潜力", - 68.0, - SceneAppMetricStatus::Watch, - ), - metric( - "operational_risk", - "运行复杂度风险", - 35.0, - SceneAppMetricStatus::Good, - ), - ], - ), - }; - - SceneAppScorecard { - sceneapp_id: descriptor.id.clone(), - updated_at: "2026-04-15T00:00:00.000Z".to_string(), - summary, - metrics, - recommended_action, - observed_failure_signals: Vec::new(), - top_failure_signal: None, - } -} - -pub fn build_sceneapp_scorecard_from_runs( - descriptor: &SceneAppDescriptor, - runs: &[SceneAppRunSummary], -) -> SceneAppScorecard { - if runs.is_empty() { - return build_seeded_sceneapp_scorecard(descriptor); - } - - let run_count = runs.len(); - let terminal_runs: Vec<&SceneAppRunSummary> = runs - .iter() - .filter(|run| { - matches!( - run.status, - SceneAppRunStatus::Success - | SceneAppRunStatus::Error - | SceneAppRunStatus::Canceled - | SceneAppRunStatus::Timeout - ) - }) - .collect(); - let success_count = terminal_runs - .iter() - .filter(|run| matches!(run.status, SceneAppRunStatus::Success)) - .count(); - let artifactful_count = runs.iter().filter(|run| run.artifact_count > 0).count(); - let repeat_use_rate = if run_count <= 1 { - 0.0 - } else { - percentage(run_count.saturating_sub(1), run_count) - }; - let sample_coverage = percentage(run_count.min(6), 6); - let success_rate = percentage(success_count, terminal_runs.len()); - let artifact_output_rate = percentage(artifactful_count, run_count); - let failure_count = terminal_runs.len().saturating_sub(success_count); - let observed_failure_signals = collect_observed_failure_signals(descriptor, runs); - let top_failure_signal = observed_failure_signals.first().cloned(); - - let recommended_action = if run_count < 2 { - SceneAppRecommendedAction::Launch - } else if success_rate >= 78.0 && artifact_output_rate >= 50.0 && repeat_use_rate >= 25.0 { - SceneAppRecommendedAction::Keep - } else if run_count >= 5 && success_rate < 35.0 && artifact_output_rate < 20.0 { - SceneAppRecommendedAction::Retire - } else { - SceneAppRecommendedAction::Optimize - }; - - let failure_sentence = match top_failure_signal.as_deref() { - Some(signal) => format!("当前主要阻塞集中在{}。", failure_signal_label(signal)), - None if failure_count > 0 => "当前失败分布仍偏分散,需要继续补样本。".to_string(), - _ => "当前还没有出现明显失败簇。".to_string(), - }; - let activity_sentence = if runs.iter().any(|run| { - matches!( - run.status, - SceneAppRunStatus::Queued | SceneAppRunStatus::Running - ) - }) { - "目录里仍有运行中的样本,后续分数会继续波动。" - } else { - "当前样本已经以终态运行记录为主。" - }; - let summary = format!( - "基于最近 {run_count} 次真实运行,{sceneapp_type}场景的执行成功率为 {success_rate:.1}% ,结果产出率为 {artifact_output_rate:.1}% ,重复使用率为 {repeat_use_rate:.1}% 。{failure_sentence}{activity_sentence} 目前更适合{}。", - recommended_action_label(&recommended_action), - sceneapp_type = sceneapp_type_label(&descriptor.sceneapp_type), - ); - - let updated_at = runs - .iter() - .map(|run| { - run.finished_at - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or(run.started_at.as_str()) - .to_string() - }) - .max() - .unwrap_or_else(|| "2026-04-15T00:00:00.000Z".to_string()); - - if descriptor.delivery_contract == SceneAppDeliveryContract::ProjectPack { - let complete_pack_rate = percentage( - terminal_runs - .iter() - .filter(|run| { - run.delivery_part_coverage_known - && !run.delivery_required_parts.is_empty() - && run.delivery_missing_parts.is_empty() - }) - .count(), - terminal_runs.len(), - ); - let review_pass_rate = percentage( - terminal_runs - .iter() - .filter(|run| { - matches!(run.status, SceneAppRunStatus::Success) - && run.failure_signal.as_deref() != Some("review_blocked") - && run.artifact_count > 0 - }) - .count(), - terminal_runs.len(), - ); - let publish_conversion_rate = percentage( - terminal_runs - .iter() - .filter(|run| { - matches!(run.status, SceneAppRunStatus::Success) - && run.artifact_count > 0 - && run.failure_signal.as_deref() != Some("publish_stalled") - }) - .count(), - terminal_runs.len(), - ); - let pack_recommended_action = if run_count < 2 { - SceneAppRecommendedAction::Launch - } else if complete_pack_rate >= 75.0 - && review_pass_rate >= 70.0 - && publish_conversion_rate >= 55.0 - { - SceneAppRecommendedAction::Keep - } else if terminal_runs.len() >= 5 - && complete_pack_rate < 25.0 - && publish_conversion_rate < 20.0 - { - SceneAppRecommendedAction::Retire - } else { - SceneAppRecommendedAction::Optimize - }; - let summary = format!( - "基于最近 {run_count} 次真实运行,{sceneapp_type}场景的整包交付率为 {complete_pack_rate:.1}% ,复核通过率为 {review_pass_rate:.1}% ,发布转化率为 {publish_conversion_rate:.1}% 。{failure_sentence}{activity_sentence} 目前更适合{}。", - recommended_action_label(&pack_recommended_action), - sceneapp_type = sceneapp_type_label(&descriptor.sceneapp_type), - ); - let metric_keys = descriptor - .scorecard_profile - .as_ref() - .map(|profile| profile.metric_keys.clone()) - .filter(|keys| !keys.is_empty()) - .unwrap_or_else(|| { - vec![ - "complete_pack_rate".to_string(), - "review_pass_rate".to_string(), - "publish_conversion_rate".to_string(), - ] - }); - - let metrics = metric_keys - .iter() - .filter_map(|key| { - let value = match key.as_str() { - "complete_pack_rate" => Some(complete_pack_rate), - "review_pass_rate" => Some(review_pass_rate), - "publish_conversion_rate" => Some(publish_conversion_rate), - "success_rate" | "run_success_rate" => Some(success_rate), - "reuse_rate" | "repeat_use_rate" => Some(repeat_use_rate), - "artifact_output_rate" => Some(artifact_output_rate), - "sample_coverage" => Some(sample_coverage), - _ => None, - }?; - Some(metric( - key, - metric_label(key), - value, - metric_status_for_key(key, value), - )) - }) - .collect::>(); - - return SceneAppScorecard { - sceneapp_id: descriptor.id.clone(), - updated_at, - summary, - metrics, - recommended_action: pack_recommended_action, - observed_failure_signals, - top_failure_signal, - }; - } - - let requested_metric_keys = descriptor - .scorecard_profile - .as_ref() - .map(|profile| profile.metric_keys.clone()) - .filter(|keys| !keys.is_empty()) - .unwrap_or_else(|| { - vec![ - "sample_coverage".to_string(), - "success_rate".to_string(), - "artifact_output_rate".to_string(), - "reuse_rate".to_string(), - ] - }); - SceneAppScorecard { - sceneapp_id: descriptor.id.clone(), - updated_at, - summary, - metrics: requested_metric_keys - .iter() - .filter_map(|key| { - let value = match key.as_str() { - "sample_coverage" => Some(sample_coverage), - "success_rate" | "run_success_rate" => Some(success_rate), - "artifact_output_rate" => Some(artifact_output_rate), - "reuse_rate" | "repeat_use_rate" => Some(repeat_use_rate), - "delivery_readiness" => Some(artifact_output_rate), - "reuse_potential" => Some(repeat_use_rate), - _ => None, - }?; - Some(metric( - key, - metric_label(key), - value, - metric_status_for_key(key, value), - )) - }) - .collect(), - recommended_action, - observed_failure_signals, - top_failure_signal, - } -} - -pub fn get_sceneapp_scorecard(sceneapp_id: &str) -> Option { - let descriptor = get_sceneapp_descriptor(sceneapp_id)?; - Some(build_seeded_sceneapp_scorecard(&descriptor)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::sceneapp::catalog::get_sceneapp_descriptor; - - fn run( - run_id: &str, - status: SceneAppRunStatus, - artifact_count: usize, - started_at: &str, - ) -> SceneAppRunSummary { - SceneAppRunSummary { - run_id: run_id.to_string(), - sceneapp_id: "daily-trend-briefing".to_string(), - status, - source: "automation".to_string(), - source_ref: Some("job-daily-trend".to_string()), - session_id: None, - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: started_at.to_string(), - finished_at: Some(started_at.to_string()), - artifact_count, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: Vec::new(), - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - } - } - - fn project_pack_run( - run_id: &str, - status: SceneAppRunStatus, - completed_parts: &[&str], - missing_parts: &[&str], - failure_signal: Option<&str>, - started_at: &str, - ) -> SceneAppRunSummary { - SceneAppRunSummary { - run_id: run_id.to_string(), - sceneapp_id: "story-video-suite".to_string(), - status, - source: "chat".to_string(), - source_ref: Some("session-story-video".to_string()), - session_id: Some("session-story-video".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: started_at.to_string(), - finished_at: Some(started_at.to_string()), - artifact_count: completed_parts.len(), - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: vec![ - "brief".to_string(), - "storyboard".to_string(), - "script".to_string(), - "music_refs".to_string(), - "video_draft".to_string(), - "review_note".to_string(), - ], - delivery_completed_parts: completed_parts - .iter() - .map(|part| part.to_string()) - .collect(), - delivery_missing_parts: missing_parts.iter().map(|part| part.to_string()).collect(), - delivery_completion_rate: Some(percentage( - completed_parts.len(), - completed_parts.len() + missing_parts.len(), - )), - delivery_part_coverage_known: true, - failure_signal: failure_signal.map(str::to_string), - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - } - } - - #[test] - fn build_sceneapp_scorecard_from_runs_should_keep_stable_scene() { - let descriptor = - get_sceneapp_descriptor("daily-trend-briefing").expect("descriptor should exist"); - let scorecard = build_sceneapp_scorecard_from_runs( - &descriptor, - &[ - run( - "run-1", - SceneAppRunStatus::Success, - 1, - "2026-04-15T00:00:00.000Z", - ), - run( - "run-2", - SceneAppRunStatus::Success, - 1, - "2026-04-15T01:00:00.000Z", - ), - run( - "run-3", - SceneAppRunStatus::Success, - 1, - "2026-04-15T02:00:00.000Z", - ), - run( - "run-4", - SceneAppRunStatus::Success, - 1, - "2026-04-15T03:00:00.000Z", - ), - ], - ); - - assert_eq!( - scorecard.recommended_action, - SceneAppRecommendedAction::Keep - ); - assert!(scorecard.summary.contains("执行成功率为 100.0%")); - assert_eq!(scorecard.updated_at, "2026-04-15T03:00:00.000Z"); - } - - #[test] - fn build_sceneapp_scorecard_from_runs_should_fallback_to_seeded_baseline() { - let descriptor = - get_sceneapp_descriptor("story-video-suite").expect("descriptor should exist"); - let scorecard = build_sceneapp_scorecard_from_runs(&descriptor, &[]); - - assert_eq!( - scorecard.recommended_action, - SceneAppRecommendedAction::Optimize - ); - assert!(scorecard.summary.contains("多模态组合场景")); - } - - #[test] - fn build_sceneapp_scorecard_from_runs_should_use_project_pack_metrics() { - let descriptor = - get_sceneapp_descriptor("story-video-suite").expect("descriptor should exist"); - let scorecard = build_sceneapp_scorecard_from_runs( - &descriptor, - &[ - project_pack_run( - "run-1", - SceneAppRunStatus::Success, - &[ - "brief", - "storyboard", - "script", - "music_refs", - "video_draft", - "review_note", - ], - &[], - None, - "2026-04-15T00:00:00.000Z", - ), - project_pack_run( - "run-2", - SceneAppRunStatus::Success, - &["brief", "storyboard", "script", "music_refs", "video_draft"], - &["review_note"], - Some("review_blocked"), - "2026-04-15T01:00:00.000Z", - ), - ], - ); - - assert_eq!( - scorecard.metrics, - vec![ - metric( - "complete_pack_rate", - "整包交付率", - 50.0, - SceneAppMetricStatus::Watch, - ), - metric( - "review_pass_rate", - "复核通过率", - 50.0, - SceneAppMetricStatus::Watch, - ), - metric( - "publish_conversion_rate", - "发布转化率", - 100.0, - SceneAppMetricStatus::Good, - ), - ] - ); - assert_eq!( - scorecard.recommended_action, - SceneAppRecommendedAction::Optimize - ); - assert_eq!( - scorecard.top_failure_signal.as_deref(), - Some("review_blocked") - ); - assert_eq!( - scorecard.observed_failure_signals, - vec!["review_blocked".to_string()] - ); - assert!(scorecard.summary.contains("整包交付率为 50.0%")); - } - - #[test] - fn build_sceneapp_scorecard_from_runs_should_retire_poor_scene() { - let descriptor = - get_sceneapp_descriptor("x-article-export").expect("descriptor should exist"); - let runs = [ - SceneAppRunSummary { - run_id: "run-1".to_string(), - sceneapp_id: descriptor.id.clone(), - status: SceneAppRunStatus::Error, - source: "chat".to_string(), - source_ref: None, - session_id: Some("session-1".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T00:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T00:00:00.000Z".to_string()), - artifact_count: 0, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: Vec::new(), - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - SceneAppRunSummary { - run_id: "run-2".to_string(), - sceneapp_id: descriptor.id.clone(), - status: SceneAppRunStatus::Timeout, - source: "chat".to_string(), - source_ref: None, - session_id: Some("session-2".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T01:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T01:00:00.000Z".to_string()), - artifact_count: 0, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: Vec::new(), - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - SceneAppRunSummary { - run_id: "run-3".to_string(), - sceneapp_id: descriptor.id.clone(), - status: SceneAppRunStatus::Error, - source: "chat".to_string(), - source_ref: None, - session_id: Some("session-3".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T02:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T02:00:00.000Z".to_string()), - artifact_count: 0, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: Vec::new(), - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - SceneAppRunSummary { - run_id: "run-4".to_string(), - sceneapp_id: descriptor.id.clone(), - status: SceneAppRunStatus::Canceled, - source: "chat".to_string(), - source_ref: None, - session_id: Some("session-4".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T03:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T03:00:00.000Z".to_string()), - artifact_count: 0, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: Vec::new(), - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - SceneAppRunSummary { - run_id: "run-5".to_string(), - sceneapp_id: descriptor.id.clone(), - status: SceneAppRunStatus::Error, - source: "chat".to_string(), - source_ref: None, - session_id: Some("session-5".to_string()), - browser_runtime_ref: None, - service_scene_runtime_ref: None, - native_skill_runtime_ref: None, - started_at: "2026-04-15T04:00:00.000Z".to_string(), - finished_at: Some("2026-04-15T04:00:00.000Z".to_string()), - artifact_count: 0, - delivery_artifact_refs: Vec::new(), - governance_artifact_refs: Vec::new(), - delivery_required_parts: Vec::new(), - delivery_completed_parts: Vec::new(), - delivery_missing_parts: Vec::new(), - delivery_completion_rate: None, - delivery_part_coverage_known: false, - failure_signal: None, - runtime_evidence_used: false, - evidence_known_gaps: Vec::new(), - verification_failure_outcomes: Vec::new(), - request_telemetry_available: None, - request_telemetry_matched_count: None, - artifact_validator_applicable: None, - artifact_validator_issue_count: None, - artifact_validator_recovered_count: None, - }, - ]; - - let scorecard = build_sceneapp_scorecard_from_runs(&descriptor, &runs); - - assert_eq!( - scorecard.recommended_action, - SceneAppRecommendedAction::Retire - ); - assert!(scorecard.summary.contains("外部依赖与会话稳定性")); - } -} diff --git a/src-tauri/src/sceneapp/mod.rs b/src-tauri/src/sceneapp/mod.rs deleted file mode 100644 index 3b2c77b7e..000000000 --- a/src-tauri/src/sceneapp/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod adapters; -pub mod application; -pub mod catalog; -pub mod context; -pub mod dto; -pub mod governance; -pub mod runtime; diff --git a/src-tauri/src/sceneapp/runtime.rs b/src-tauri/src/sceneapp/runtime.rs deleted file mode 100644 index e6df6c0bf..000000000 --- a/src-tauri/src/sceneapp/runtime.rs +++ /dev/null @@ -1,554 +0,0 @@ -use super::adapters::build_sceneapp_runtime_adapter_plan; -use super::context::compiler::build_sceneapp_context_overlay; -use super::context::dto::{ReferenceItem, SceneAppContextOverlay}; -use super::context::store::PersistedSceneAppContext; -use super::dto::*; -use std::collections::BTreeSet; - -fn pattern_title(pattern: &SceneAppPattern) -> &'static str { - match pattern { - SceneAppPattern::Pipeline => "执行多步场景编排", - SceneAppPattern::Generator => "生成结构化结果产物", - SceneAppPattern::Reviewer => "执行末尾质量审查", - SceneAppPattern::Inversion => "补齐缺失参数与上下文", - SceneAppPattern::ToolWrapper => "加载站点/协议封装能力", - } -} - -fn storage_strategy(descriptor: &SceneAppDescriptor) -> String { - if descriptor - .infra_profile - .iter() - .any(|item| item == "db_store" || item == "json_snapshot") - { - return "db_plus_snapshot".to_string(); - } - if descriptor - .infra_profile - .iter() - .any(|item| item == "artifact_bundle" || item == "workspace_storage") - { - return "workspace_bundle".to_string(); - } - "session_only".to_string() -} - -fn governance_hooks(descriptor: &SceneAppDescriptor) -> Vec { - let mut hooks = vec!["evidence_pack".to_string(), "scorecard".to_string()]; - if descriptor - .pattern_stack - .iter() - .any(|pattern| matches!(pattern, SceneAppPattern::Reviewer)) - { - hooks.push("review_policy".to_string()); - } - if descriptor - .infra_profile - .iter() - .any(|item| item == "automation_schedule") - { - hooks.push("retry_policy".to_string()); - } - hooks -} - -fn requirement_satisfied( - requirement: &SceneAppLaunchRequirement, - intent: &SceneAppLaunchIntent, -) -> bool { - let runtime = intent.runtime_context.clone().unwrap_or_default(); - match requirement.kind { - SceneAppLaunchRequirementKind::UserInput => { - intent - .user_input - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || intent.slots.values().any(|value| !value.trim().is_empty()) - } - SceneAppLaunchRequirementKind::Project => intent - .project_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()), - SceneAppLaunchRequirementKind::BrowserSession => runtime.browser_session_attached, - // legacy compat only:current SceneApp 不再把云端会话视为执行门槛。 - SceneAppLaunchRequirementKind::DirectorySessionCompat => true, - SceneAppLaunchRequirementKind::Automation => runtime.automation_enabled, - } -} - -fn build_readiness( - descriptor: &SceneAppDescriptor, - intent: &SceneAppLaunchIntent, -) -> SceneAppReadiness { - let unmet_requirements = descriptor - .launch_requirements - .iter() - .filter(|requirement| !requirement_satisfied(requirement, intent)) - .cloned() - .collect::>(); - - SceneAppReadiness { - ready: unmet_requirements.is_empty(), - unmet_requirements, - } -} - -fn build_step_plan(descriptor: &SceneAppDescriptor) -> Vec { - let binding_family = descriptor - .entry_bindings - .first() - .map(|binding| binding.binding_family.clone()) - .unwrap_or(SceneAppBindingFamily::AgentTurn); - - descriptor - .pattern_stack - .iter() - .enumerate() - .map(|(index, pattern)| SceneAppExecutionPlanStep { - id: format!("step-{}", index + 1), - title: pattern_title(pattern).to_string(), - binding_family: binding_family.clone(), - }) - .collect() -} - -fn dedupe_strings(values: I) -> Vec -where - I: IntoIterator, -{ - let mut seen = BTreeSet::new(); - values - .into_iter() - .filter_map(|value| { - let normalized = value.trim().to_string(); - if normalized.is_empty() || !seen.insert(normalized.clone()) { - return None; - } - Some(normalized) - }) - .collect() -} - -fn build_project_pack_required_parts(descriptor: &SceneAppDescriptor) -> Vec { - let declared_parts = descriptor - .delivery_profile - .as_ref() - .map(|profile| profile.required_parts.clone()) - .unwrap_or_default(); - if !declared_parts.is_empty() { - return dedupe_strings(declared_parts); - } - - descriptor - .composition_profile - .as_ref() - .map(|profile| { - dedupe_strings( - profile - .steps - .iter() - .map(|step| step.id.clone()) - .collect::>(), - ) - }) - .unwrap_or_default() -} - -fn build_project_pack_completion_strategy( - descriptor: &SceneAppDescriptor, - required_parts: &[String], -) -> String { - if !required_parts.is_empty() { - return "required_parts_complete".to_string(); - } - if descriptor - .infra_profile - .iter() - .any(|item| item == "workspace_storage" || item == "artifact_bundle") - { - return "workspace_artifact_writeback".to_string(); - } - "artifact_writeback".to_string() -} - -fn build_project_pack_plan( - descriptor: &SceneAppDescriptor, - intent: &SceneAppLaunchIntent, - context_overlay: &SceneAppContextOverlay, -) -> SceneAppProjectPackPlan { - let required_parts = build_project_pack_required_parts(descriptor); - let viewer_kind = descriptor - .delivery_profile - .as_ref() - .and_then(|profile| profile.viewer_kind.clone()); - let primary_part = descriptor - .delivery_profile - .as_ref() - .and_then(|profile| profile.primary_part.clone()) - .or_else(|| required_parts.first().cloned()); - let completion_strategy = - build_project_pack_completion_strategy(descriptor, required_parts.as_slice()); - let mut notes = Vec::new(); - - if matches!( - descriptor.delivery_contract, - SceneAppDeliveryContract::ProjectPack - ) { - notes.push("当前 SceneApp 以结果包作为默认交付单位。".to_string()); - } - if !required_parts.is_empty() { - notes.push(format!( - "完整度将按 {} 个必含部件判断。", - required_parts.len() - )); - } - if let Some(project_id) = intent - .project_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - notes.push(format!( - "结果会优先回写到项目 {project_id},便于继续编辑与复盘。" - )); - } else { - notes.push("当前还没有绑定项目,结果包只能先按运行时结果临时回流。".to_string()); - } - if context_overlay.compiler_plan.reference_count > 0 - || context_overlay.snapshot.taste_profile.is_some() - { - notes.push("结果包会连同参考与风格快照一起进入后续治理链。".to_string()); - } - if viewer_kind.is_some() { - notes.push("结果包可继续复用当前 viewer 主链打开与检查。".to_string()); - } - - SceneAppProjectPackPlan { - pack_kind: descriptor.delivery_contract.clone(), - primary_part, - required_parts, - viewer_kind, - completion_strategy, - notes, - } -} - -pub fn build_launch_plan( - descriptor: SceneAppDescriptor, - intent: SceneAppLaunchIntent, - persisted_context: Option<&PersistedSceneAppContext>, - explicit_reference_items: &[ReferenceItem], -) -> SceneAppPlanResult { - let context_overlay = build_sceneapp_context_overlay( - &descriptor, - &intent, - persisted_context, - explicit_reference_items, - ); - let project_pack_plan = build_project_pack_plan(&descriptor, &intent, &context_overlay); - let sceneapp_id = descriptor.id.clone(); - let binding_family = descriptor - .entry_bindings - .first() - .map(|binding| binding.binding_family.clone()) - .unwrap_or(SceneAppBindingFamily::AgentTurn); - let readiness = build_readiness(&descriptor, &intent); - let step_plan = build_step_plan(&descriptor); - let adapter_plan = build_sceneapp_runtime_adapter_plan(&descriptor, &intent); - let storage_strategy = storage_strategy(&descriptor); - let artifact_contract = descriptor.delivery_contract.clone(); - let hooks = governance_hooks(&descriptor); - let mut warnings = Vec::new(); - if !readiness.ready { - warnings.push("当前 SceneApp 仍有未满足的启动前置条件。".to_string()); - } - if !intent - .workspace_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - warnings.push("尚未显式绑定 workspace_id,后续应接入统一工作区边界。".to_string()); - } - - SceneAppPlanResult { - descriptor: descriptor.clone(), - readiness, - context_overlay: Some(context_overlay), - project_pack_plan: Some(project_pack_plan), - plan: SceneAppExecutionPlan { - sceneapp_id, - executor_kind: binding_family.clone(), - binding_family, - step_plan, - adapter_plan, - storage_strategy, - artifact_contract, - governance_hooks: hooks, - warnings, - }, - } -} - -#[cfg(test)] -mod tests { - use super::build_launch_plan; - use crate::sceneapp::catalog::get_sceneapp_descriptor; - use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile}; - use crate::sceneapp::context::store::PersistedSceneAppContext; - use crate::sceneapp::dto::{ - SceneAppBindingFamily, SceneAppDeliveryContract, SceneAppLaunchIntent, - SceneAppRuntimeAction, SceneAppRuntimeContext, - }; - use std::collections::BTreeMap; - - #[test] - fn should_build_service_scene_adapter_plan_for_hybrid_sceneapp() { - let descriptor = get_sceneapp_descriptor("story-video-suite") - .expect("story-video-suite descriptor should exist"); - let plan = build_launch_plan( - descriptor, - SceneAppLaunchIntent { - sceneapp_id: "story-video-suite".to_string(), - entry_source: Some("sceneapp_card".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-video".to_string()), - user_input: Some("根据发布会内容生成 30 秒短视频草稿".to_string()), - reference_memory_ids: Vec::new(), - slots: BTreeMap::new(), - runtime_context: Some(SceneAppRuntimeContext::default()), - }, - None, - &[], - ); - - assert_eq!( - plan.plan.adapter_plan.runtime_action, - SceneAppRuntimeAction::OpenServiceSceneSession - ); - assert_eq!(plan.plan.executor_kind, SceneAppBindingFamily::AgentTurn); - assert_eq!(plan.plan.binding_family, SceneAppBindingFamily::AgentTurn); - assert!(plan.context_overlay.is_some()); - assert_eq!( - plan.context_overlay - .as_ref() - .map(|overlay| overlay.compiler_plan.reference_count), - Some(1) - ); - assert_eq!( - plan.project_pack_plan - .as_ref() - .map(|pack| pack.pack_kind.clone()), - Some(SceneAppDeliveryContract::ProjectPack) - ); - assert_eq!( - plan.project_pack_plan - .as_ref() - .map(|pack| pack.completion_strategy.as_str()), - Some("required_parts_complete") - ); - assert_eq!( - plan.plan.adapter_plan.target_ref, - "sceneapp-service-story-video" - ); - assert!(plan - .plan - .adapter_plan - .request_metadata - .pointer("/harness/service_scene_launch/service_scene_run/scene_key") - .is_some()); - assert_eq!( - plan.plan - .adapter_plan - .request_metadata - .pointer("/harness/service_scene_launch/kind") - .and_then(|value| value.as_str()), - Some("local_service_skill") - ); - assert_eq!( - plan.plan - .adapter_plan - .request_metadata - .pointer("/harness/service_scene_launch/service_scene_run/execution_kind") - .and_then(|value| value.as_str()), - Some("local_service_skill") - ); - assert_eq!( - plan.plan - .adapter_plan - .request_metadata - .pointer("/harness/service_scene_launch/service_scene_run/execution_location") - .and_then(|value| value.as_str()), - Some("client_default") - ); - } - - #[test] - fn should_build_browser_assist_adapter_plan_with_known_adapter_name() { - let descriptor = get_sceneapp_descriptor("x-article-export") - .expect("x-article-export descriptor should exist"); - let mut slots = BTreeMap::new(); - slots.insert( - "article_url".to_string(), - "https://x.com/openai/article/123".to_string(), - ); - slots.insert("target_language".to_string(), "中文".to_string()); - - let plan = build_launch_plan( - descriptor, - SceneAppLaunchIntent { - sceneapp_id: "x-article-export".to_string(), - entry_source: Some("sceneapp_card".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-research".to_string()), - user_input: Some("请导出这篇文章".to_string()), - reference_memory_ids: Vec::new(), - slots, - runtime_context: Some(SceneAppRuntimeContext { - browser_session_attached: true, - ..SceneAppRuntimeContext::default() - }), - }, - None, - &[], - ); - - assert_eq!( - plan.plan.adapter_plan.runtime_action, - SceneAppRuntimeAction::LaunchBrowserAssist - ); - assert!(plan.context_overlay.as_ref().is_some_and(|overlay| overlay - .compiler_plan - .active_layers - .contains(&"reference".to_string()))); - assert_eq!( - plan.project_pack_plan - .as_ref() - .map(|pack| pack.required_parts.len()), - Some(2) - ); - assert_eq!(plan.plan.adapter_plan.target_ref, "x/article-export"); - assert_eq!( - plan.plan.adapter_plan.preferred_profile_key.as_deref(), - Some("general_browser_assist") - ); - assert_eq!( - plan.plan - .adapter_plan - .launch_payload - .pointer("/args/url") - .and_then(|value| value.as_str()), - Some("https://x.com/openai/article/123") - ); - } - - #[test] - fn should_build_automation_adapter_plan_for_local_durable_sceneapp() { - let descriptor = get_sceneapp_descriptor("daily-trend-briefing") - .expect("daily-trend-briefing descriptor should exist"); - let plan = build_launch_plan( - descriptor, - SceneAppLaunchIntent { - sceneapp_id: "daily-trend-briefing".to_string(), - entry_source: Some("sceneapp_card".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-growth".to_string()), - user_input: Some("关注 AI Agent 产品趋势".to_string()), - reference_memory_ids: Vec::new(), - slots: BTreeMap::new(), - runtime_context: Some(SceneAppRuntimeContext { - automation_enabled: true, - ..SceneAppRuntimeContext::default() - }), - }, - None, - &[], - ); - - assert_eq!( - plan.plan.adapter_plan.runtime_action, - SceneAppRuntimeAction::CreateAutomationJob - ); - assert!(plan.context_overlay.as_ref().is_some_and(|overlay| overlay - .compiler_plan - .active_layers - .contains(&"memory".to_string()))); - assert_eq!( - plan.plan.adapter_plan.target_ref, - "sceneapp-service-daily-trend" - ); - assert_eq!( - plan.plan - .adapter_plan - .launch_payload - .pointer("/schedule/kind") - .and_then(|value| value.as_str()), - Some("every") - ); - } - - #[test] - fn should_merge_persisted_context_into_launch_plan() { - let descriptor = get_sceneapp_descriptor("story-video-suite") - .expect("story-video-suite descriptor should exist"); - let persisted_context = PersistedSceneAppContext { - sceneapp_id: "story-video-suite".to_string(), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-video".to_string()), - reference_items: vec![ReferenceItem { - id: "saved-reference-1".to_string(), - label: "历史拆解".to_string(), - source_kind: ContextLayerSourceKind::ReferenceLibrary, - content_type: "text".to_string(), - uri: None, - summary: Some("保留强开头与多镜头切换。".to_string()), - selected: true, - usage_count: Some(1), - last_used_at: Some("2026-04-16T00:00:00.000Z".to_string()), - last_feedback_label: Some("可继续复用".to_string()), - }], - taste_profile: Some(TasteProfile { - profile_id: "taste-story-video-suite".to_string(), - summary: "偏好科技感、快节奏。".to_string(), - keywords: vec!["科技感".to_string(), "快节奏".to_string()], - avoid_keywords: vec!["铺垫过长".to_string()], - derived_from_reference_ids: vec!["saved-reference-1".to_string()], - confidence: Some(0.74), - feedback_summary: Some("最近一次运行可继续复用。".to_string()), - feedback_signals: vec!["publish_ready".to_string()], - last_feedback_at: Some("2026-04-16T00:00:00.000Z".to_string()), - }), - last_feedback_run_id: Some("sceneapp-run-1".to_string()), - }; - let mut slots = BTreeMap::new(); - slots.insert("style".to_string(), "科技感".to_string()); - - let plan = build_launch_plan( - descriptor, - SceneAppLaunchIntent { - sceneapp_id: "story-video-suite".to_string(), - entry_source: Some("sceneapp_card".to_string()), - workspace_id: Some("workspace-default".to_string()), - project_id: Some("project-video".to_string()), - user_input: Some("根据这次发布会做 30 秒短视频".to_string()), - reference_memory_ids: Vec::new(), - slots, - runtime_context: Some(SceneAppRuntimeContext::default()), - }, - Some(&persisted_context), - &[], - ); - - let overlay = plan.context_overlay.expect("context overlay should exist"); - assert_eq!(overlay.compiler_plan.reference_count, 3); - assert!(overlay - .compiler_plan - .notes - .iter() - .any(|note| note.contains("已从项目上下文恢复 1 条历史参考"))); - assert!(overlay - .compiler_plan - .notes - .iter() - .any(|note| note.contains("当前已复用项目级 TasteProfile"))); - } -} diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index 366e32ca2..3a826f426 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -31,9 +31,7 @@ pub use crate::services::runtime_evidence_pack_output_service::{ use crate::services::runtime_evidence_request_telemetry_service::collect_request_telemetry; #[cfg(test)] use crate::services::runtime_evidence_verification_service::collect_requested_fix_execution_results; -use crate::services::runtime_evidence_verification_service::{ - collect_runtime_verification, extract_verification_failure_outcomes, -}; +use crate::services::runtime_evidence_verification_service::collect_runtime_verification; 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; @@ -67,19 +65,6 @@ pub struct RuntimeEvidencePackExportResult { pub artifacts: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct RuntimeEvidenceSceneAppSnapshot { - pub recent_artifact_paths: Vec, - pub workspace_root: Option, - pub known_gaps: Vec, - pub verification_failure_outcomes: Vec, - pub request_telemetry_available: bool, - pub request_telemetry_matched_count: usize, - pub artifact_validator_applicable: bool, - pub artifact_validator_issue_count: usize, - pub artifact_validator_recovered_count: usize, -} - pub fn export_runtime_evidence_pack( detail: &SessionDetail, thread_read: &AgentRuntimeThreadReadModel, @@ -312,66 +297,6 @@ pub(crate) fn resolve_runtime_export_workspace_root( Err("当前会话缺少 workspace / working_dir,无法导出运行时制品".to_string()) } -pub(crate) fn build_runtime_evidence_sceneapp_snapshot( - detail: &SessionDetail, - thread_read: &AgentRuntimeThreadReadModel, - workspace_root: Option<&Path>, -) -> RuntimeEvidenceSceneAppSnapshot { - let recent_artifacts = collect_recent_artifacts(detail); - let request_telemetry = workspace_root - .map(|root| collect_request_telemetry(detail, root)) - .unwrap_or_default(); - let auxiliary_runtime = collect_auxiliary_runtime_snapshots(workspace_root, &recent_artifacts); - let modality_runtime_contracts = - collect_modality_runtime_contract_snapshots(detail, workspace_root, &recent_artifacts); - let verification = collect_runtime_verification(detail, workspace_root, &recent_artifacts); - let signal_coverage = build_signal_coverage( - thread_read, - &recent_artifacts, - &request_telemetry, - &auxiliary_runtime, - &modality_runtime_contracts, - &verification, - ); - let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage, thread_read); - - RuntimeEvidenceSceneAppSnapshot { - recent_artifact_paths: recent_artifacts - .into_iter() - .map(|artifact| artifact.path) - .collect(), - workspace_root: workspace_root.map(|path| path.to_string_lossy().to_string()), - known_gaps, - verification_failure_outcomes: extract_verification_failure_outcomes(&verification), - request_telemetry_available: !request_telemetry.searched_roots.is_empty(), - request_telemetry_matched_count: request_telemetry.matched_request_count, - artifact_validator_applicable: verification.artifact_validator.applicable, - artifact_validator_issue_count: verification - .artifact_validator - .records - .iter() - .map(|record| { - record - .get("issues") - .and_then(Value::as_array) - .map(|issues| issues.len()) - .unwrap_or(0) - }) - .sum(), - artifact_validator_recovered_count: verification - .artifact_validator - .records - .iter() - .filter(|record| { - record - .get("repaired") - .and_then(Value::as_bool) - .unwrap_or(false) - }) - .count(), - } -} - #[cfg(test)] #[path = "runtime_evidence_pack_service_tests.rs"] mod tests; diff --git a/src-tauri/src/services/runtime_evidence_verification_service.rs b/src-tauri/src/services/runtime_evidence_verification_service.rs index ca0755a25..a79b7c88d 100644 --- a/src-tauri/src/services/runtime_evidence_verification_service.rs +++ b/src-tauri/src/services/runtime_evidence_verification_service.rs @@ -236,25 +236,6 @@ fn collect_artifact_validator_summary( summary } -pub(crate) fn extract_verification_failure_outcomes( - verification: &RuntimeEvidenceVerificationSummary, -) -> Vec { - build_observability_verification_summary_json(verification) - .and_then(|summary| { - summary - .get("focusVerificationFailureOutcomes") - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(ToString::to_string) - .collect::>() - }) - }) - .unwrap_or_default() -} - fn collect_browser_evidence(detail: &SessionDetail) -> Vec { let mut evidence = Vec::new(); diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 3df13ca87..a307db965 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.39.0", + "version": "1.40.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 b3039a8c3..8e9de1775 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.39.0", + "version": "1.40.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index a44cd9de9..3069b650c 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -36,26 +36,36 @@ const latestKnowledgePageProps = vi.hoisted( value: null as Record | null, }) as { value: Record | null }, ); +const latestAgentAppsProps = vi.hoisted( + () => + ({ + value: null as Record | null, + }) as { value: Record | null }, +); +const latestExpertPlazaProps = vi.hoisted( + () => + ({ + value: null as Record | null, + }) as { value: Record | null }, +); const agentAppLabLifecycle = vi.hoisted( () => ({ mounts: 0, }) as { mounts: number }, ); -const latestSceneAppsPageProps = vi.hoisted( - () => - ({ - value: null as Record | null, - }) as { value: Record | null }, -); -const sceneAppsLifecycle = vi.hoisted( +const agentAppsLifecycle = vi.hoisted( () => ({ mounts: 0, - unmounts: 0, - }) as { mounts: number; unmounts: number }, + }) as { mounts: number }, +); +const agentAppRuntimeLifecycle = vi.hoisted( + () => + ({ + mounts: 0, + }) as { mounts: number }, ); - vi.mock("./agent/chat", () => ({ AgentChatPage: (props: Record) => { latestAgentChatProps.value = props; @@ -106,6 +116,21 @@ vi.mock("@/features/knowledge", () => ({ })); vi.mock("@/features/agent-app", () => ({ + AgentAppRuntimePage: () => { + useEffect(() => { + agentAppRuntimeLifecycle.mounts += 1; + }, []); + + return
; + }, + AgentAppsPage: (props: Record) => { + latestAgentAppsProps.value = props; + useEffect(() => { + agentAppsLifecycle.mounts += 1; + }, []); + + return
; + }, AgentAppLabPage: () => { useEffect(() => { agentAppLabLifecycle.mounts += 1; @@ -115,19 +140,10 @@ vi.mock("@/features/agent-app", () => ({ }, })); -vi.mock("./sceneapps", () => ({ - SceneAppsPage: (props: Record) => { - latestSceneAppsPageProps.value = props; - - useEffect(() => { - sceneAppsLifecycle.mounts += 1; - - return () => { - sceneAppsLifecycle.unmounts += 1; - }; - }, []); - - return
; +vi.mock("./experts", () => ({ + ExpertPlazaPage: (props: Record) => { + latestExpertPlazaProps.value = props; + return
; }, })); @@ -214,10 +230,9 @@ describe("AppPageContent", () => { latestSkillsWorkspaceProps.value = null; latestMemoryPageProps.value = null; latestKnowledgePageProps.value = null; + latestExpertPlazaProps.value = null; agentAppLabLifecycle.mounts = 0; - latestSceneAppsPageProps.value = null; - sceneAppsLifecycle.mounts = 0; - sceneAppsLifecycle.unmounts = 0; + agentAppsLifecycle.mounts = 0; }); afterEach(() => { @@ -307,6 +322,44 @@ describe("AppPageContent", () => { }); }); + it("agent 页面应透传专家 Agent 身份,重复恢复同一专家不触发新 key", async () => { + const pageParams: AgentPageParams = { + agentEntry: "claw", + projectId: "default", + theme: "general", + initialSessionId: "session-expert-1", + expertAgentLaunch: { + tenantId: "tenant-0001", + expertId: "marketing-strategist", + releaseId: "expert-release-0001", + agentInstanceKey: + "tenant-0001:marketing-strategist:expert-release-0001", + launchMode: "resume_or_create", + latestSessionId: "session-expert-1", + }, + }; + + const mounted = renderContentWithNavigationState({ + currentPage: "agent", + pageParams, + }); + await flushEffects(); + expect(agentChatLifecycle.mounts).toBe(1); + expect(latestAgentChatProps.value).toMatchObject({ + initialSessionId: "session-expert-1", + expertAgentLaunch: pageParams.expertAgentLaunch, + }); + + mounted.rerender({ + currentPage: "agent", + pageParams: { ...pageParams }, + }); + await flushEffects(); + + expect(agentChatLifecycle.mounts).toBe(1); + expect(agentChatLifecycle.unmounts).toBe(0); + }); + it("agent 页面应把 initialSessionId 透传给 AgentChatPage", async () => { const pageParams: AgentPageParams = { agentEntry: "claw", @@ -711,29 +764,16 @@ describe("AppPageContent", () => { }); }); - it("sceneapps 页面应把目录页参数透传给 SceneAppsPage", async () => { - const { container } = renderContent("sceneapps", { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-1", - projectId: "project-sceneapp", - prefillIntent: "生成一个短视频方案", - search: "短视频", - }); + it("experts 页面应挂载专家广场并透传导航", async () => { + const { container } = renderContent("experts"); await flushEffects(); expect( - container.querySelector('[data-testid="sceneapps-page"]'), + container.querySelector('[data-testid="expert-plaza-page"]'), ).not.toBeNull(); - expect(latestSceneAppsPageProps.value).toMatchObject({ - isActive: true, - pageParams: { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-1", - projectId: "project-sceneapp", - prefillIntent: "生成一个短视频方案", - search: "短视频", - }, - }); + expect(latestExpertPlazaProps.value?.onNavigate).toEqual( + expect.any(Function), + ); }); it("agent-app-lab 页面应渲染 P0 只读实验入口", async () => { @@ -746,97 +786,34 @@ describe("AppPageContent", () => { expect(agentAppLabLifecycle.mounts).toBe(1); }); - it("sceneapps 页面在持续流程往返后不应重新挂载", async () => { - const { rerender } = renderContent("sceneapps", { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-1", - projectId: "project-sceneapp", - search: "短视频", - }); - await flushEffects(); - - const mountsAfterInitialRender = sceneAppsLifecycle.mounts; - - expect(mountsAfterInitialRender).toBeGreaterThan(0); - expect(sceneAppsLifecycle.unmounts).toBe(0); - expect(latestSceneAppsPageProps.value).toMatchObject({ - pageParams: { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-1", - projectId: "project-sceneapp", - search: "短视频", - }, - }); - - rerender("automation", { - selectedJobId: "automation-job-1", - workspaceTab: "tasks", - }); - await flushEffects(); - - expect(sceneAppsLifecycle.mounts).toBe(mountsAfterInitialRender); - expect(sceneAppsLifecycle.unmounts).toBe(0); - expect(latestSceneAppsPageProps.value).toMatchObject({ - pageParams: { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-1", - projectId: "project-sceneapp", - search: "短视频", - }, - }); - - rerender("sceneapps", { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-2", - projectId: "project-sceneapp", - search: "复盘", - }); - await flushEffects(); - - expect(sceneAppsLifecycle.mounts).toBe(mountsAfterInitialRender); - expect(sceneAppsLifecycle.unmounts).toBe(0); - expect(latestSceneAppsPageProps.value).toMatchObject({ - isActive: true, - pageParams: { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-2", - projectId: "project-sceneapp", - search: "复盘", - }, - }); - }); - - it("requested 页面已切回 sceneapps 时应优先渲染最新请求目标", async () => { - const { container } = renderContentWithNavigationState({ - currentPage: "automation", - pageParams: { - selectedJobId: "automation-job-1", - workspaceTab: "tasks", - }, - requestedPage: "sceneapps", - requestedPageParams: { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-2", - projectId: "project-sceneapp", - search: "复盘", - }, - navigationRequestId: 9, + it("agent-apps 页面应渲染正式 Agent Apps 管理入口", async () => { + const { container } = renderContent("agent-apps", { + selectedAgentAppId: "content-factory-app", }); await flushEffects(); expect( - container.querySelector('[data-testid="sceneapps-page"]'), + container.querySelector('[data-testid="agent-apps-page"]'), ).not.toBeNull(); - expect(latestSceneAppsPageProps.value).toMatchObject({ - isActive: true, - isNavigationTargetOwner: true, - navigationRequestId: 9, + expect(agentAppsLifecycle.mounts).toBe(1); + expect(latestAgentAppsProps.value).toMatchObject({ pageParams: { - sceneappId: "story-video-suite", - runId: "story-video-suite-run-2", - projectId: "project-sceneapp", - search: "复盘", + selectedAgentAppId: "content-factory-app", }, }); + expect(latestAgentAppsProps.value?.onNavigate).toEqual(expect.any(Function)); + }); + + it("agent-app 页面应渲染已安装 App 的独立使用入口", async () => { + const { container } = renderContent("agent-app", { + appId: "content-factory-app", + entryKey: "dashboard", + }); + await flushEffects(); + + expect( + container.querySelector('[data-testid="agent-app-runtime-page"]'), + ).not.toBeNull(); + expect(agentAppRuntimeLifecycle.mounts).toBe(1); }); }); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 0911a2cca..22c6530bd 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -4,14 +4,12 @@ * 负责根据当前页面类型渲染对应主内容,避免主入口继续膨胀。 */ -import { lazy, useEffect, useState, type ReactNode } from "react"; +import { lazy } from "react"; import styled from "styled-components"; -import { - normalizeSceneAppsPageParams, - serializeSceneAppsPageParams, -} from "@/lib/sceneapp"; import type { AgentPageParams, + AgentAppPageParams, + AgentAppsPageParams, AutomationPageParams, BrowserRuntimePageParams, KnowledgePageParams, @@ -19,13 +17,11 @@ import type { Page, PageParams, ResourcesPageParams, - SceneAppsPageParams, SettingsPageParams, SkillsPageParams, } from "@/types/page"; import { AutomationPage } from "./automation"; import { ImConfigPage } from "./channels/ImConfigPage"; -import { SceneAppsPage } from "./sceneapps"; import { SettingsPageV2 } from "./settings-v2"; const PageWrapper = styled.div<{ $isActive: boolean }>` @@ -35,12 +31,6 @@ const PageWrapper = styled.div<{ $isActive: boolean }>` display: ${(props) => (props.$isActive ? "block" : "none")}; `; -const KeepAliveColumnPage = styled.div<{ $isActive: boolean }>` - flex: 1; - min-height: 0; - display: ${(props) => (props.$isActive ? "flex" : "none")}; -`; - const columnPageStyle = { flex: 1, minHeight: 0, @@ -68,6 +58,19 @@ const loadAgentAppLabPage = () => import("@/features/agent-app").then((module) => ({ default: module.AgentAppLabPage, })); +const loadAgentAppsPage = () => + import("@/features/agent-app").then((module) => ({ + default: module.AgentAppsPage, + })); + +const loadAgentAppRuntimePage = () => + import("@/features/agent-app").then((module) => ({ + default: module.AgentAppRuntimePage, + })); +const loadExpertPlazaPage = () => + import("./experts").then((module) => ({ + default: module.ExpertPlazaPage, + })); const loadBrowserRuntimeWorkspace = () => import("@/features/browser-runtime").then((module) => ({ default: module.BrowserRuntimeWorkspace, @@ -127,6 +130,9 @@ const MemoryPage = lazy(loadMemoryPage); const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage); const KnowledgePage = lazy(loadKnowledgePage); const AgentAppLabPage = lazy(loadAgentAppLabPage); +const AgentAppsPage = lazy(loadAgentAppsPage); +const AgentAppRuntimePage = lazy(loadAgentAppRuntimePage); +const ExpertPlazaPage = lazy(loadExpertPlazaPage); const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace); const AgentChatPage = lazy(loadAgentChatPage); @@ -178,6 +184,19 @@ function serializeInitialKnowledgePackSelectionKey( }); } +function serializeExpertAgentLaunchKey(params: AgentPageParams): string { + const launch = params.expertAgentLaunch; + if (!launch) { + return ""; + } + return [ + launch.agentInstanceKey, + launch.launchMode, + launch.expertId, + launch.releaseId, + ].join(":"); +} + interface AppPageContentProps { currentPage: Page; pageParams: PageParams; @@ -193,67 +212,14 @@ export function AppPageContent({ pageParams, requestedPage, requestedPageParams, - navigationRequestId = 0, onNavigate, onAgentHasMessagesChange, }: AppPageContentProps) { const activePage = requestedPage ?? currentPage; const activePageParams = requestedPageParams ?? pageParams; - const activeSceneAppsPageParams = - activePage === "sceneapps" - ? normalizeSceneAppsPageParams(activePageParams as SceneAppsPageParams) - : null; - const activeSceneAppsPageParamsKey = serializeSceneAppsPageParams( - activeSceneAppsPageParams ?? {}, - ); - const [hasVisitedSceneApps, setHasVisitedSceneApps] = useState( - activePage === "sceneapps", - ); - const [cachedSceneAppsPageParams, setCachedSceneAppsPageParams] = - useState(() => activeSceneAppsPageParams ?? {}); - - useEffect(() => { - if (activePage !== "sceneapps" || !activeSceneAppsPageParams) { - return; - } - - setHasVisitedSceneApps(true); - setCachedSceneAppsPageParams((current) => - serializeSceneAppsPageParams(current) === activeSceneAppsPageParamsKey - ? current - : activeSceneAppsPageParams, - ); - }, [activeSceneAppsPageParams, activeSceneAppsPageParamsKey, activePage]); - - const shouldRenderSceneApps = - hasVisitedSceneApps || activePage === "sceneapps"; - const sceneAppsContent = shouldRenderSceneApps ? ( - - - - ) : null; - const wrapWithSceneApps = (content: ReactNode) => - sceneAppsContent ? ( - <> - {sceneAppsContent} - {content} - - ) : ( - content - ); - - if (activePage === "sceneapps") { - return wrapWithSceneApps(null); - } if (activePage === "automation") { - const content = ( + return (
); - - return wrapWithSceneApps(content); } if (activePage === "channels") { - const content = ( + return (
@@ -275,17 +239,15 @@ export function AppPageContent({
); - - return wrapWithSceneApps(content); } if (activePage === "agent") { const agentPageParams = activePageParams as AgentPageParams; - const content = ( + return (
); - - return wrapWithSceneApps(content); } if (activePage === "resources") { - const content = ( + return (
); - - return wrapWithSceneApps(content); } if (activePage === "browser-runtime") { const browserRuntimeParams = activePageParams as BrowserRuntimePageParams; - const content = ( + return ( ); - - return wrapWithSceneApps(content); } if (activePage === "memory") { - const content = ( + return (
); - - return wrapWithSceneApps(content); } if (activePage === "skills") { - const content = ( + return (
); - - return wrapWithSceneApps(content); } if (activePage === "agent-app-lab") { - const content = ( + return (
); + } - return wrapWithSceneApps(content); + if (activePage === "agent-app") { + return ( +
+ +
+ ); + } + + if (activePage === "agent-apps") { + return ( +
+ +
+ ); + } + + if (activePage === "experts") { + return ( +
+ +
+ ); } if (activePage === "knowledge") { - const content = ( + return (
); - - return wrapWithSceneApps(content); } if (activePage === "settings") { - const content = ( + return (
); - - return wrapWithSceneApps(content); } return null; diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 305857733..db0073ae9 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -24,6 +24,7 @@ const { mockSaveConfig, mockSubscribeAppConfigChanged, mockListAgentRuntimeSessions, + mockListInstalledAgentApps, mockUpdateAgentRuntimeSession, mockDeleteAgentRuntimeSession, mockSetI18nLanguage, @@ -45,6 +46,7 @@ const { mockSaveConfig: vi.fn(), mockSubscribeAppConfigChanged: vi.fn(), mockListAgentRuntimeSessions: vi.fn(), + mockListInstalledAgentApps: vi.fn(), mockUpdateAgentRuntimeSession: vi.fn(), mockDeleteAgentRuntimeSession: vi.fn(), mockSetI18nLanguage: vi.fn(), @@ -87,6 +89,11 @@ vi.mock("@/lib/api/agentRuntime", () => ({ updateAgentRuntimeSession: mockUpdateAgentRuntimeSession, })); +vi.mock("@/lib/api/agentApps", () => ({ + AGENT_APPS_CHANGED_EVENT: "lime:agent-apps-changed", + listInstalledAgentApps: mockListInstalledAgentApps, +})); + vi.mock("@/lib/api/oemCloudControlPlane", () => ({ logoutClient: mockLogoutClient, getConfiguredOemCloudTarget: mockGetConfiguredOemCloudTarget, @@ -313,6 +320,7 @@ describe("AppSidebar", () => { mockGetConfig.mockResolvedValue({}); mockSaveConfig.mockResolvedValue(undefined); mockListAgentRuntimeSessions.mockResolvedValue([]); + mockListInstalledAgentApps.mockResolvedValue({ states: [], issues: [] }); mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); mockDeleteAgentRuntimeSession.mockResolvedValue(undefined); mockLogoutClient.mockResolvedValue(undefined); @@ -397,6 +405,73 @@ describe("AppSidebar", () => { ); }); + it("已安装 Agent App 应作为动态导航项显示并直达默认入口", async () => { + mockListInstalledAgentApps.mockResolvedValue({ + states: [ + { + appId: "content-factory-app", + disabled: false, + manifest: { + displayName: "内容工厂", + }, + projection: { + app: { + appId: "content-factory-app", + displayName: "内容工厂", + }, + entries: [ + { + key: "dashboard", + kind: "page", + title: "项目首页", + }, + ], + }, + }, + ], + issues: [], + }); + const onNavigate = vi.fn(); + const container = mountSidebarContainer({ onNavigate }); + await flushEffects(2); + + expect(container.textContent).toContain("内容工厂"); + expect( + Array.from( + container.querySelectorAll('[data-testid="app-sidebar-main-nav"] button'), + ).map((button) => button.getAttribute("aria-label")), + ).toEqual([ + "新建任务", + "专家", + "Skills", + "内容工厂", + "项目资料", + ]); + + const appButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("内容工厂"), + ) as HTMLButtonElement | undefined; + act(() => { + appButton?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith( + "agent-app", + expect.objectContaining({ + appId: "content-factory-app", + entryKey: "dashboard", + launchRequestKey: expect.any(Number), + }), + ); + + await openAccountMenu(container); + const accountMenu = container.querySelector( + '[data-testid="app-sidebar-account-menu"]', + ); + expect(accountMenu?.textContent).toContain("Agent Apps"); + expect(accountMenu?.textContent).not.toContain("内容工厂"); + }); + it("新建任务页应自动展开导航栏", async () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true"); @@ -510,8 +585,10 @@ describe("AppSidebar", () => { expect(container.textContent).toContain("新建任务"); expect(container.textContent).not.toContain("工作台"); expect(container.textContent).not.toContain("生成"); + expect(container.textContent).toContain("专家"); expect(container.textContent).toContain("Skills"); - expect(container.textContent).toContain("灵感"); + expect(container.textContent).not.toContain("Agent Apps"); + expect(container.textContent).not.toContain("灵感"); expect(container.textContent).toContain("项目资料"); expect(container.textContent).not.toContain("设置"); expect(container.textContent).not.toContain("持续流程"); @@ -529,7 +606,12 @@ describe("AppSidebar", () => { '[data-testid="app-sidebar-footer-area"]', ); - expect(mainNavButtons).toEqual(["新建任务", "Skills", "灵感", "项目资料"]); + expect(mainNavButtons).toEqual([ + "新建任务", + "专家", + "Skills", + "项目资料", + ]); expect( container.querySelector('[data-testid="app-sidebar-footer-nav"]'), ).toBeNull(); @@ -549,6 +631,8 @@ describe("AppSidebar", () => { '[data-testid="app-sidebar-account-menu"]', ); expect(accountMenu?.textContent).toContain("设置"); + expect(accountMenu?.textContent).toContain("Agent Apps"); + expect(accountMenu?.textContent).toContain("灵感"); expect(accountMenu?.textContent).toContain("持续流程"); expect(accountMenu?.textContent).toContain("消息渠道"); }); @@ -589,7 +673,7 @@ describe("AppSidebar", () => { mainNav && (header.compareDocumentPosition(mainNav) & Node.DOCUMENT_POSITION_FOLLOWING) !== - 0, + 0, ), ).toBe(true); @@ -1225,6 +1309,12 @@ describe("AppSidebar", () => { tab: SettingsTabs.Home, }); + await clickAccountMenuItem(container, "Agent Apps"); + expect(onNavigate).toHaveBeenLastCalledWith("agent-apps", undefined); + + await clickAccountMenuItem(container, "灵感"); + expect(onNavigate).toHaveBeenLastCalledWith("memory", undefined); + await clickAccountMenuItem(container, "持续流程"); expect(onNavigate).toHaveBeenLastCalledWith("automation", undefined); @@ -2090,25 +2180,25 @@ describe("AppSidebar", () => { }) => options?.archivedOnly ? [ - { - id: "session-archived", - name: "归档会话", - created_at: 1713000000, - updated_at: 1713000600, - archived_at: 1713003600, - workspace_id: "project-1", - }, - ] + { + id: "session-archived", + name: "归档会话", + created_at: 1713000000, + updated_at: 1713000600, + archived_at: 1713003600, + workspace_id: "project-1", + }, + ] : [ - { - id: "session-recent", - name: "最近会话", - created_at: 1714000000, - updated_at: 1714000600, - archived_at: null, - workspace_id: "project-1", - }, - ], + { + id: "session-recent", + name: "最近会话", + created_at: 1714000000, + updated_at: 1714000600, + archived_at: null, + workspace_id: "project-1", + }, + ], ); const container = mountSidebarContainer({ @@ -2160,7 +2250,7 @@ describe("AppSidebar", () => { conversationShelf && (mainNav.compareDocumentPosition(conversationShelf) & Node.DOCUMENT_POSITION_FOLLOWING) !== - 0, + 0, ), ).toBe(true); @@ -2451,15 +2541,15 @@ describe("AppSidebar", () => { }) => options?.archivedOnly ? [ - { - id: "session-archived", - name: "归档会话", - created_at: 1713000000, - updated_at: 1713000600, - archived_at: 1713003600, - workspace_id: "project-1", - }, - ] + { + id: "session-archived", + name: "归档会话", + created_at: 1713000000, + updated_at: 1713000600, + archived_at: 1713003600, + workspace_id: "project-1", + }, + ] : [], ); @@ -2712,19 +2802,6 @@ describe("AppSidebar", () => { expect(accountMenu?.textContent).toContain("桌宠"); }); - it("sceneapps 页面不应再在侧栏展示独立主入口", async () => { - const container = mountSidebarContainer({ - currentPage: "sceneapps", - currentPageParams: { - sceneappId: "story-video-suite", - projectId: "project-9", - }, - }); - await flushEffects(2); - - expect(container.querySelector('button[aria-label="创作场景"]')).toBeNull(); - }); - it("点击当前已激活的Skills入口时不应重复导航", async () => { const onNavigate = vi.fn(); const container = mountSidebarContainer({ diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index ddf0e7ce3..a67f19e98 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -20,6 +20,7 @@ import { ChevronRight, Check, Cloud, + Boxes, Copy, ExternalLink, Gift, @@ -66,6 +67,12 @@ import { updateAgentRuntimeSession, type AsterSessionInfo, } from "@/lib/api/agentRuntime"; +import { + AGENT_APPS_CHANGED_EVENT, + listInstalledAgentApps, +} from "@/lib/api/agentApps"; +import type { InstalledAgentAppState } from "@/features/agent-app/types"; +import { resolveInstalledAgentAppDisplayName } from "@/features/agent-app/ui/agentAppDisplay"; import { isAuxiliaryAgentSessionId } from "@/lib/api/agentRuntime/sessionIdentity"; import { DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, @@ -182,10 +189,12 @@ const SIDEBAR_CONVERSATION_NAVIGATION_DEFER_MS = SIDEBAR_SESSION_ENTRY_REFRESH_DEFER_MS; const SIDEBAR_SEARCH_HOVER_PREFETCH_DELAY_MS = 900; const SIDEBAR_NAV_LABEL_KEYS: Record = { + "agent-apps": "navigation.sidebar.items.agentApps", "agent-app-lab": "navigation.sidebar.items.agentAppLab", automation: "navigation.sidebar.items.automation", channels: "navigation.sidebar.items.channels", companion: "navigation.sidebar.items.companion", + experts: "navigation.sidebar.items.experts", "home-general": "navigation.sidebar.items.homeGeneral", knowledge: "navigation.sidebar.items.knowledge", memory: "navigation.sidebar.items.memory", @@ -282,6 +291,16 @@ function isSameSidebarNavigationTarget( ); } +function resolveAgentAppSidebarEntryKey( + state: InstalledAgentAppState, +): string | undefined { + return ( + state.projection.entries.find((entry) => + ["page", "panel", "settings"].includes(entry.kind), + )?.key ?? state.projection.entries[0]?.key + ); +} + const Container = styled.aside<{ $collapsed?: boolean; $themeMode: "light" | "dark"; @@ -1663,6 +1682,9 @@ const AccountMenuPopover = styled.div<{ $collapsed?: boolean }>` z-index: 80; width: ${({ $collapsed }) => ($collapsed ? "284px" : "304px")}; max-width: min(304px, calc(100vw - 24px)); + max-height: min(760px, calc(100vh - 24px)); + overflow-y: auto; + overscroll-behavior: contain; border-radius: 18px; border: 1px solid var(--lime-card-subtle-border, rgba(226, 240, 226, 0.92)); background: var(--lime-card-subtle, var(--lime-surface, #ffffff)); @@ -2487,6 +2509,9 @@ export function AppSidebar({ const [enabledNavItems, setEnabledNavItems] = useState( DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, ); + const [installedAgentAppNavStates, setInstalledAgentAppNavStates] = useState< + InstalledAgentAppState[] + >([]); const sidebarSessionsRef = useRef([]); const archivedSidebarSessionsRef = useRef([]); const [sidebarSessions, setSidebarSessions] = useState( @@ -2633,6 +2658,33 @@ export function AppSidebar({ }); }, []); + const loadInstalledAgentAppNavStates = useCallback(async () => { + try { + const result = await listInstalledAgentApps(); + setInstalledAgentAppNavStates(result.states); + } catch (error) { + console.error("加载 Agent App 导航失败:", error); + setInstalledAgentAppNavStates([]); + } + }, []); + + useEffect(() => { + void loadInstalledAgentAppNavStates(); + if (typeof window === "undefined") { + return; + } + + const reload = () => { + void loadInstalledAgentAppNavStates(); + }; + window.addEventListener(AGENT_APPS_CHANGED_EVENT, reload); + window.addEventListener("focus", reload); + return () => { + window.removeEventListener(AGENT_APPS_CHANGED_EVENT, reload); + window.removeEventListener("focus", reload); + }; + }, [loadInstalledAgentAppNavStates]); + const localizeSidebarNavItem = useCallback( (item: SidebarNavItem): SidebarNavItem => { const key = SIDEBAR_NAV_LABEL_KEYS[item.id]; @@ -2648,12 +2700,54 @@ export function AppSidebar({ [t], ); + const agentAppNavItems = useMemo(() => { + return installedAgentAppNavStates + .filter( + (state) => !state.disabled && resolveAgentAppSidebarEntryKey(state), + ) + .map((state) => { + const entryKey = resolveAgentAppSidebarEntryKey(state); + return { + id: `agent-app:${state.appId}`, + label: resolveInstalledAgentAppDisplayName(state), + icon: Boxes, + page: "agent-app", + params: { + appId: state.appId, + entryKey, + }, + resolveParams: (params) => ({ + ...(params ?? {}), + launchRequestKey: Date.now(), + }), + isActive: (currentPage, currentParams) => + currentPage === "agent-app" && + (currentParams as { appId?: string } | undefined)?.appId === + state.appId, + configurable: false, + }; + }); + }, [installedAgentAppNavStates]); + const filteredMainNavItems = useMemo(() => { - return MAIN_SIDEBAR_NAV_ITEMS.filter( + const baseItems = MAIN_SIDEBAR_NAV_ITEMS.filter( (item) => item.configurable === false || enabledNavItems.includes(item.id), ).map(localizeSidebarNavItem); - }, [enabledNavItems, localizeSidebarNavItem]); + + if (agentAppNavItems.length === 0) { + return baseItems; + } + const skillsIndex = baseItems.findIndex((item) => item.id === "skills"); + if (skillsIndex < 0) { + return [...baseItems, ...agentAppNavItems]; + } + return [ + ...baseItems.slice(0, skillsIndex + 1), + ...agentAppNavItems, + ...baseItems.slice(skillsIndex + 1), + ]; + }, [agentAppNavItems, enabledNavItems, localizeSidebarNavItem]); const filteredFooterNavItems = useMemo(() => { return FOOTER_SIDEBAR_NAV_ITEMS.filter( diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 8dd1e8f50..14a56175d 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -79,10 +79,6 @@ import { type AgentRuntimeSaveReviewDecisionRequest, } from "@/lib/api/agentRuntime"; import type { AgentRuntimeUpdateSessionRequest } from "@/lib/api/agentRuntime/types"; -import { - prepareSceneAppRunGovernanceArtifact, - prepareSceneAppRunGovernanceArtifacts, -} from "@/lib/api/sceneapp"; import { getProjectMemory, type ProjectMemory, @@ -220,7 +216,6 @@ import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGen import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime"; import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime"; import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions"; -import { useWorkspaceSceneAppEntryActions } from "./workspace/useWorkspaceSceneAppEntryActions"; import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl"; import { rememberInitialSessionNavigationStart, @@ -229,6 +224,12 @@ import { import { WorkspaceGeneralWorkbenchSidebar } from "./workspace/WorkspaceGeneralWorkbenchSidebar"; import { GeneralWorkbenchHarnessDialogSection } from "./workspace/WorkspaceHarnessDialogs"; import { WorkspaceShellScene } from "./workspace/WorkspaceShellScene"; +import { ExpertInfoPanel } from "./experts/ExpertInfoPanel"; +import { + syncExpertAgentInstanceToCloud, + updateExpertAgentInstanceSession, + updateExpertAgentInstanceSkillRefs, +} from "@/features/experts"; import { FileManagerSidebar } from "./components/FileManager/FileManagerSidebar"; import { TaskCenterTabStrip, @@ -339,23 +340,15 @@ import { hasSavedSceneAppExecutionAsInspiration, saveSceneAppExecutionAsInspiration, } from "./utils/saveSceneAppExecutionAsInspiration"; -import { buildSceneAppExecutionPromptActionPayload } from "./utils/sceneAppExecutionPromptContinuation"; import { buildSkillsPageParamsFromSceneAppExecution } from "./utils/sceneAppSkillScaffoldDraft"; import { buildSkillsPageParamsFromMessage } from "./utils/skillScaffoldDraft"; import { AutomationJobDialog } from "@/components/settings-v2/system/automation/AutomationJobDialog"; import { shouldAutoSelectGeneralArtifact } from "./workspace/generalArtifactAutoSelection"; import { - buildSceneAppExecutionSummaryRunDetailViewModel, - type SceneAppExecutionPromptAction, buildSceneAppQuickReviewDecisionRequest, formatSceneAppErrorMessage, - hasSceneAppRecentVisit, - resolveSceneAppRunEntryNavigationTarget, - resolveSceneAppRuntimeArtifactOpenTarget, - resolveSceneAppsPageEntryParams, SCENEAPP_QUICK_REVIEW_ACTIONS, - subscribeSceneAppRecentVisits, -} from "@/lib/sceneapp"; +} from "@/lib/agent/legacySceneAppExecutionSummary"; const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; const BLANK_HOME_DEFERRED_LOAD_MS = 18_000; @@ -368,6 +361,46 @@ const SESSION_RECENT_METADATA_BACKGROUND_SYNC_IDLE_TIMEOUT_MS = 20_000; const BROWSER_WORKSPACE_HOME_HINT_STORAGE_KEY = "lime.agent.browser-workspace-home-hint-shown"; +function areStringArraysEqual(left: string[] | null, right: string[]): boolean { + if (!left || left.length !== right.length) { + return false; + } + return left.every((item, index) => item === right[index]); +} + +function mergeExpertSkillRefsIntoRequestMetadata( + metadata: Record | null | undefined, + skillRefs: string[] | null, +): Record | null { + if (!metadata || !skillRefs) { + return metadata ?? null; + } + + const root: Record = { ...metadata }; + const expert = asRecord(root.expert); + const harness = asRecord(root.harness); + const harnessExpert = asRecord(harness?.expert); + + if (expert) { + root.expert = { + ...expert, + skillRefs: [...skillRefs], + }; + } + + if (harness || harnessExpert) { + root.harness = { + ...(harness ?? {}), + expert: { + ...(harnessExpert ?? {}), + skill_refs: [...skillRefs], + }, + }; + } + + return root; +} + function isTransientWorkspaceBridgeError(message: string): boolean { const normalized = message.toLowerCase(); return ( @@ -667,6 +700,7 @@ export function AgentChatWorkspace({ initialProjectFileOpenTarget, onInitialUserPromptConsumed, newChatAt, + expertAgentLaunch, onRecommendationClick: _onRecommendationClick, onHasMessagesChange, onSessionChange, @@ -1591,6 +1625,48 @@ export function AgentChatWorkspace({ toast.error(`加载技能目录失败:${serviceSkillsError}`); }, [activeTheme, serviceSkillsError]); + + const expertPanelRequestMetadata = useMemo( + () => initialAutoSendRequestMetadata ?? initialRequestMetadata ?? null, + [initialAutoSendRequestMetadata, initialRequestMetadata], + ); + const [expertSkillRefsOverride, setExpertSkillRefsOverride] = useState< + string[] | null + >(null); + useEffect(() => { + setExpertSkillRefsOverride(null); + }, [expertPanelRequestMetadata]); + const handleExpertSkillRefsChange = useCallback( + (skillRefs: string[]) => { + setExpertSkillRefsOverride((current) => + areStringArraysEqual(current, skillRefs) ? current : [...skillRefs], + ); + if (!expertAgentLaunch) { + return; + } + const record = updateExpertAgentInstanceSkillRefs({ + tenantId: expertAgentLaunch.tenantId, + expertId: expertAgentLaunch.expertId, + releaseId: expertAgentLaunch.releaseId, + catalogVersion: expertAgentLaunch.catalogVersion, + skillRefsOverride: skillRefs, + }); + void syncExpertAgentInstanceToCloud(record).catch(() => undefined); + }, + [expertAgentLaunch], + ); + const workspaceRequestMetadataWithExpertSkills = useMemo( + () => + mergeExpertSkillRefsIntoRequestMetadata( + initialRequestMetadata ?? initialAutoSendRequestMetadata ?? null, + expertSkillRefsOverride, + ), + [ + expertSkillRefsOverride, + initialAutoSendRequestMetadata, + initialRequestMetadata, + ], + ); const initialPendingServiceSkillLaunchSignature = useMemo(() => { const skillId = initialPendingServiceSkillLaunch?.skillId?.trim(); const skillKey = initialPendingServiceSkillLaunch?.skillKey?.trim(); @@ -1729,60 +1805,12 @@ export function AgentChatWorkspace({ string | null >(null); const [timelineFocusRequestKey, setTimelineFocusRequestKey] = useState(0); - const [canResumeRecentSceneApp, setCanResumeRecentSceneApp] = - useState(() => hasSceneAppRecentVisit()); const autoCollapsedTopicSidebarRef = useRef(false); - useEffect(() => { - setCanResumeRecentSceneApp(hasSceneAppRecentVisit()); - - return subscribeSceneAppRecentVisits((records) => { - setCanResumeRecentSceneApp(records.length > 0); - }); - }, []); - // 跳转到技能主页面 const handleNavigateToSkillSettings = useCallback(() => { _onNavigate?.("skills"); }, [_onNavigate]); - const handleOpenSceneAppsDirectory = useCallback(() => { - if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到全部 Skills"); - return; - } - - _onNavigate( - "sceneapps", - resolveSceneAppsPageEntryParams( - { - projectId: projectId || undefined, - prefillIntent: input.trim() || undefined, - }, - { - mode: "browse", - }, - ), - ); - }, [_onNavigate, input, projectId]); - const handleResumeRecentSceneApp = useCallback(() => { - if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到全部 Skills"); - return; - } - - _onNavigate( - "sceneapps", - resolveSceneAppsPageEntryParams( - { - projectId: projectId || undefined, - prefillIntent: input.trim() || undefined, - }, - { - mode: "resume_latest", - }, - ), - ); - }, [_onNavigate, input, projectId]); const handleRefreshSkills = useCallback(async () => { await loadSkills(true); }, [loadSkills]); @@ -2356,6 +2384,24 @@ export function AgentChatWorkspace({ : undefined, getSyncedSessionRecentPreferences, }); + useEffect(() => { + const normalizedSessionId = sessionId?.trim(); + if (!expertAgentLaunch || !normalizedSessionId) { + return; + } + const record = updateExpertAgentInstanceSession({ + tenantId: expertAgentLaunch.tenantId, + expertId: expertAgentLaunch.expertId, + releaseId: expertAgentLaunch.releaseId, + catalogVersion: expertAgentLaunch.catalogVersion, + latestSessionId: normalizedSessionId, + skillRefsOverride: + expertSkillRefsOverride ?? expertAgentLaunch.skillRefsOverride, + }); + if (record) { + void syncExpertAgentInstanceToCloud(record).catch(() => undefined); + } + }, [expertAgentLaunch, expertSkillRefsOverride, sessionId]); const activeSessionKey = sessionId?.trim() || null; const restoredInteractiveMessageSnapshotRef = useRef<{ sessionId: string | null; @@ -3218,19 +3264,6 @@ export function AgentChatWorkspace({ onNavigate: _onNavigate, recordServiceSkillUsage, }); - const workspaceSceneAppEntryActions = useWorkspaceSceneAppEntryActions({ - activeTheme, - creationMode, - projectId, - input, - selectedText, - defaultToolPreferences: effectiveChatToolPreferences, - onNavigate: _onNavigate, - catalogLoadMode: shouldDeferWorkspaceAuxiliaryLoads - ? "deferred" - : "immediate", - catalogDeferredDelayMs: deferredWorkspaceAuxiliaryLoadMs, - }); const handlePendingServiceSkillLaunchSubmit = workspaceServiceSkillEntryActions.handlePendingServiceSkillLaunchSubmit; const clearPendingServiceSkillLaunch = @@ -4322,7 +4355,8 @@ export function AgentChatWorkspace({ browserAssistProfileKey: browserAssistRequestProfileKey, browserAssistPreferredBackend: browserAssistRequestPreferredBackend, browserAssistAutoLaunch: browserAssistRequestAutoLaunch, - workspaceRequestMetadataBase: initialRequestMetadata, + workspaceRequestMetadataBase: + workspaceRequestMetadataWithExpertSkills ?? undefined, serviceModels, messages, setChatMessages, @@ -5102,7 +5136,8 @@ export function AgentChatWorkspace({ setActiveTaskCenterDraftTabId(null); clearEntryPendingA2UI(); setChatMessages([]); - const targetSnapshotWorkspaceId = topicWorkspaceId ?? taskCenterWorkspaceId; + const targetSnapshotWorkspaceId = + topicWorkspaceId ?? taskCenterWorkspaceId; if (targetSnapshotWorkspaceId) { clearAgentSessionCachedSnapshot(targetSnapshotWorkspaceId, topicId); } @@ -5401,8 +5436,7 @@ export function AgentChatWorkspace({ return subscribeTaskCenterTaskOpenRequests( ({ sessionId: requestedSessionId, source, workspaceId }) => { const requestedWorkspaceId = normalizeProjectId(workspaceId); - const shouldForceRefresh = - shouldForceRefreshTaskOpenSource(source); + const shouldForceRefresh = shouldForceRefreshTaskOpenSource(source); if ( requestedWorkspaceId && requestedWorkspaceId !== normalizeProjectId(taskCenterWorkspaceId) @@ -6239,13 +6273,8 @@ export function AgentChatWorkspace({ () => buildCuratedTaskReferenceEntryFromSceneAppExecution({ summary: sceneAppExecutionSummaryState?.summary, - latestRunDetailView: - sceneAppExecutionSummaryState?.latestPackResultDetailView, }), - [ - sceneAppExecutionSummaryState?.latestPackResultDetailView, - sceneAppExecutionSummaryState?.summary, - ], + [sceneAppExecutionSummaryState?.summary], ); const defaultCuratedTaskReferenceEntries = useMemo( () => @@ -6329,8 +6358,6 @@ export function AgentChatWorkspace({ ), ); const sceneAppExecutionFailureSignal = - sceneAppExecutionSummaryState?.latestPackResultDetailView - ?.failureSignalLabel ?? sceneAppExecutionSummaryState?.summary?.runtimeBackflow ?.topFailureSignalLabel; const resolveSceneAppExecutionReviewDecisionTemplate = @@ -6465,280 +6492,19 @@ export function AgentChatWorkspace({ ], ); const handleOpenSceneAppExecutionDetail = useCallback(() => { - const sceneappId = - sceneAppExecutionSummaryState?.summary?.sceneappId?.trim(); - if (!_onNavigate || !sceneappId) { + if (!_onNavigate) { return; } - _onNavigate( - "sceneapps", - resolveSceneAppsPageEntryParams( - { - view: "detail", - sceneappId, - projectId: projectId || undefined, - }, - { - mode: "browse", - }, - ), - ); - }, [ - _onNavigate, - projectId, - sceneAppExecutionSummaryState?.summary?.sceneappId, - ]); + _onNavigate("agent-app-lab"); + }, [_onNavigate]); const handleOpenSceneAppExecutionGovernance = useCallback(() => { - const sceneappId = - sceneAppExecutionSummaryState?.summary?.sceneappId?.trim(); - if (!_onNavigate || !sceneappId) { + if (!_onNavigate) { return; } - _onNavigate( - "sceneapps", - resolveSceneAppsPageEntryParams( - { - view: "governance", - sceneappId, - runId: - sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId || - sceneAppExecutionSummaryState?.summary?.runtimeBackflow?.runId || - undefined, - projectId: projectId || undefined, - }, - { - mode: "browse", - }, - ), - ); - }, [ - _onNavigate, - projectId, - sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId, - sceneAppExecutionSummaryState?.summary?.runtimeBackflow?.runId, - sceneAppExecutionSummaryState?.summary?.sceneappId, - ]); - const handleOpenSceneAppExecutionDeliveryArtifact = useCallback( - ( - artifactEntry?: NonNullable< - NonNullable< - typeof sceneAppExecutionSummaryState - >["latestPackResultDetailView"] - >["deliveryArtifactEntries"][number], - ) => { - if (!_onNavigate) { - return; - } - - const target = resolveSceneAppRuntimeArtifactOpenTarget({ - entry: artifactEntry, - fallbackProjectId: projectId, - bannerPrefix: "已从生成打开结果文件", - }); - if (!target) { - toast.error("当前这次运行还没有可打开的结果文件路径。"); - return; - } - - _onNavigate("agent", { - agentEntry: "claw", - projectId: target.projectId, - initialProjectFileOpenTarget: { - relativePath: target.openTargetPath, - requestKey: Date.now(), - }, - entryBannerMessage: target.bannerMessage, - }); - }, - [_onNavigate, projectId], - ); - const handleOpenSceneAppExecutionGovernanceArtifact = useCallback( - ( - artifactEntry?: NonNullable< - NonNullable< - typeof sceneAppExecutionSummaryState - >["latestPackResultDetailView"] - >["governanceArtifactEntries"][number], - ) => { - if (!_onNavigate || !artifactEntry) { - return; - } - - const runId = - sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId?.trim() || - sceneAppReviewTargetRunSummary?.runId?.trim() || - ""; - - void (async () => { - let resolvedEntry = artifactEntry; - if (runId && sceneAppExecutionSummaryState?.summary) { - try { - const refreshed = await prepareSceneAppRunGovernanceArtifact( - runId, - artifactEntry.artifactRef.kind, - ); - if (!refreshed) { - toast.error("当前运行已不存在,无法继续准备结果材料。"); - return; - } - - const refreshedDetailView = - buildSceneAppExecutionSummaryRunDetailViewModel({ - summary: sceneAppExecutionSummaryState.summary, - run: refreshed, - }); - resolvedEntry = - refreshedDetailView.governanceArtifactEntries.find( - (entry) => - entry.artifactRef.kind === artifactEntry.artifactRef.kind, - ) ?? artifactEntry; - } catch (error) { - toast.error(formatSceneAppErrorMessage(error)); - return; - } - } - - const target = resolveSceneAppRuntimeArtifactOpenTarget({ - entry: resolvedEntry, - fallbackProjectId: projectId, - bannerPrefix: "已从生成打开结果材料", - }); - if (!target) { - toast.error("当前这次运行还没有可打开的证据或复核文件。"); - return; - } - - _onNavigate("agent", { - agentEntry: "claw", - projectId: target.projectId, - initialProjectFileOpenTarget: { - relativePath: target.openTargetPath, - requestKey: Date.now(), - }, - entryBannerMessage: target.bannerMessage, - }); - })(); - }, - [ - _onNavigate, - projectId, - sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId, - sceneAppExecutionSummaryState?.summary, - sceneAppReviewTargetRunSummary?.runId, - ], - ); - const handleRunSceneAppExecutionGovernanceAction = useCallback( - ( - action?: NonNullable< - NonNullable< - typeof sceneAppExecutionSummaryState - >["latestPackResultDetailView"] - >["governanceActionEntries"][number], - ) => { - if (!_onNavigate || !action || !sceneAppExecutionSummaryState?.summary) { - return; - } - - const runId = - sceneAppExecutionSummaryState.latestPackResultDetailView?.runId?.trim() || - sceneAppReviewTargetRunSummary?.runId?.trim() || - ""; - if (!runId) { - toast.error("当前还没有可用于后续动作的运行样本。"); - return; - } - - const sceneAppExecutionSummary = sceneAppExecutionSummaryState.summary; - - void (async () => { - try { - const refreshed = await prepareSceneAppRunGovernanceArtifacts( - runId, - action.artifactKinds, - ); - if (!refreshed) { - toast.error("当前运行已不存在,无法继续准备后续动作。"); - return; - } - - const refreshedDetailView = - buildSceneAppExecutionSummaryRunDetailViewModel({ - summary: sceneAppExecutionSummary, - run: refreshed, - }); - const targetEntry = - refreshedDetailView.governanceArtifactEntries.find( - (entry) => entry.artifactRef.kind === action.primaryArtifactKind, - ); - const target = resolveSceneAppRuntimeArtifactOpenTarget({ - entry: targetEntry, - fallbackProjectId: projectId, - bannerPrefix: "已从生成打开后续动作", - }); - if (!target) { - toast.error( - `后续动作已准备完成,但当前没有可打开的${action.primaryArtifactLabel}路径。`, - ); - return; - } - - _onNavigate("agent", { - agentEntry: "claw", - projectId: target.projectId, - initialProjectFileOpenTarget: { - relativePath: target.openTargetPath, - requestKey: Date.now(), - }, - entryBannerMessage: target.bannerMessage, - }); - } catch (error) { - toast.error(formatSceneAppErrorMessage(error)); - } - })(); - }, - [ - _onNavigate, - projectId, - sceneAppExecutionSummaryState, - sceneAppReviewTargetRunSummary?.runId, - ], - ); - const handleOpenSceneAppExecutionEntryAction = useCallback( - ( - action?: NonNullable< - NonNullable< - typeof sceneAppExecutionSummaryState - >["latestPackResultDetailView"] - >["entryAction"], - ) => { - if (!_onNavigate || !action || !sceneAppExecutionSummaryState?.summary) { - return; - } - - const target = resolveSceneAppRunEntryNavigationTarget({ - action, - sceneappId: sceneAppExecutionSummaryState.summary.sceneappId, - sceneTitle: sceneAppExecutionSummaryState.summary.title, - sourceLabel: "生成", - projectId, - linkedServiceSkillId: - sceneAppExecutionSummaryState.summary.descriptorSnapshot - ?.linkedServiceSkillId, - linkedSceneKey: - sceneAppExecutionSummaryState.summary.descriptorSnapshot - ?.linkedSceneKey, - }); - if (!target) { - toast.error("当前运行缺少可恢复的入口上下文。"); - return; - } - - _onNavigate(target.page, target.params); - }, - [_onNavigate, projectId, sceneAppExecutionSummaryState?.summary], - ); + _onNavigate("agent-app-lab"); + }, [_onNavigate]); const handleOpenSceneAppExecutionContentPost = useCallback( (entry: SceneAppExecutionContentPostEntry) => { if (entry.source.kind === "task_file") { @@ -6803,46 +6569,6 @@ export function AgentChatWorkspace({ taskFiles, ], ); - const handleRunSceneAppExecutionPromptAction = useCallback( - async (action: SceneAppExecutionPromptAction) => { - const followUpAction = buildSceneAppExecutionPromptActionPayload({ - action, - summary: sceneAppExecutionSummaryState?.summary, - detailView: sceneAppExecutionSummaryState?.latestPackResultDetailView, - }); - if (!followUpAction) { - toast.error("当前动作缺少可发送内容。"); - return; - } - - if (isSending || queuedTurns.length > 0) { - toast.info("当前会话还有执行中的内容,请稍后再继续推进。"); - return; - } - - if (followUpAction.capabilityRoute) { - applyWorkbenchFollowUpActionPayload(followUpAction); - return; - } - - await handleSendRef.current( - [], - webSearchPreferenceRef.current, - effectiveChatToolPreferences.thinking, - followUpAction.prompt, - ); - }, - [ - applyWorkbenchFollowUpActionPayload, - effectiveChatToolPreferences.thinking, - handleSendRef, - isSending, - queuedTurns.length, - sceneAppExecutionSummaryState?.latestPackResultDetailView, - sceneAppExecutionSummaryState?.summary, - webSearchPreferenceRef, - ], - ); const handleSaveSceneAppExecutionAsSkill = useCallback(() => { if (!_onNavigate) { toast.error("当前入口暂不支持直接跳转到 Skill 页面"); @@ -6859,7 +6585,6 @@ export function AgentChatWorkspace({ const nextPageParams = buildSkillsPageParamsFromSceneAppExecution( sceneAppExecutionSummaryState?.summary, - sceneAppExecutionSummaryState?.latestPackResultDetailView, { projectId, reviewSignal: latestReviewSignal, @@ -6875,7 +6600,6 @@ export function AgentChatWorkspace({ }, [ _onNavigate, projectId, - sceneAppExecutionSummaryState?.latestPackResultDetailView, sceneAppExecutionSummaryState?.summary, sessionId, ]); @@ -6891,16 +6615,10 @@ export function AgentChatWorkspace({ const handleSaveSceneAppExecutionAsInspiration = useCallback(() => { void saveSceneAppExecutionAsInspiration({ summary: sceneAppExecutionSummaryState?.summary, - detailView: sceneAppExecutionSummaryState?.latestPackResultDetailView, projectId, sessionId, }); - }, [ - projectId, - sceneAppExecutionSummaryState?.latestPackResultDetailView, - sceneAppExecutionSummaryState?.summary, - sessionId, - ]); + }, [projectId, sceneAppExecutionSummaryState?.summary, sessionId]); const handleOpenInspirationLibrary = useCallback(() => { if (!_onNavigate) { toast.error("当前入口暂不支持直接打开灵感库"); @@ -6911,26 +6629,19 @@ export function AgentChatWorkspace({ "memory", buildSceneAppExecutionInspirationLibraryPageParams({ summary: sceneAppExecutionSummaryState?.summary, - detailView: sceneAppExecutionSummaryState?.latestPackResultDetailView, }), ); - }, [ - _onNavigate, - sceneAppExecutionSummaryState?.latestPackResultDetailView, - sceneAppExecutionSummaryState?.summary, - ]); + }, [_onNavigate, sceneAppExecutionSummaryState?.summary]); const sceneAppExecutionSavedAsInspiration = useMemo(() => { void curatedTaskRecommendationSignalsVersion; return hasSavedSceneAppExecutionAsInspiration({ summary: sceneAppExecutionSummaryState?.summary, - detailView: sceneAppExecutionSummaryState?.latestPackResultDetailView, projectId, sessionId, }); }, [ curatedTaskRecommendationSignalsVersion, projectId, - sceneAppExecutionSummaryState?.latestPackResultDetailView, sceneAppExecutionSummaryState?.summary, sessionId, ]); @@ -6946,13 +6657,6 @@ export function AgentChatWorkspace({ sceneAppExecutionSummaryState?.summary ? ( 0} - onPromptAction={handleRunSceneAppExecutionPromptAction} /> ) : null, [ @@ -6991,17 +6687,10 @@ export function AgentChatWorkspace({ handleOpenSceneAppExecutionHumanReview, handleOpenSceneAppExecutionGovernance, handleOpenInspirationLibrary, - handleOpenSceneAppExecutionDeliveryArtifact, - handleOpenSceneAppExecutionEntryAction, - handleOpenSceneAppExecutionGovernanceArtifact, handleReviewCurrentSceneAppExecution, handleSaveSceneAppExecutionAsInspiration, handleSaveSceneAppExecutionAsSkill, - handleRunSceneAppExecutionPromptAction, - handleRunSceneAppExecutionGovernanceAction, - isSending, latestReviewFeedbackSignal, - queuedTurns.length, sceneAppExecutionSavedAsInspiration, sceneAppReviewDecisionLoading, sceneAppReviewDecisionSaving, @@ -7181,7 +6870,7 @@ export function AgentChatWorkspace({ return; } - if (!sessionId) { + if (!projectId) { return; } @@ -7196,11 +6885,12 @@ export function AgentChatWorkspace({ pendingInitialPrompt, undefined, undefined, - initialAutoSendRequestMetadata - ? { - requestMetadata: initialAutoSendRequestMetadata, - } - : undefined, + { + ...(initialAutoSendRequestMetadata + ? { requestMetadata: initialAutoSendRequestMetadata } + : {}), + skipSessionRestore: true, + }, ); if (disposed) { return; @@ -7226,6 +6916,7 @@ export function AgentChatWorkspace({ isSending, messages.length, onInitialUserPromptConsumed, + projectId, sessionId, setInput, shouldUseCompactGeneralWorkbench, @@ -8349,17 +8040,10 @@ export function AgentChatWorkspace({ handleRefreshSkills, handleOpenBrowserAssistInCanvas: handleOpenBrowserRuntimeForBrowserAssist, browserAssistLaunching, - featuredSceneApps: workspaceSceneAppEntryActions.featuredSceneApps, - sceneAppsLoading: workspaceSceneAppEntryActions.sceneAppsLoading, - sceneAppLaunchingId: workspaceSceneAppEntryActions.sceneAppLaunchingId, - handleLaunchSceneApp: workspaceSceneAppEntryActions.handleLaunchSceneApp, - canResumeRecentSceneApp, - handleResumeRecentSceneApp, recentSessionTitle: recentSessionTopic?.title ?? null, recentSessionSummary: recentSessionTopic?.lastPreview ?? null, recentSessionActionLabel, handleResumeRecentSession, - handleOpenSceneAppsDirectory, taskCenterTabsNode: shouldRenderTaskCenterTabStrip ? taskCenterTabsNode : browserWorkspaceHomeTabsNode, @@ -8471,6 +8155,15 @@ export function AgentChatWorkspace({ initialDirectory={project?.rootPath || null} /> ) : null; + const expertInfoPanelNode = ( + + ); return ( <> @@ -8489,6 +8182,7 @@ export function AgentChatWorkspace({ onExpandGeneralWorkbenchSidebar={handleExpandGeneralWorkbenchSidebar} fileManagerNode={fileManagerNode} mainAreaNode={conversationSceneRuntime.mainAreaNode} + rightRailNode={expertInfoPanelNode} currentTopicId={activeTaskCenterDraftTabId ?? sessionId ?? null} topics={topics} onNewChat={shellHandleBackHome} @@ -8527,19 +8221,6 @@ export function AgentChatWorkspace({ workspaceServiceSkillEntryActions.handleAutomationDialogSubmit } /> - void; newChatAt?: number; + expertAgentLaunch?: ExpertAgentLaunchParams; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; onHasMessagesChange?: (hasMessages: boolean) => void; onSessionChange?: (sessionId: string | null) => void; diff --git a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx index 8d5c4aa4c..fa3c64d4b 100644 --- a/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx +++ b/src/components/agent/chat/components/AgentThreadMemoryPrefetchPreview.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; import { AgentThreadMemoryPrefetchPreview } from "./AgentThreadMemoryPrefetchPreview"; interface MountedHarness { @@ -27,12 +28,13 @@ function renderPreview( return container; } -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); }); afterEach(() => { diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index ccd479d70..88da24a4d 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -7,7 +7,6 @@ import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import type { UnifiedMemory } from "@/lib/api/unifiedMemory"; import type { ServiceSkillHomeItem } from "../service-skills/types"; -import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { InputCapabilitySelection } from "../skill-selection/inputCapabilitySelection"; import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import { @@ -494,24 +493,6 @@ function createSceneBoundServiceSkill(): ServiceSkillHomeItem { }; } -function createSceneAppEntry(): SceneAppEntryCardItem { - return { - id: "story-video-suite", - title: "短视频编排", - summary: "把文本、线框图、配乐和短视频草稿串成结果链。", - businessLabel: "多模态组合", - valueStatement: "从一句主题串起脚本、线框图、配乐方向和短视频草稿。", - deliveryLabel: "短视频结果包", - executionLabel: "当前会话继续", - executionTone: "sky", - patternSummary: "步骤链 · 结果生成", - infraSummary: "组合蓝图 · 项目沉淀 · 云端编排", - sourceLabel: "将基于当前输入启动", - sourcePreview: "做一条新品发布短视频", - actionLabel: "进入生成", - }; -} - describe("EmptyState", () => { it("首页应以 slogan 作为主视觉并保留创作语义", async () => { const container = renderEmptyState({ @@ -3139,94 +3120,6 @@ describe("EmptyState", () => { expect(onLaunchBrowserAssist).not.toHaveBeenCalled(); }); - it("通用主题应把 SceneApp 放到第二屏任务库而不是首屏补充入口", async () => { - const onLaunchSceneApp = vi.fn(); - const container = renderEmptyState({ - activeTheme: "general", - featuredSceneApps: [createSceneAppEntry()], - onLaunchSceneApp, - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).toContain("短视频编排"); - expect( - container.querySelector('[data-testid="home-supplemental-actions"]'), - ).toBeNull(); - expect( - container.querySelector('[data-testid="sceneapps-home-directory"]'), - ).toBeNull(); - - const launchButton = container.querySelector( - '[data-testid="home-gallery-entry-sceneapp-story-video-suite"]', - ) as HTMLButtonElement | null; - expect(launchButton).toBeTruthy(); - - act(() => { - launchButton?.click(); - }); - - expect(onLaunchSceneApp).toHaveBeenCalledWith("story-video-suite"); - }); - - it("通用主题不应把 SceneApp 目录页入口提前放到首屏", async () => { - const onOpenSceneAppsDirectory = vi.fn(); - const container = renderEmptyState({ - activeTheme: "general", - featuredSceneApps: [createSceneAppEntry()], - onOpenSceneAppsDirectory, - }); - await act(async () => { - await Promise.resolve(); - }); - - const browseButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("查看全部 Skills"), - ); - expect(browseButton).toBeFalsy(); - - expect(onOpenSceneAppsDirectory).not.toHaveBeenCalled(); - }); - - it("仅有目录入口但没有可直接收益时,不应默认渲染更多起手方式", async () => { - const container = renderEmptyState({ - activeTheme: "general", - onOpenSceneAppsDirectory: vi.fn(), - }); - await act(async () => { - await Promise.resolve(); - }); - - expect(container.textContent).not.toContain("更多起手方式"); - expect( - container.querySelector('[data-testid="sceneapps-home-directory"]'), - ).toBeNull(); - }); - - it("存在最近 SceneApp 时,即使没有推荐做法也应提供继续最近做法入口", async () => { - const onResumeRecentSceneApp = vi.fn(); - const container = renderEmptyState({ - activeTheme: "general", - canResumeRecentSceneApp: true, - onResumeRecentSceneApp, - }); - await act(async () => { - await Promise.resolve(); - }); - - const resumeButton = container.querySelector( - '[data-testid="entry-sceneapp-resume"]', - ) as HTMLButtonElement | null; - expect(resumeButton).toBeTruthy(); - - act(() => { - resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(onResumeRecentSceneApp).toHaveBeenCalledTimes(1); - }); - it("存在最近会话时应提供继续最近会话入口", async () => { const onResumeRecentSession = vi.fn(); const container = renderEmptyState({ diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 3d0a9c7e1..4faf77cb4 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -94,7 +94,6 @@ import { } from "../service-skills/siteSkillExamplePrompts"; import { buildServiceSkillHomeCopy } from "../service-skills/homeCopy"; import { listFeaturedHomeServiceSkills } from "../service-skills/homeEntrySkills"; -import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { RuntimeToolAvailability } from "../utils/runtimeToolAvailability"; import type { AgentTaskRuntimeCardModel } from "../utils/agentTaskRuntime"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; @@ -364,18 +363,6 @@ interface EmptyStateProps extends SkillSelectionSourceProps { onLaunchBrowserAssist?: () => void | Promise; /** 浏览器协助启动中 */ browserAssistLoading?: boolean; - /** 首页推荐的 SceneApp 入口 */ - featuredSceneApps?: SceneAppEntryCardItem[]; - /** SceneApp 入口加载中 */ - sceneAppsLoading?: boolean; - /** 当前正在启动的 SceneApp */ - sceneAppLaunchingId?: string | null; - /** 启动 SceneApp */ - onLaunchSceneApp?: (sceneappId: string) => void | Promise; - /** 是否存在可恢复的最近 SceneApp */ - canResumeRecentSceneApp?: boolean; - /** 恢复最近一次 SceneApp 上下文 */ - onResumeRecentSceneApp?: () => void; /** 最近会话标题 */ recentSessionTitle?: string | null; /** 最近会话摘要 */ @@ -384,8 +371,6 @@ interface EmptyStateProps extends SkillSelectionSourceProps { recentSessionActionLabel?: string; /** 恢复最近一次会话上下文 */ onResumeRecentSession?: () => void; - /** 打开 SceneApp 目录页 */ - onOpenSceneAppsDirectory?: () => void; /** 当前项目 ID */ projectId?: string | null; /** 当前会话 ID */ @@ -488,15 +473,10 @@ export const EmptyState: React.FC = ({ onImportSkill, onRefreshSkills, onLaunchBrowserAssist, - featuredSceneApps = [], - onLaunchSceneApp, - canResumeRecentSceneApp = false, - onResumeRecentSceneApp, recentSessionTitle = null, recentSessionSummary = null, recentSessionActionLabel, onResumeRecentSession, - onOpenSceneAppsDirectory, projectId = null, sessionId = null, isLoading = false, @@ -1307,12 +1287,10 @@ export const EmptyState: React.FC = ({ catalogSceneEntries: homeCatalogSceneEntries, serviceSkills: homeServiceSkillItems, installedSkills: skillSelection.skills ?? [], - sceneApps: featuredSceneApps, slashEntryUsage: listSlashEntryUsage(), }); }, [ curatedTaskTemplates, - featuredSceneApps, homeCatalogSceneEntries, homeServiceSkillItems, serviceSkillHomeCopy, @@ -1375,11 +1353,6 @@ export const EmptyState: React.FC = ({ return; } - if (item.launchKind === "scene_app") { - void onLaunchSceneApp?.(item.id); - return; - } - if (item.launchKind === "skill_catalog_scene") { const launchPrompt = item.launchPrompt?.trim() || @@ -1405,7 +1378,6 @@ export const EmptyState: React.FC = ({ handleCuratedTaskLauncherRequest, handleSelectInputCapability, homeServiceSkillItems, - onLaunchSceneApp, serviceSkills, setInput, skillSelection.skills, @@ -1414,10 +1386,6 @@ export const EmptyState: React.FC = ({ const handleSelectHomeStarterChip = useCallback( (chip: HomeStarterChip) => { - if (chip.launchKind === "open_manager") { - onOpenSceneAppsDirectory?.(); - return; - } if (chip.launchKind === "prefill_prompt") { setGuideHelpActive(false); const prompt = chip.prompt?.trim(); @@ -1478,7 +1446,6 @@ export const EmptyState: React.FC = ({ homeSkillItems, knowledgePackSelection, onManageKnowledgePacks, - onOpenSceneAppsDirectory, onStartKnowledgeOrganize, setInput, ], @@ -1531,20 +1498,8 @@ export const EmptyState: React.FC = ({ }); } - if (canResumeRecentSceneApp && onResumeRecentSceneApp) { - actions.push({ - id: "recent-sceneapp", - label: homeSurfaceCopy.chrome.recentSceneAppActionLabel, - testId: "entry-sceneapp-resume", - onSelect: onResumeRecentSceneApp, - }); - } - return actions; }, [ - canResumeRecentSceneApp, - homeSurfaceCopy.chrome.recentSceneAppActionLabel, - onResumeRecentSceneApp, onResumeRecentSession, recentSessionLinkLabel, recentSessionLinkTitle, diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx index 6e35f7245..727e840f2 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx @@ -193,6 +193,9 @@ const renderInputbarCore = async ( return container; }; +const waitForMinimumDictationDuration = () => + new Promise((resolve) => window.setTimeout(resolve, 650)); + describe("InputbarCore", () => { it("挂载时不应主动预取语音配置", async () => { await renderInputbarCore({ @@ -557,13 +560,21 @@ describe("InputbarCore", () => { micButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); await Promise.resolve(); + await waitForMinimumDictationDuration(); + }); + await vi.waitFor(() => { + expect(mockGetRecordingStatus).toHaveBeenCalled(); }); - const stopDictationButton = Array.from( - container.querySelectorAll("button"), - ).find((button) => - button.getAttribute("aria-label")?.startsWith("录音中"), - ) as HTMLButtonElement | undefined; + let stopDictationButton: HTMLButtonElement | undefined; + await vi.waitFor(() => { + stopDictationButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => + button.getAttribute("aria-label")?.startsWith("录音中"), + ) as HTMLButtonElement | undefined; + expect(stopDictationButton).toBeTruthy(); + }); await act(async () => { stopDictationButton?.dispatchEvent( @@ -573,7 +584,9 @@ describe("InputbarCore", () => { await Promise.resolve(); }); - expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本"); + await vi.waitFor(() => { + expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本"); + }); expect(setText).toHaveBeenCalledWith("原始识别文本"); expect(mockToastError).not.toHaveBeenCalled(); }); @@ -693,9 +706,13 @@ describe("InputbarCore", () => { await startHandler?.(); await Promise.resolve(); await Promise.resolve(); + await waitForMinimumDictationDuration(); }); expect(mockStartRecording).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(mockGetRecordingStatus).toHaveBeenCalled(); + }); await act(async () => { await stopHandler?.(); @@ -706,8 +723,10 @@ describe("InputbarCore", () => { expect(mockStopRecording).toHaveBeenCalledTimes(1); expect(mockTranscribeAudio).toHaveBeenCalled(); - expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本"); - expect(setText).toHaveBeenLastCalledWith("润色后的文本"); + await vi.waitFor(() => { + expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本"); + expect(setText).toHaveBeenLastCalledWith("润色后的文本"); + }); }); it("未启用快捷键监听时不应注册语音快捷键事件", async () => { diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts index 4909164aa..5bac80d04 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarDictation.ts @@ -42,6 +42,8 @@ type InputbarDictationState = | "transcribing" | "polishing"; +const MIN_RECORDING_DURATION_SECONDS = 0.5; + interface UseInputbarDictationArgs { text: string; setText: (value: string) => void; @@ -136,6 +138,8 @@ export function useInputbarDictation({ const dictationSelectionRef = useRef(null); const lastLiveTranscriptRef = useRef(""); const lastLiveTranscribedSampleRef = useRef(0); + const recordingStatusRef = useRef(null); + const recordingStartedAtRef = useRef(null); const { playStartSound, playStopSound } = useVoiceSound(soundEnabled); const updateDictationState = useCallback( @@ -174,12 +178,17 @@ export function useInputbarDictation({ textRef.current = text; }, [text]); + useEffect(() => { + recordingStatusRef.current = recordingStatus; + }, [recordingStatus]); + useEffect(() => { dictationStateRef.current = dictationState; if (dictationState !== "listening") { setRecordingStatus(null); if (dictationState === "idle") { setLiveTranscript(""); + recordingStartedAtRef.current = null; } } }, [dictationState]); @@ -216,6 +225,7 @@ export function useInputbarDictation({ const status = await getRecordingStatus(); if (!disposed) { setRecordingStatus(status); + recordingStatusRef.current = status; } } catch (error) { console.error("[输入栏] 获取录音状态失败:", error); @@ -355,7 +365,8 @@ export function useInputbarDictation({ console.error("[输入栏] 检查本地语音模型失败:", error); } - updateDictationState("listening"); + recordingStartedAtRef.current = Date.now(); + updateDictationState("listening"); setLiveTranscript(""); lastLiveTranscriptRef.current = ""; lastLiveTranscribedSampleRef.current = 0; @@ -365,25 +376,31 @@ export function useInputbarDictation({ start: textarea?.selectionStart ?? textRef.current.length, end: textarea?.selectionEnd ?? textRef.current.length, }; - setRecordingStatus({ + const initialRecordingStatus = { is_recording: true, volume: 0, duration: 0, - }); + }; + setRecordingStatus(initialRecordingStatus); + recordingStatusRef.current = initialRecordingStatus; playStartSound(); try { await cancelRecording().catch(() => undefined); await startRecording(config.selected_device_id); - setRecordingStatus({ + const startedRecordingStatus = { is_recording: true, volume: 0, duration: 0, - }); + }; + setRecordingStatus(startedRecordingStatus); + recordingStatusRef.current = startedRecordingStatus; } catch (error: unknown) { console.error("[输入栏] 开始录音失败:", error); toast.error(getRuntimeErrorMessage(error, copy.startFailed)); setRecordingStatus(null); + recordingStatusRef.current = null; + recordingStartedAtRef.current = null; updateDictationState("idle"); } }, [ @@ -400,15 +417,38 @@ export function useInputbarDictation({ return; } + const startedAt = recordingStartedAtRef.current; + const elapsedDuration = + startedAt !== null ? Math.max(0, (Date.now() - startedAt) / 1_000) : 0; + const knownDuration = Math.max( + recordingStatusRef.current?.duration ?? 0, + elapsedDuration, + ); + if ( + knownDuration < MIN_RECORDING_DURATION_SECONDS && + !lastLiveTranscriptRef.current + ) { + await cancelRecording().catch(() => undefined); + toast.info(copy.tooShort); + setRecordingStatus(null); + recordingStatusRef.current = null; + recordingStartedAtRef.current = null; + updateDictationState("idle"); + return; + } + playStopSound(); setRecordingStatus(null); + recordingStatusRef.current = null; updateDictationState("transcribing"); try { const result = await stopRecording(); - if (result.duration < 0.5) { + if (result.duration < MIN_RECORDING_DURATION_SECONDS) { toast.info(copy.tooShort); setRecordingStatus(null); + recordingStatusRef.current = null; + recordingStartedAtRef.current = null; updateDictationState("idle"); return; } @@ -422,6 +462,8 @@ export function useInputbarDictation({ if (!lastLiveTranscriptRef.current) { toast.info(copy.emptyTranscript); setRecordingStatus(null); + recordingStatusRef.current = null; + recordingStartedAtRef.current = null; updateDictationState("idle"); return; } @@ -443,11 +485,15 @@ export function useInputbarDictation({ applyTranscriptToDictationBase(finalText, true); setRecordingStatus(null); + recordingStatusRef.current = null; + recordingStartedAtRef.current = null; updateDictationState("idle"); } catch (error: unknown) { console.error("[输入栏] 完成语音输入失败:", error); toast.error(getRuntimeErrorMessage(error, copy.recognitionFailed)); setRecordingStatus(null); + recordingStatusRef.current = null; + recordingStartedAtRef.current = null; updateDictationState("idle"); } }, [ diff --git a/src/components/agent/chat/experts/ExpertInfoPanel.test.tsx b/src/components/agent/chat/experts/ExpertInfoPanel.test.tsx new file mode 100644 index 000000000..b1d82eb63 --- /dev/null +++ b/src/components/agent/chat/experts/ExpertInfoPanel.test.tsx @@ -0,0 +1,145 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import type { Skill } from "@/lib/api/skills"; +import { ExpertInfoPanel } from "./ExpertInfoPanel"; + +interface MountedContent { + container: HTMLDivElement; + root: Root; +} + +const mountedContents: MountedContent[] = []; + +const REQUEST_METADATA = { + expert: { + expertId: "marketing-strategist", + releaseId: "rel-marketing-strategist-20260515", + title: "营销策略专家", + category: "growth", + source: "seeded", + personaRef: "expert-persona:marketing-strategist@1.0.0", + memoryTemplateRef: "memory-template:marketing-strategist@1.0.0", + skillRefs: ["service-skill:daily-trend-briefing"], + workflowRefs: ["workflow:campaign-growth-loop"], + memoryEnabled: true, + workflowEnabled: true, + }, + harness: { + expert: { + expert_id: "marketing-strategist", + release_id: "rel-marketing-strategist-20260515", + title: "营销策略专家", + category: "growth", + source: "seeded", + persona_ref: "expert-persona:marketing-strategist@1.0.0", + memory_template_ref: "memory-template:marketing-strategist@1.0.0", + skill_refs: ["service-skill:daily-trend-briefing"], + workflow_refs: ["workflow:campaign-growth-loop"], + memory_enabled: true, + workflow_enabled: true, + }, + }, +}; + +const LOCAL_SKILL: Skill = { + key: "docx", + name: "docx", + description: "把 Word 文档解析为可继续处理的结构化内容。", + directory: "docx", + installed: true, + sourceKind: "builtin", + catalogSource: "user", +}; + +function renderPanel(onSkillRefsChange = vi.fn()) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedContents.push({ container, root }); + return { container, onSkillRefsChange }; +} + +async function flushEffects(times = 4) { + await act(async () => { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } + }); +} + +describe("ExpertInfoPanel", () => { + beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await changeLimeLocale("zh-CN"); + window.localStorage.clear(); + }); + + afterEach(() => { + while (mountedContents.length > 0) { + const mounted = mountedContents.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("应支持从右侧专家面板为当前 Agent 添加技能", async () => { + const { container, onSkillRefsChange } = renderPanel(); + await flushEffects(); + + const addButton = container.querySelector( + '[data-testid="expert-info-skills-add"]', + ); + expect(addButton).not.toBeNull(); + + act(() => { + addButton?.click(); + }); + await flushEffects(); + + expect( + container.querySelector('[data-testid="expert-skill-picker-dialog"]'), + ).not.toBeNull(); + + const addDocxButton = container.querySelector( + '[data-testid="expert-skill-add-skill-docx"]', + ); + expect(addDocxButton).not.toBeNull(); + + act(() => { + addDocxButton?.click(); + }); + await flushEffects(); + + expect(container.textContent).toContain("docx"); + expect( + container.querySelector( + '[data-testid="expert-info-skill-chip-skill-docx"]', + ), + ).not.toBeNull(); + expect(onSkillRefsChange).toHaveBeenLastCalledWith([ + "service-skill:daily-trend-briefing", + "skill:docx", + ]); + }); +}); diff --git a/src/components/agent/chat/experts/ExpertInfoPanel.tsx b/src/components/agent/chat/experts/ExpertInfoPanel.tsx new file mode 100644 index 000000000..17258d4ae --- /dev/null +++ b/src/components/agent/chat/experts/ExpertInfoPanel.tsx @@ -0,0 +1,1281 @@ +import { memo, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { + BookOpen, + Brain, + Check, + ChevronDown, + ChevronUp, + Loader2, + Plus, + Search, + Sparkles, + UserRound, + Workflow, + X, +} from "lucide-react"; +import styled from "styled-components"; +import { + getSeededExpertCatalog, + readCachedExpertCatalog, + type ExpertAvatar, + type ExpertCatalog, + type ExpertProfile, +} from "@/features/experts"; +import { + getCurrentSkillCatalogSnapshot, + listSkillCatalogSkillEntries, + type SkillCatalogSkillEntry, +} from "@/lib/api/skillCatalog"; +import type { ServiceSkillItem } from "@/lib/api/serviceSkills"; +import type { Skill } from "@/lib/api/skills"; + +type ExpertPanelSectionKey = + | "overview" + | "memory" + | "diary" + | "skills" + | "workflow"; + +interface ExpertInfoPanelProps { + requestMetadata?: Record | null; + localSkills?: Skill[]; + serviceSkills?: ServiceSkillItem[]; + skillsLoading?: boolean; + onSkillRefsChange?: (skillRefs: string[]) => void; +} + +interface ResolvedExpertInfo { + id: string; + title: string; + summary: string; + avatar: ExpertAvatar; + category: string; + tags: string[]; + promptStarters: string[]; + showcase: Array<{ title: string; body: string }>; + memoryTemplateRef?: string; + skillRefs: string[]; + workflowRefs: string[]; + memoryEnabled: boolean; + workflowEnabled: boolean; + usageCount?: number; + likeCount?: number; +} + +type ExpertSkillCandidateSource = "local" | "service" | "catalog"; + +interface ExpertSkillCandidate { + ref: string; + title: string; + summary: string; + source: ExpertSkillCandidateSource; + sourceLabel: string; +} + +const Panel = styled.aside` + display: flex; + width: 328px; + max-width: 34vw; + min-width: 300px; + flex: 0 0 328px; + min-height: 0; + flex-direction: column; + gap: 14px; + overflow-y: auto; + border: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.92)); + border-radius: 24px; + background: var(--lime-surface, #ffffff); + box-shadow: 0 24px 56px -42px var(--lime-shadow-color, rgba(15, 23, 42, 0.18)); + color: var(--lime-text, #1a3b2b); + padding: 16px; + + @media (max-width: 1120px) { + display: none; + } +`; + +const PanelTitle = styled.div` + display: flex; + align-items: center; + gap: 8px; + color: var(--lime-text-strong, #123d2e); + font-size: 14px; + font-weight: 760; +`; + +const Header = styled.div` + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 12px; + align-items: center; +`; + +const Avatar = styled.div` + display: inline-flex; + width: 44px; + height: 44px; + align-items: center; + justify-content: center; + border-radius: 18px; + background: var(--lime-brand-soft, #ecfdf5); + color: var(--lime-brand-strong, #166534); + font-size: 24px; + box-shadow: inset 0 0 0 1px + var(--lime-surface-border, rgba(226, 240, 226, 0.9)); + overflow: hidden; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +`; + +const HeaderText = styled.div` + min-width: 0; +`; + +const ExpertTitle = styled.h2` + margin: 0; + overflow: hidden; + color: var(--lime-text-strong, #123d2e); + font-size: 16px; + font-weight: 780; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ExpertSummary = styled.p` + margin: 3px 0 0; + overflow: hidden; + color: var(--lime-text-muted, #6b826b); + font-size: 12px; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const SectionHeaderRow = styled.div` + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 8px; +`; + +const SectionToggleButton = styled.button` + display: flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + justify-content: space-between; + border: 0; + background: transparent; + color: var(--lime-text-strong, #123d2e); + cursor: pointer; + padding: 0; + font-size: 14px; + font-weight: 740; + text-align: left; +`; + +const SectionTitle = styled.span` + display: inline-flex; + min-width: 0; + align-items: center; + gap: 7px; +`; + +const IconActionButton = styled.button` + display: inline-flex; + width: 28px; + height: 28px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--lime-brand, #10b981); + border-radius: 10px; + background: var(--lime-brand-soft, #ecfdf5); + color: var(--lime-brand-strong, #166534); + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + transform 160ms ease; + + &:hover { + border-color: var(--lime-brand-strong, #166534); + background: var(--lime-surface, #ffffff); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--lime-focus-ring, rgba(16, 185, 129, 0.28)); + outline-offset: 2px; + } +`; + +const Card = styled.div` + border: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.88)); + border-radius: 16px; + background: var(--lime-surface-soft, #f8fcf9); + padding: 12px; +`; + +const BlockTitle = styled.h3` + margin: 0 0 7px; + color: var(--lime-text-strong, #123d2e); + font-size: 12px; + font-weight: 760; + + &:not(:first-child) { + margin-top: 12px; + } +`; + +const BodyText = styled.p` + margin: 0; + color: var(--lime-text, #1a3b2b); + font-size: 12px; + line-height: 1.65; +`; + +const BulletList = styled.ul` + margin: 0; + padding-left: 17px; + color: var(--lime-text, #1a3b2b); + font-size: 12px; + line-height: 1.65; +`; + +const ChipList = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +const Chip = styled.span` + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 6px; + border-radius: 10px; + background: var(--lime-surface-muted, #f2f7f3); + color: var(--lime-text, #1a3b2b); + font-size: 12px; + line-height: 1; + padding: 7px 9px; +`; + +const ChipRemoveButton = styled.button` + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: var(--lime-surface, #ffffff); + color: var(--lime-text-muted, #6b826b); + cursor: pointer; + padding: 0; + + &:hover { + background: var(--lime-brand-soft, #ecfdf5); + color: var(--lime-brand-strong, #166534); + } +`; + +const EmptyCard = styled(Card)` + color: var(--lime-text-muted, #6b826b); + font-size: 12px; +`; + +const WorkflowList = styled.ol` + display: flex; + flex-direction: column; + gap: 0; + list-style: none; + margin: 0; + padding: 2px 0 0; +`; + +const WorkflowStep = styled.li` + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + column-gap: 9px; + color: var(--lime-text, #1a3b2b); + font-size: 12px; + line-height: 1.5; + + &::before { + content: ""; + width: 8px; + height: 8px; + margin-top: 5px; + border: 1px solid var(--lime-brand, #10b981); + border-radius: 999px; + background: var(--lime-surface, #ffffff); + box-shadow: 0 0 0 3px var(--lime-brand-soft, #ecfdf5); + } + + &:not(:last-child)::after { + content: ""; + grid-column: 1; + width: 1px; + height: 22px; + margin: 2px auto; + background: var(--lime-surface-border-strong, #c7e7d1); + } +`; + +const SkillDialogBackdrop = styled.div` + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + background: rgba(17, 52, 38, 0.28); + padding: 24px; +`; + +const SkillDialog = styled.div` + display: flex; + width: min(560px, calc(100vw - 48px)); + max-height: min(720px, calc(100vh - 48px)); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.95)); + border-radius: 24px; + background: var(--lime-surface, #ffffff); + box-shadow: 0 32px 90px -46px var(--lime-shadow-color, rgba(15, 23, 42, 0.32)); +`; + +const SkillDialogHeader = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) 32px; + gap: 12px; + align-items: start; + border-bottom: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.88)); + padding: 18px 18px 14px; +`; + +const SkillDialogTitle = styled.h2` + margin: 0; + color: var(--lime-text-strong, #123d2e); + font-size: 17px; + font-weight: 780; + line-height: 1.35; +`; + +const SkillDialogSubtitle = styled.p` + margin: 5px 0 0; + color: var(--lime-text-muted, #6b826b); + font-size: 12px; + line-height: 1.55; +`; + +const DialogCloseButton = styled(IconActionButton)` + border-color: var(--lime-surface-border, rgba(226, 240, 226, 0.92)); + background: var(--lime-surface-soft, #f8fcf9); + color: var(--lime-text-muted, #6b826b); +`; + +const SkillDialogBody = styled.div` + display: flex; + min-height: 0; + flex-direction: column; + gap: 12px; + padding: 14px 18px 18px; +`; + +const SkillSearchBox = styled.label` + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 8px; + align-items: center; + border: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.92)); + border-radius: 14px; + background: var(--lime-surface-soft, #f8fcf9); + color: var(--lime-text-muted, #6b826b); + padding: 10px 12px; + + input { + min-width: 0; + border: 0; + outline: none; + background: transparent; + color: var(--lime-text, #1a3b2b); + font-size: 13px; + } +`; + +const SkillCandidateList = styled.div` + display: flex; + min-height: 0; + flex-direction: column; + gap: 8px; + overflow-y: auto; + padding-right: 2px; +`; + +const SkillCandidateCard = styled.div` + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + border: 1px solid var(--lime-surface-border, rgba(226, 240, 226, 0.9)); + border-radius: 16px; + background: var(--lime-surface-soft, #f8fcf9); + padding: 10px; +`; + +const SkillCandidateAvatar = styled.div` + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + border-radius: 13px; + background: var(--lime-brand-soft, #ecfdf5); + color: var(--lime-brand-strong, #166534); + font-size: 13px; + font-weight: 780; +`; + +const SkillCandidateContent = styled.div` + min-width: 0; +`; + +const SkillCandidateTitle = styled.div` + overflow: hidden; + color: var(--lime-text-strong, #123d2e); + font-size: 13px; + font-weight: 760; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const SkillCandidateSummary = styled.div` + display: -webkit-box; + margin-top: 3px; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: var(--lime-text-muted, #6b826b); + font-size: 12px; + line-height: 1.45; +`; + +const SkillCandidateMeta = styled.div` + margin-top: 6px; + color: var(--lime-brand-strong, #166534); + font-size: 11px; + font-weight: 700; +`; + +const CandidateAddButton = styled.button<{ $added?: boolean }>` + display: inline-flex; + min-width: 68px; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid + ${({ $added }) => + $added + ? "var(--lime-surface-border, rgba(226, 240, 226, 0.92))" + : "var(--lime-brand, #10b981)"}; + border-radius: 999px; + background: ${({ $added }) => + $added + ? "var(--lime-surface-muted, #f2f7f3)" + : "var(--lime-brand-soft, #ecfdf5)"}; + color: ${({ $added }) => + $added + ? "var(--lime-text-muted, #6b826b)" + : "var(--lime-brand-strong, #166534)"}; + cursor: ${({ $added }) => ($added ? "default" : "pointer")}; + font-size: 12px; + font-weight: 740; + padding: 7px 10px; + + &:not(:disabled):hover { + border-color: var(--lime-brand-strong, #166534); + background: var(--lime-surface, #ffffff); + } +`; + +const SkillEmptyState = styled(EmptyCard)` + display: flex; + min-height: 96px; + align-items: center; + justify-content: center; + gap: 8px; + text-align: center; +`; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function pickString(record: Record | null, key: string) { + const value = record?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function pickStringArray(record: Record | null, key: string) { + const value = record?.[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function pickBoolean( + record: Record | null, + key: string, + fallback: boolean, +) { + const value = record?.[key]; + return typeof value === "boolean" ? value : fallback; +} + +function normalizeRefKey(ref: string): string { + return ref.trim().toLowerCase(); +} + +function sanitizeTestId(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function dedupeSkillRefs(refs: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const ref of refs) { + const normalized = ref.trim(); + if (!normalized) { + continue; + } + const key = normalizeRefKey(normalized); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(normalized); + } + return result; +} + +function extractExpertMetadata(metadata?: Record | null) { + const root = asRecord(metadata); + const expert = asRecord(root?.expert); + const harness = asRecord(asRecord(root?.harness)?.expert); + const expertId = + pickString(expert, "expertId") || pickString(harness, "expert_id"); + if (!expertId) { + return null; + } + + return { + id: expertId, + title: pickString(expert, "title") || pickString(harness, "title"), + category: pickString(expert, "category") || pickString(harness, "category"), + source: pickString(expert, "source") || pickString(harness, "source"), + memoryTemplateRef: + pickString(expert, "memoryTemplateRef") || + pickString(harness, "memory_template_ref"), + skillRefs: + pickStringArray(expert, "skillRefs").length > 0 + ? pickStringArray(expert, "skillRefs") + : pickStringArray(harness, "skill_refs"), + workflowRefs: + pickStringArray(expert, "workflowRefs").length > 0 + ? pickStringArray(expert, "workflowRefs") + : pickStringArray(harness, "workflow_refs"), + memoryEnabled: + pickBoolean(expert, "memoryEnabled", true) && + pickBoolean(harness, "memory_enabled", true), + workflowEnabled: + pickBoolean(expert, "workflowEnabled", true) && + pickBoolean(harness, "workflow_enabled", true), + }; +} + +function getCatalogs(): ExpertCatalog[] { + const cached = readCachedExpertCatalog(); + const seeded = getSeededExpertCatalog(); + return cached ? [cached, seeded] : [seeded]; +} + +function findCatalogExpert(expertId: string): ExpertProfile | null { + for (const catalog of getCatalogs()) { + const matched = catalog.items.find((item) => item.id === expertId); + if (matched) { + return matched; + } + } + return null; +} + +function renderAvatar(avatar: ExpertAvatar, title: string) { + if (avatar.kind === "url" || avatar.kind === "asset") { + return {title}; + } + return avatar.value; +} + +function formatRefLabel(ref: string) { + const withoutPrefix = ref.includes(":") ? ref.split(":").pop() || ref : ref; + const withoutVersion = withoutPrefix.split("@")[0] || withoutPrefix; + return withoutVersion + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function localSkillRef(skill: Skill): string { + const key = skill.key?.trim() || skill.directory?.trim() || skill.name.trim(); + return key.startsWith("skill:") ? key : `skill:${key}`; +} + +function serviceSkillRef(skill: Pick): string { + return `service-skill:${skill.id}`; +} + +function catalogSkillRef(entry: SkillCatalogSkillEntry): string { + return entry.skillId?.trim() + ? `skill:${entry.skillId.trim()}` + : entry.id.trim(); +} + +function candidateInitial(title: string): string { + const normalized = title.trim(); + return normalized ? normalized.slice(0, 1).toUpperCase() : "S"; +} + +function upsertSkillCandidate( + map: Map, + candidate: ExpertSkillCandidate, +) { + const key = normalizeRefKey(candidate.ref); + if (!key || map.has(key)) { + return; + } + map.set(key, candidate); +} + +function buildSkillCandidates(input: { + localSkills?: Skill[]; + serviceSkills?: ServiceSkillItem[]; +}): ExpertSkillCandidate[] { + const candidates = new Map(); + + for (const skill of input.localSkills || []) { + const ref = localSkillRef(skill); + upsertSkillCandidate(candidates, { + ref, + title: skill.name || skill.key || skill.directory, + summary: skill.description || skill.directory || ref, + source: "local", + sourceLabel: skill.catalogSource === "remote" ? "remote" : "local", + }); + } + + for (const skill of input.serviceSkills || []) { + upsertSkillCandidate(candidates, { + ref: serviceSkillRef(skill), + title: skill.title, + summary: skill.summary, + source: "service", + sourceLabel: skill.category || "", + }); + } + + try { + const catalog = getCurrentSkillCatalogSnapshot(); + for (const item of catalog.items) { + upsertSkillCandidate(candidates, { + ref: serviceSkillRef(item), + title: item.title, + summary: item.summary, + source: "service", + sourceLabel: item.category || "", + }); + } + for (const entry of listSkillCatalogSkillEntries(catalog)) { + upsertSkillCandidate(candidates, { + ref: catalogSkillRef(entry), + title: entry.title, + summary: entry.summary, + source: "catalog", + sourceLabel: entry.groupKey || "", + }); + } + } catch { + // 目录读取失败不应阻断专家信息面板;已绑定技能仍按 ref 展示。 + } + + const sourceOrder: Record = { + local: 0, + service: 1, + catalog: 2, + }; + + return [...candidates.values()].sort((left, right) => { + const sourceDiff = sourceOrder[left.source] - sourceOrder[right.source]; + if (sourceDiff !== 0) { + return sourceDiff; + } + return left.title.localeCompare(right.title, "zh-CN"); + }); +} + +function filterSkillCandidates( + candidates: ExpertSkillCandidate[], + query: string, +): ExpertSkillCandidate[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return candidates; + } + + return candidates.filter((candidate) => + [ + candidate.ref, + candidate.title, + candidate.summary, + candidate.sourceLabel, + ].some((value) => value.toLowerCase().includes(normalized)), + ); +} + +function resolveSkillLabel( + ref: string, + candidates: ExpertSkillCandidate[], +): string { + const matched = candidates.find( + (candidate) => normalizeRefKey(candidate.ref) === normalizeRefKey(ref), + ); + return matched?.title || formatRefLabel(ref); +} + +function resolveExpertInfo( + metadata?: Record | null, +): ResolvedExpertInfo | null { + const extracted = extractExpertMetadata(metadata); + if (!extracted) { + return null; + } + + const catalogExpert = findCatalogExpert(extracted.id); + return { + id: extracted.id, + title: extracted.title || catalogExpert?.title || extracted.id, + summary: catalogExpert?.summary || "", + avatar: catalogExpert?.avatar || { kind: "emoji", value: "🧑‍💼" }, + category: extracted.category || catalogExpert?.category || "expert", + tags: catalogExpert?.tags || [], + promptStarters: catalogExpert?.promptStarters || [], + showcase: catalogExpert?.showcase || [], + memoryTemplateRef: + extracted.memoryEnabled === false + ? undefined + : extracted.memoryTemplateRef || + catalogExpert?.release.memoryTemplateRef, + skillRefs: + extracted.skillRefs.length > 0 + ? extracted.skillRefs + : catalogExpert?.release.skillRefs || [], + workflowRefs: + extracted.workflowEnabled === false + ? [] + : extracted.workflowRefs.length > 0 + ? extracted.workflowRefs + : catalogExpert?.release.workflowRefs || [], + memoryEnabled: extracted.memoryEnabled, + workflowEnabled: extracted.workflowEnabled, + usageCount: catalogExpert?.stats.usageCount, + likeCount: catalogExpert?.stats.likeCount, + }; +} + +function useCollapsedSections() { + const [collapsed, setCollapsed] = useState>( + () => new Set(), + ); + + const toggle = (key: ExpertPanelSectionKey) => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + + return { collapsed, toggle }; +} + +export const ExpertInfoPanel = memo(function ExpertInfoPanel({ + requestMetadata, + localSkills, + serviceSkills, + skillsLoading = false, + onSkillRefsChange, +}: ExpertInfoPanelProps) { + const { t } = useTranslation("agent"); + const expert = useMemo( + () => resolveExpertInfo(requestMetadata), + [requestMetadata], + ); + const { collapsed, toggle } = useCollapsedSections(); + const [addedSkillRefs, setAddedSkillRefs] = useState([]); + const [skillQuery, setSkillQuery] = useState(""); + const [skillPickerOpen, setSkillPickerOpen] = useState(false); + const candidates = useMemo( + () => buildSkillCandidates({ localSkills, serviceSkills }), + [localSkills, serviceSkills], + ); + const filteredCandidates = useMemo( + () => filterSkillCandidates(candidates, skillQuery), + [candidates, skillQuery], + ); + + const baseSkillRefs = useMemo(() => (expert ? expert.skillRefs : []), [expert]); + const effectiveSkillRefs = useMemo( + () => dedupeSkillRefs([...baseSkillRefs, ...addedSkillRefs]), + [addedSkillRefs, baseSkillRefs], + ); + const baseSkillRefKeys = useMemo( + () => new Set(baseSkillRefs.map(normalizeRefKey)), + [baseSkillRefs], + ); + const effectiveSkillRefKeys = useMemo( + () => new Set(effectiveSkillRefs.map(normalizeRefKey)), + [effectiveSkillRefs], + ); + + useEffect(() => { + if (!expert) { + return; + } + onSkillRefsChange?.(effectiveSkillRefs); + }, [effectiveSkillRefs, expert, onSkillRefsChange]); + + useEffect(() => { + setAddedSkillRefs([]); + setSkillQuery(""); + setSkillPickerOpen(false); + }, [expert?.id]); + + if (!expert) { + return null; + } + + const renderSection = ( + key: ExpertPanelSectionKey, + title: string, + icon: ReactNode, + content: ReactNode, + action?: ReactNode, + ) => { + const isCollapsed = collapsed.has(key); + return ( +
+ + toggle(key)} + > + + {icon} + {title} + + {isCollapsed ? : } + + {action} + + {isCollapsed ? null : content} +
+ ); + }; + + const focusItems = [ + ...expert.tags.slice(0, 4).map((tag) => `#${tag}`), + ...expert.promptStarters.slice(0, 2), + ]; + const workflowRefs = expert.workflowRefs; + const handleAddSkill = (ref: string) => { + setAddedSkillRefs((current) => + dedupeSkillRefs([...current, ref]).filter( + (item) => !baseSkillRefKeys.has(normalizeRefKey(item)), + ), + ); + }; + const handleRemoveAddedSkill = (ref: string) => { + const key = normalizeRefKey(ref); + setAddedSkillRefs((current) => + current.filter((item) => normalizeRefKey(item) !== key), + ); + }; + const resolveCandidateSourceLabel = (candidate: ExpertSkillCandidate) => { + if (candidate.source === "local") { + return candidate.sourceLabel === "remote" + ? t("agentExperts.info.skills.source.remote", "技能市场") + : t("agentExperts.info.skills.source.local", "本地技能"); + } + if (candidate.source === "service") { + return ( + candidate.sourceLabel || + t("agentExperts.info.skills.source.service", "服务技能") + ); + } + return ( + candidate.sourceLabel || + t("agentExperts.info.skills.source.catalog", "技能目录") + ); + }; + + return ( + + + + {t("agentExperts.info.title", "专家信息")} + + +
+ + + {expert.title} + {expert.summary} + +
+ + {renderSection( + "overview", + t("agentExperts.info.sections.overview", "简介"), + , + + + {t("agentExperts.info.overview.who", "专家定位")} + + + {expert.summary || + t( + "agentExperts.info.overview.empty", + "这个专家会把人设、技能、记忆和工作流绑定到当前 Agent 对话中。", + )} + + + {t("agentExperts.info.overview.how", "工作方式")} + + +
  • + {t( + "agentExperts.info.overview.workWithContext", + "先理解当前项目目标,再给出可执行建议。", + )} +
  • +
  • + {t( + "agentExperts.info.overview.workWithRefs", + "必要时引用已绑定的技能、记忆模板和工作流。", + )} +
  • + {expert.showcase.slice(0, 2).map((item) => ( +
  • + {item.title}:{item.body} +
  • + ))} +
    + + {t("agentExperts.info.overview.boundaries", "边界")} + + +
  • + {t( + "agentExperts.info.boundary.truth", + "基于真实上下文和用户提供的信息工作,不编造未给出的事实。", + )} +
  • +
  • + {t( + "agentExperts.info.boundary.private", + "不会把项目文件、私有记忆或对话内容写回公共专家目录。", + )} +
  • +
    +
    , + )} + + {renderSection( + "memory", + t("agentExperts.info.sections.memory", "记忆"), + , + expert.memoryTemplateRef ? ( + + + {t("agentExperts.info.memory.title", { + title: expert.title, + defaultValue: "MEMORY.md — {{title}} Memory", + })} + + + {t("agentExperts.info.memory.focus", "当前项目与关注")} + + + {focusItems.length > 0 ? ( + focusItems.map((item) =>
  • {item}
  • ) + ) : ( +
  • + {t( + "agentExperts.info.memory.defaultFocus", + "围绕本轮任务持续沉淀偏好、约束和复盘线索。", + )} +
  • + )} +
    + + {t("agentExperts.info.memory.accumulated", "经验积累")} + + +
  • + {t( + "agentExperts.info.memory.templateEnabled", + "专家记忆模板已启用,会作为本轮对话的长期上下文线索。", + )} +
  • + {typeof expert.usageCount === "number" ? ( +
  • + {t("agentExperts.info.memory.usage", { + count: expert.usageCount, + defaultValue: "历史使用 {{count}} 次", + })} +
  • + ) : null} +
    +
    + ) : ( + + {t( + "agentExperts.info.memory.disabled", + "这个专家当前没有启用记忆模板。", + )} + + ), + )} + + {renderSection( + "diary", + t("agentExperts.info.sections.diary", "日记"), + , + {t("agentExperts.info.diary.empty", "暂无日记")}, + )} + + {renderSection( + "skills", + t("agentExperts.info.sections.skills", "技能"), + , + effectiveSkillRefs.length > 0 ? ( + + {effectiveSkillRefs.map((ref) => ( + + {resolveSkillLabel(ref, candidates)} + {baseSkillRefKeys.has(normalizeRefKey(ref)) ? null : ( + handleRemoveAddedSkill(ref)} + > + + + )} + + ))} + + ) : ( + + {t("agentExperts.info.skills.empty", "暂无绑定技能")} + + ), + setSkillPickerOpen(true)} + > + + , + )} + + {renderSection( + "workflow", + t("agentExperts.info.sections.workflow", "工作流程"), + , + workflowRefs.length > 0 ? ( + + {workflowRefs.map((ref) => ( + {formatRefLabel(ref)} + ))} + + ) : ( + + {t("agentExperts.info.workflow.empty", "暂无绑定工作流程")} + + ), + )} + + {skillPickerOpen ? ( + { + if (event.target === event.currentTarget) { + setSkillPickerOpen(false); + } + }} + > + + +
    + + {t( + "agentExperts.info.skills.dialogTitle", + "为当前 Agent 添加技能", + )} + + + {t( + "agentExperts.info.skills.dialogSubtitle", + "选择后会加入当前专家对话的 skillRefs,并随下一轮 Agent 请求进入运行时上下文。", + )} + +
    + setSkillPickerOpen(false)} + > + + +
    + + + + setSkillQuery(event.target.value)} + /> + + + {skillsLoading && candidates.length === 0 ? ( + + + {t("agentExperts.info.skills.loading", "正在加载技能目录...")} + + ) : filteredCandidates.length > 0 ? ( + + {filteredCandidates.map((candidate) => { + const added = effectiveSkillRefKeys.has( + normalizeRefKey(candidate.ref), + ); + return ( + + + + + {candidate.title} + + + {candidate.summary} + + + {resolveCandidateSourceLabel(candidate)} ·{" "} + {candidate.ref} + + + handleAddSkill(candidate.ref)} + > + {added ? : } + {added + ? t("agentExperts.info.skills.added", "已添加") + : t("agentExperts.info.skills.add", "添加")} + + + ); + })} + + ) : ( + + {t( + "agentExperts.info.skills.noCandidates", + "没有找到可添加的技能。", + )} + + )} + +
    +
    + ) : null} +
    + ); +}); diff --git a/src/components/agent/chat/home/HomeStartSurface.test.tsx b/src/components/agent/chat/home/HomeStartSurface.test.tsx index 705ad4e1b..226348483 100644 --- a/src/components/agent/chat/home/HomeStartSurface.test.tsx +++ b/src/components/agent/chat/home/HomeStartSurface.test.tsx @@ -40,7 +40,6 @@ const TEST_CHROME_COPY: HomeSurfaceChromeCopy = { scrollCueLabel: "向下滑,看看 Lime 可以帮你做什么", secondScreenLabel: "Lime 可执行任务示例", recentSessionDefaultActionLabel: "继续最近会话", - recentSceneAppActionLabel: "继续最近做法", }; function createItem(): HomeSkillSurfaceItem { diff --git a/src/components/agent/chat/home/buildHomeSkillSurface.ts b/src/components/agent/chat/home/buildHomeSkillSurface.ts index 0fc4c14bd..9da59ba52 100644 --- a/src/components/agent/chat/home/buildHomeSkillSurface.ts +++ b/src/components/agent/chat/home/buildHomeSkillSurface.ts @@ -5,14 +5,12 @@ import type { } from "@/lib/api/skillCatalog"; import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { ServiceSkillHomeCopy } from "../service-skills/homeCopy"; -import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { CuratedTaskTemplateItem } from "../utils/curatedTaskTemplates"; import type { SlashEntryUsageRecord } from "../skill-selection/slashEntryUsage"; import { HOME_CATEGORY_ORDER, type HomeSurfaceCopy } from "./homeSurfaceCopy"; import { fromCuratedTaskTemplate, fromInstalledSkill, - fromSceneAppEntry, fromServiceSkill, fromSkillCatalogSceneEntry, } from "./homeSkillSurfaceAdapters"; @@ -31,7 +29,6 @@ interface BuildHomeSkillSurfaceInput { serviceSkills?: ServiceSkillHomeItem[]; catalogSceneEntries?: SkillCatalogSceneEntry[]; installedSkills?: Skill[]; - sceneApps?: SceneAppEntryCardItem[]; slashEntryUsage?: SlashEntryUsageRecord[]; } @@ -84,7 +81,6 @@ export function buildHomeSkillItems({ serviceSkills = [], catalogSceneEntries = [], installedSkills = [], - sceneApps = [], slashEntryUsage = [], }: BuildHomeSkillSurfaceInput): HomeSkillSurfaceItem[] { const installedUsage = getUsageByKind(slashEntryUsage, "skill"); @@ -114,9 +110,6 @@ export function buildHomeSkillItems({ serviceSkillHomeCopy, ), ), - ...sceneApps.map((item) => - fromSceneAppEntry(item, sceneUsage.get(item.id), serviceSkillHomeCopy), - ), ]).sort(compareHomeItems); } diff --git a/src/components/agent/chat/home/homeSkillSurfaceAdapters.ts b/src/components/agent/chat/home/homeSkillSurfaceAdapters.ts index c70a46f79..a3d3c9702 100644 --- a/src/components/agent/chat/home/homeSkillSurfaceAdapters.ts +++ b/src/components/agent/chat/home/homeSkillSurfaceAdapters.ts @@ -2,7 +2,6 @@ import type { Skill } from "@/lib/api/skills"; import type { SkillCatalogSceneEntry } from "@/lib/api/skillCatalog"; import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { ServiceSkillHomeCopy } from "../service-skills/homeCopy"; -import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { CuratedTaskTemplateItem } from "../utils/curatedTaskTemplates"; import type { SlashEntryUsageRecord } from "../skill-selection/slashEntryUsage"; import type { @@ -187,27 +186,6 @@ export function fromInstalledSkill( }; } -export function fromSceneAppEntry( - item: SceneAppEntryCardItem, - usage: SlashEntryUsageRecord | undefined, - copy: ServiceSkillHomeCopy, -): HomeSkillSurfaceItem { - return { - id: item.id, - title: item.title, - summary: usage?.replayText?.trim() || item.summary, - category: "other", - sourceKind: "scene_app", - launchKind: "scene_app", - coverToken: buildCoverToken(item.executionTone, "scene"), - isRecent: Boolean(usage), - isRecommended: false, - usedAt: usage?.usedAt ?? null, - testId: `entry-sceneapp-${item.id}`, - badge: usage ? copy.badge.recent : item.businessLabel, - }; -} - export function fromSkillCatalogSceneEntry( entry: SkillCatalogSceneEntry, usage: SlashEntryUsageRecord | undefined, diff --git a/src/components/agent/chat/home/homeSurfaceCopy.ts b/src/components/agent/chat/home/homeSurfaceCopy.ts index c6d21e3ad..0a178a689 100644 --- a/src/components/agent/chat/home/homeSurfaceCopy.ts +++ b/src/components/agent/chat/home/homeSurfaceCopy.ts @@ -108,8 +108,7 @@ export type HomeSurfaceCopyKey = | "agentChat.home.gallery.description" | "agentChat.home.scrollCue.label" | "agentChat.home.secondScreen.label" - | "agentChat.home.supplemental.recentSession.defaultAction" - | "agentChat.home.supplemental.recentSceneApp.label"; + | "agentChat.home.supplemental.recentSession.defaultAction"; type HomeSurfaceCopyValue = number | string; @@ -128,7 +127,6 @@ export interface HomeSurfaceChromeCopy { scrollCueLabel: string; secondScreenLabel: string; recentSessionDefaultActionLabel: string; - recentSceneAppActionLabel: string; } export interface HomeSurfaceHeroCopy { @@ -370,9 +368,6 @@ export function buildHomeSurfaceCopy( recentSessionDefaultActionLabel: translate( "agentChat.home.supplemental.recentSession.defaultAction", ), - recentSceneAppActionLabel: translate( - "agentChat.home.supplemental.recentSceneApp.label", - ), }, categoryLabels: { recent: translate("agentChat.home.category.recent"), diff --git a/src/components/agent/chat/home/homeSurfaceTypes.ts b/src/components/agent/chat/home/homeSurfaceTypes.ts index 30cbb8298..8e8fca75a 100644 --- a/src/components/agent/chat/home/homeSurfaceTypes.ts +++ b/src/components/agent/chat/home/homeSurfaceTypes.ts @@ -11,14 +11,12 @@ export type HomeSkillSourceKind = | "curated_task" | "service_skill" | "installed_skill" - | "scene_app" | "skill_catalog_scene"; export type HomeSkillLaunchKind = | "curated_task_launcher" | "service_skill" | "installed_skill" - | "scene_app" | "skill_catalog_scene" | "prefill_prompt" | "open_knowledge_hub" @@ -26,8 +24,7 @@ export type HomeSkillLaunchKind = | "open_drawer" | "open_manager" | "launch_browser" - | "resume_recent_session" - | "resume_recent_scene_app"; + | "resume_recent_session"; export interface HomeSkillSurfaceItem { id: string; diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index d05e7c4c8..86fde4be3 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1427,11 +1427,16 @@ describe("AgentChatPage 任务中心初始会话标签", () => { state.switchTopic = switchTopic; installMockAgentChatUnifiedState(state); - mountPage({ + const { container } = mountPage({ agentEntry: "claw", initialSessionId: "topic-target", projectId: "workspace-test", }); + await waitForElement( + container, + '[data-testid="workspace-shell-scene"]', + 80, + ); await flushEffects(); expect(switchTopic).toHaveBeenCalledWith("topic-target", { @@ -5130,6 +5135,77 @@ describe("AgentChatPage 通用工作台", { timeout: 20_000 }, () => { expect(onHasMessagesChange).toHaveBeenCalled(); }); + it("自动首条专家入口在尚无会话时也应创建发送计划", async () => { + installMockAgentChatUnifiedState( + createMockAgentChatUnifiedState({ + sessionId: null, + topics: [], + }), + ); + + const prompt = "请以营销策略专家身份帮我拆解增长方案"; + mockGetOrCreateDefaultProject.mockResolvedValue( + createProject("project-expert-default"), + ); + const container = renderPage({ + agentEntry: "claw", + projectId: "default", + theme: "general", + lockTheme: true, + newChatAt: 1234567890, + initialUserPrompt: prompt, + autoRunInitialPromptOnMount: true, + initialAutoSendRequestMetadata: { + expert: { + expertId: "marketing-strategist", + }, + harness: { + expert: { + expert_id: "marketing-strategist", + }, + }, + }, + }); + await waitForElement( + container, + '[data-testid="workspace-shell-scene"]', + 80, + ); + await flushEffects(12); + + const expertPanel = container.querySelector( + '[data-testid="expert-info-panel"]', + ); + expect(expertPanel).not.toBeNull(); + expect(expertPanel?.textContent).toMatch(/专家信息|Expert Info/); + expect(expertPanel?.textContent).toContain("营销策略专家"); + expect( + expertPanel?.querySelector('[data-testid="expert-info-section-memory"]'), + ).not.toBeNull(); + expect( + expertPanel?.querySelector('[data-testid="expert-info-skills"]'), + ).not.toBeNull(); + expect( + expertPanel?.querySelector('[data-testid="expert-info-workflow"]'), + ).not.toBeNull(); + + expect(sharedSendMessageMock).toHaveBeenCalledTimes(1); + expect(sharedSendMessageMock.mock.calls[0]?.[0]).toBe(prompt); + expect(sharedSendMessageMock.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + expert: { + expertId: "marketing-strategist", + }, + harness: { + expert: { + expert_id: "marketing-strategist", + }, + }, + }, + skipSessionRestore: true, + }); + }); + it("强浏览器任务直发时不应注入继续执行确认消息", async () => { renderPage({ projectId: "project-browser-required-continue", diff --git a/src/components/agent/chat/sceneappEntryTypes.ts b/src/components/agent/chat/sceneappEntryTypes.ts deleted file mode 100644 index c49df8edb..000000000 --- a/src/components/agent/chat/sceneappEntryTypes.ts +++ /dev/null @@ -1 +0,0 @@ -export type { SceneAppEntryCardItem, SceneAppEntryTone } from "@/lib/sceneapp"; diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx index 2fb4396a4..c058339ab 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -7,16 +7,67 @@ import { saveSkillCatalog, type SkillCatalog, } from "@/lib/api/skillCatalog"; +import { + getAutomationJobs, + type AutomationJobRecord, +} from "@/lib/api/automation"; import { changeLimeLocale } from "@/i18n/createI18n"; import { recordServiceSkillAutomationLink } from "./automationLinkStorage"; import { recordServiceSkillUsage } from "./storage"; import { useServiceSkills } from "./useServiceSkills"; +vi.mock("@/lib/api/automation", () => ({ + getAutomationJobs: vi.fn(), +})); + interface HookHarness { getValue: () => ReturnType; unmount: () => void; } +function buildAutomationJob( + overrides: Partial = {}, +): AutomationJobRecord { + const timestamp = "2026-03-29T12:00:00.000Z"; + + return { + id: "automation-job-daily-brief", + name: "每日线索巡检", + description: null, + enabled: true, + workspace_id: "workspace-default", + execution_mode: "skill", + schedule: { kind: "every", every_secs: 86_400 }, + payload: { + kind: "agent_turn", + prompt: "巡检今日可复用的增长线索", + web_search: false, + request_metadata: null, + }, + delivery: { + mode: "none", + best_effort: true, + output_schema: "text", + output_format: "text", + }, + timeout_secs: null, + max_retries: 0, + next_run_at: "2026-03-30T12:00:00.000Z", + last_status: "success", + last_error: null, + last_run_at: timestamp, + last_finished_at: timestamp, + running_started_at: null, + consecutive_failures: 0, + last_retry_count: 0, + auto_disabled_until: null, + last_delivery: null, + created_at: timestamp, + updated_at: timestamp, + ...overrides, + }; +} + function buildRemoteCatalog(): SkillCatalog { const seeded = getSeededSkillCatalog(); return { @@ -210,6 +261,7 @@ describe("useServiceSkills", () => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + vi.mocked(getAutomationJobs).mockResolvedValue([]); window.localStorage.clear(); }); @@ -223,6 +275,8 @@ describe("useServiceSkills", () => { }); it("目录更新事件后应刷新技能列表并保留分组元数据", async () => { + vi.mocked(getAutomationJobs).mockResolvedValue([buildAutomationJob()]); + const harness = mountHook(); try { diff --git a/src/components/agent/chat/utils/saveSceneAppExecutionAsInspiration.ts b/src/components/agent/chat/utils/saveSceneAppExecutionAsInspiration.ts index 5248ebb90..31b5b53f0 100644 --- a/src/components/agent/chat/utils/saveSceneAppExecutionAsInspiration.ts +++ b/src/components/agent/chat/utils/saveSceneAppExecutionAsInspiration.ts @@ -3,7 +3,7 @@ import { createUnifiedMemory } from "@/lib/api/unifiedMemory"; import type { SceneAppExecutionSummaryViewModel, SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; +} from "@/lib/agent/legacySceneAppExecutionSummary"; import type { MemoryPageParams } from "@/types/page"; import { listCuratedTaskRecommendationSignals, diff --git a/src/components/agent/chat/utils/sceneAppCuratedTaskReference.ts b/src/components/agent/chat/utils/sceneAppCuratedTaskReference.ts index 829aec7b0..6a49e5002 100644 --- a/src/components/agent/chat/utils/sceneAppCuratedTaskReference.ts +++ b/src/components/agent/chat/utils/sceneAppCuratedTaskReference.ts @@ -1,7 +1,7 @@ import type { SceneAppExecutionSummaryViewModel, SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; +} from "@/lib/agent/legacySceneAppExecutionSummary"; import { buildCuratedTaskLaunchInputPrefillFromReferenceEntries, extractCuratedTaskReferenceMemoryIds, diff --git a/src/components/agent/chat/utils/sceneAppExecutionInspirationDraft.ts b/src/components/agent/chat/utils/sceneAppExecutionInspirationDraft.ts index b1da13bcf..899abfe67 100644 --- a/src/components/agent/chat/utils/sceneAppExecutionInspirationDraft.ts +++ b/src/components/agent/chat/utils/sceneAppExecutionInspirationDraft.ts @@ -5,7 +5,7 @@ import type { import type { SceneAppExecutionSummaryViewModel, SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; +} from "@/lib/agent/legacySceneAppExecutionSummary"; import type { MemoryPageSection } from "@/types/page"; interface SceneAppExecutionInspirationDraft { @@ -59,9 +59,12 @@ function dedupeItems( function buildTitle( summary: SceneAppExecutionSummaryViewModel, - detailView: SceneAppRunDetailViewModel, + detailView?: SceneAppRunDetailViewModel | null, ): string { - const failureLabel = normalizeOptionalText(detailView.failureSignalLabel); + const failureLabel = normalizeOptionalText( + detailView?.failureSignalLabel || + summary.runtimeBackflow?.topFailureSignalLabel, + ); if (failureLabel) { return truncate(`${summary.title} · ${failureLabel}`, 28); } @@ -71,13 +74,13 @@ function buildTitle( function buildSummary( summary: SceneAppExecutionSummaryViewModel, - detailView: SceneAppRunDetailViewModel, + detailView?: SceneAppRunDetailViewModel | null, ): string { const summaryText = dedupeItems( [ - detailView.deliverySummary, - detailView.nextAction ? `下一步:${detailView.nextAction}` : null, + detailView?.deliverySummary, + detailView?.nextAction ? `下一步:${detailView.nextAction}` : null, summary.scorecardAggregate?.summary, summary.runtimeBackflow?.summary, summary.summary, @@ -90,31 +93,47 @@ function buildSummary( function buildContent( summary: SceneAppExecutionSummaryViewModel, - detailView: SceneAppRunDetailViewModel, + detailView?: SceneAppRunDetailViewModel | null, ): string { const lines = dedupeItems( [ `场景:${summary.title}`, `业务类型:${summary.businessLabel} / ${summary.typeLabel}`, `结果摘要:${summary.summary}`, - `当前交付:${detailView.deliverySummary}`, - `建议下一步:${detailView.nextAction}`, - detailView.deliveryMissingParts.length > 0 + detailView?.deliverySummary + ? `当前交付:${detailView.deliverySummary}` + : summary.runtimeBackflow?.deliveryCompletionLabel + ? `当前交付:${summary.runtimeBackflow.deliveryCompletionLabel}` + : null, + detailView?.nextAction + ? `建议下一步:${detailView.nextAction}` + : summary.runtimeBackflow?.nextAction + ? `建议下一步:${summary.runtimeBackflow.nextAction}` + : summary.scorecardAggregate?.nextAction + ? `建议下一步:${summary.scorecardAggregate.nextAction}` + : null, + detailView?.deliveryMissingParts.length ? `待补部件:${detailView.deliveryMissingParts .map((item) => item.label) .join("、")}` - : null, - detailView.failureSignalLabel + : summary.runtimeBackflow?.deliveryMissingParts.length + ? `待补部件:${summary.runtimeBackflow.deliveryMissingParts + .map((item) => item.label) + .join("、")}` + : null, + detailView?.failureSignalLabel ? `当前信号:${detailView.failureSignalLabel}` - : null, + : summary.runtimeBackflow?.topFailureSignalLabel + ? `当前信号:${summary.runtimeBackflow.topFailureSignalLabel}` + : null, `执行主链:${summary.executionChainLabel}`, summary.referenceCount > 0 ? `当前带入:${summary.referenceCount} 条参考对象` : "当前带入:本轮主要依赖当前输入与项目上下文", summary.tasteSummary ? `风格摘要:${summary.tasteSummary}` : null, - detailView.contextBaseline?.feedbackSummary || summary.feedbackSummary + detailView?.contextBaseline?.feedbackSummary || summary.feedbackSummary ? `最近反馈:${ - detailView.contextBaseline?.feedbackSummary || + detailView?.contextBaseline?.feedbackSummary || summary.feedbackSummary }` : null, @@ -133,13 +152,13 @@ function buildContent( function buildTags( summary: SceneAppExecutionSummaryViewModel, - detailView: SceneAppRunDetailViewModel, + detailView?: SceneAppRunDetailViewModel | null, ): string[] { return dedupeItems([ summary.businessLabel, summary.typeLabel, summary.deliveryContractLabel, - detailView.failureSignalLabel, + detailView?.failureSignalLabel, summary.runtimeBackflow?.topFailureSignalLabel, summary.scorecardAggregate?.statusLabel, ]); @@ -150,7 +169,7 @@ export function buildSceneAppExecutionInspirationDraft( detailView?: SceneAppRunDetailViewModel | null, options: BuildSceneAppExecutionInspirationDraftOptions = {}, ): SceneAppExecutionInspirationDraft | null { - if (!summary || !detailView) { + if (!summary) { return null; } @@ -165,7 +184,8 @@ export function buildSceneAppExecutionInspirationDraft( request: { session_id: normalizeOptionalText(options.sessionId) || - normalizeOptionalText(detailView.runId) || + normalizeOptionalText(detailView?.runId) || + normalizeOptionalText(summary.runtimeBackflow?.runId) || normalizeOptionalText(summary.sceneappId) || title, title, diff --git a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts b/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts deleted file mode 100644 index a8218acfb..000000000 --- a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildSceneAppExecutionPromptActionPayload } from "./sceneAppExecutionPromptContinuation"; - -describe("sceneAppExecutionPromptContinuation", () => { - it("发布类动作应切回对应的 builtin command capability", () => { - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "publish_check", - label: "发布前检查", - helperText: "检查当前发布风险。", - prompt: "@发布合规 请基于当前结果做发布前检查。", - tone: "neutral", - }, - }), - ).toEqual({ - prompt: "@发布合规 请基于当前结果做发布前检查。", - bannerMessage: "已切到“发布前检查”这条下一步,可继续改写后发送。", - capabilityRoute: { - kind: "builtin_command", - commandKey: "publish_compliance", - commandPrefix: "@发布合规", - }, - }); - - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "publish_prepare", - label: "进入发布整理", - helperText: "整理最终发布稿。", - prompt: "@发布 请基于当前结果整理最终发布稿。", - tone: "positive", - }, - }), - ).toEqual({ - prompt: "@发布 请基于当前结果整理最终发布稿。", - bannerMessage: "已切到“进入发布整理”这条下一步,可继续改写后发送。", - capabilityRoute: { - kind: "builtin_command", - commandKey: "publish_runtime", - commandPrefix: "@发布", - }, - }); - - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "channel_preview", - label: "生成渠道预览稿", - helperText: "预览首屏和封面。", - prompt: "@渠道预览 请基于当前结果生成渠道预览稿。", - tone: "neutral", - }, - }), - ).toEqual({ - prompt: "@渠道预览 请基于当前结果生成渠道预览稿。", - bannerMessage: "已切到“生成渠道预览稿”这条下一步,可继续改写后发送。", - capabilityRoute: { - kind: "builtin_command", - commandKey: "channel_preview_runtime", - commandPrefix: "@渠道预览", - }, - }); - - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "upload_prepare", - label: "整理上传稿", - helperText: "整理上传前素材。", - prompt: "@上传 请基于当前结果整理上传稿。", - tone: "positive", - }, - }), - ).toEqual({ - prompt: "@上传 请基于当前结果整理上传稿。", - bannerMessage: "已切到“整理上传稿”这条下一步,可继续改写后发送。", - capabilityRoute: { - kind: "builtin_command", - commandKey: "upload_runtime", - commandPrefix: "@上传", - }, - }); - }); - - it("补齐缺失部件在有 linked scene key 时应切回 runtime scene capability", () => { - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "fill_missing_parts", - label: "补齐缺失部件", - helperText: "继续补齐缺件。", - prompt: "请基于当前结果继续补齐缺失部件。", - tone: "warning", - }, - summary: { - sceneappId: "story-video-suite", - title: "短视频编排", - summary: "当前结果已进入补件阶段。", - businessLabel: "内容闭环", - typeLabel: "多模态组合", - executionChainLabel: "做法 -> 生成 -> Project Pack", - deliveryContractLabel: "Project Pack", - planningStatusLabel: "已就绪", - planningSummary: "已装配上下文。", - activeLayers: [], - referenceCount: 0, - referenceItems: [], - projectPackPlan: null, - scorecardMetricKeys: [], - scorecardFailureSignals: [], - notes: [], - descriptorSnapshot: { - deliveryContract: "project_pack", - linkedSceneKey: "story-video-suite", - }, - runtimeBackflow: null, - }, - }), - ).toEqual({ - prompt: "请基于当前结果继续补齐缺失部件。", - bannerMessage: "已切到“补齐缺失部件”这条下一步,可继续改写后发送。", - capabilityRoute: { - kind: "runtime_scene", - sceneKey: "story-video-suite", - commandPrefix: "/story-video-suite", - }, - }); - }); - - it("补齐缺失部件在缺少 scene key 时应继续回退到 prompt-only", () => { - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "fill_missing_parts", - label: "补齐缺失部件", - helperText: "继续补齐缺件。", - prompt: "请基于当前结果继续补齐缺失部件。", - tone: "warning", - }, - }), - ).toEqual({ - prompt: "请基于当前结果继续补齐缺失部件。", - bannerMessage: "已切到“补齐缺失部件”这条下一步,可继续改写后发送。", - }); - }); - - it("空 prompt 应返回空结果", () => { - expect( - buildSceneAppExecutionPromptActionPayload({ - action: { - key: "publish_check", - label: "发布前检查", - helperText: "检查当前发布风险。", - prompt: " ", - tone: "neutral", - }, - }), - ).toBeNull(); - }); -}); diff --git a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts b/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts deleted file mode 100644 index bb354d877..000000000 --- a/src/components/agent/chat/utils/sceneAppExecutionPromptContinuation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { - SceneAppExecutionPromptAction, - SceneAppExecutionSummaryViewModel, - SceneAppRunDetailViewModel, -} from "@/lib/sceneapp"; -import type { InputCapabilitySendRoute } from "../skill-selection/inputCapabilitySelection"; -import type { GeneralWorkbenchFollowUpActionPayload } from "../components/generalWorkbenchSidebarContract"; - -function resolveSceneAppExecutionRuntimeSceneKey(params: { - summary?: SceneAppExecutionSummaryViewModel | null; - detailView?: SceneAppRunDetailViewModel | null; -}): string | undefined { - const linkedSceneKey = - params.summary?.descriptorSnapshot?.linkedSceneKey?.trim() || ""; - if (linkedSceneKey) { - return linkedSceneKey; - } - - const entryAction = params.detailView?.entryAction; - if (entryAction?.kind === "open_service_scene_session") { - const sceneKey = entryAction.serviceSceneRuntimeRef.sceneKey?.trim() || ""; - if (sceneKey) { - return sceneKey; - } - } - - const sceneappId = params.summary?.sceneappId?.trim() || ""; - return sceneappId || undefined; -} - -function resolveSceneAppExecutionPromptActionCapabilityRoute(params: { - action: SceneAppExecutionPromptAction; - summary?: SceneAppExecutionSummaryViewModel | null; - detailView?: SceneAppRunDetailViewModel | null; -}): InputCapabilitySendRoute | undefined { - const { action } = params; - - switch (action.key) { - case "publish_check": - return { - kind: "builtin_command", - commandKey: "publish_compliance", - commandPrefix: "@发布合规", - }; - case "publish_prepare": - return { - kind: "builtin_command", - commandKey: "publish_runtime", - commandPrefix: "@发布", - }; - case "channel_preview": - return { - kind: "builtin_command", - commandKey: "channel_preview_runtime", - commandPrefix: "@渠道预览", - }; - case "upload_prepare": - return { - kind: "builtin_command", - commandKey: "upload_runtime", - commandPrefix: "@上传", - }; - case "fill_missing_parts": { - const sceneKey = resolveSceneAppExecutionRuntimeSceneKey(params); - if (!sceneKey) { - return undefined; - } - - return { - kind: "runtime_scene", - sceneKey, - commandPrefix: `/${sceneKey}`, - }; - } - default: - return undefined; - } -} - -export function buildSceneAppExecutionPromptActionPayload(params: { - action: SceneAppExecutionPromptAction; - summary?: SceneAppExecutionSummaryViewModel | null; - detailView?: SceneAppRunDetailViewModel | null; -}): GeneralWorkbenchFollowUpActionPayload | null { - const prompt = params.action.prompt.trim(); - if (!prompt) { - return null; - } - - const capabilityRoute = - resolveSceneAppExecutionPromptActionCapabilityRoute(params); - - return { - prompt, - bannerMessage: `已切到“${params.action.label}”这条下一步,可继续改写后发送。`, - ...(capabilityRoute - ? { - capabilityRoute, - } - : {}), - }; -} diff --git a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts index 69f99a005..ec8779142 100644 --- a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts +++ b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { SceneAppExecutionSummaryViewModel, SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; +} from "@/lib/agent/legacySceneAppExecutionSummary"; import { buildSkillsPageParamsFromSceneAppExecution } from "./sceneAppSkillScaffoldDraft"; function createSummary(): SceneAppExecutionSummaryViewModel { @@ -180,12 +180,30 @@ describe("buildSkillsPageParamsFromSceneAppExecution", () => { ); }); - it("缺少结果明细时不应生成技能草稿", () => { + it("缺少结果明细时应基于历史摘要生成技能草稿", () => { + vi.spyOn(Date, "now").mockReturnValue(22334455); + const result = buildSkillsPageParamsFromSceneAppExecution( createSummary(), - null, + { + projectId: "project-summary-only", + }, ); - expect(result).toBeNull(); + expect(result).toMatchObject({ + creationProjectId: "project-summary-only", + initialScaffoldRequestKey: 22334455, + initialScaffoldDraft: { + target: "project", + name: "短视频编排复用做法", + sourceMessageId: "sceneapp-run-story-video-suite", + }, + }); + expect(result?.initialScaffoldDraft?.outputs).toEqual( + expect.arrayContaining([ + "结果状态参考:按必含部件判断整包完成度", + "后续建议:当前已经带入 2 条参考与 1 条风格偏好,可直接进入生成。", + ]), + ); }); }); diff --git a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts index 62465ad3d..8d03676ed 100644 --- a/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts +++ b/src/components/agent/chat/utils/sceneAppSkillScaffoldDraft.ts @@ -1,7 +1,7 @@ import type { SceneAppExecutionSummaryViewModel, SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; +} from "@/lib/agent/legacySceneAppExecutionSummary"; import type { SkillsPageParams } from "@/types/page"; import type { CuratedTaskRecommendationSignal } from "./curatedTaskRecommendationSignals"; @@ -46,7 +46,7 @@ function dedupeItems( function buildDirectorySlug( summary: SceneAppExecutionSummaryViewModel, - detailView: SceneAppRunDetailViewModel, + detailView?: SceneAppRunDetailViewModel | null, ): string { const titleSeed = summary.title .normalize("NFKD") @@ -55,7 +55,11 @@ function buildDirectorySlug( .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 24); - const runSeed = detailView.runId + const runSeed = ( + detailView?.runId || + summary.runtimeBackflow?.runId || + summary.sceneappId + ) .toLowerCase() .replace(/[^a-z0-9]+/g, "") .slice(-8); @@ -84,31 +88,59 @@ function buildReviewDecisionSnippet( export function buildSkillsPageParamsFromSceneAppExecution( summary?: SceneAppExecutionSummaryViewModel | null, - detailView?: SceneAppRunDetailViewModel | null, + detailOrOptions?: + | SceneAppRunDetailViewModel + | BuildSkillsPageParamsFromSceneAppExecutionOptions + | null, options: BuildSkillsPageParamsFromSceneAppExecutionOptions = {}, ): SkillsPageParams | null { - if (!summary || !detailView) { + if (!summary) { return null; } - const projectId = normalizeOptionalText(options.projectId); - const reviewSnippet = buildReviewDecisionSnippet(options.reviewSignal); + const detailView = + detailOrOptions && "runId" in detailOrOptions ? detailOrOptions : null; + const resolvedOptions = + detailOrOptions && !("runId" in detailOrOptions) + ? detailOrOptions + : options; + const projectId = normalizeOptionalText(resolvedOptions.projectId); + const reviewSnippet = buildReviewDecisionSnippet( + resolvedOptions.reviewSignal, + ); const summarySnippet = truncate(summary.summary, 72); - const deliverySnippet = truncate(detailView.deliverySummary, 72); - const nextActionSnippet = truncate(detailView.nextAction, 72); + const deliverySnippet = truncate( + detailView?.deliverySummary || + summary.runtimeBackflow?.deliveryCompletionLabel || + summary.projectPackPlan?.completionStrategyLabel || + summary.deliveryContractLabel, + 72, + ); + const nextActionSnippet = truncate( + detailView?.nextAction || + summary.runtimeBackflow?.nextAction || + summary.scorecardAggregate?.nextAction || + summary.planningSummary, + 72, + ); const tasteSnippet = normalizeOptionalText(summary.tasteSummary); const feedbackSnippet = - normalizeOptionalText(detailView.contextBaseline?.feedbackSummary) || + normalizeOptionalText(detailView?.contextBaseline?.feedbackSummary) || normalizeOptionalText(summary.feedbackSummary); - const missingPartsLabel = detailView.deliveryMissingParts + const missingPartsLabel = ( + detailView?.deliveryMissingParts ?? + summary.runtimeBackflow?.deliveryMissingParts ?? + [] + ) .map((item) => normalizeOptionalText(item.label)) .filter((item): item is string => Boolean(item)) .join("、"); const failureSignalLabel = normalizeOptionalText( - detailView.failureSignalLabel, + detailView?.failureSignalLabel || + summary.runtimeBackflow?.topFailureSignalLabel, ); const reviewSignalSummary = normalizeOptionalText( - options.reviewSignal?.summary, + resolvedOptions.reviewSignal?.summary, ); const name = truncate(`${summary.title}复用做法`, 24); @@ -182,7 +214,9 @@ export function buildSkillsPageParamsFromSceneAppExecution( ? `如果最近人工判断提出新的约束,优先按“${truncate(reviewSignalSummary, 72)}”处理后再继续复用。` : "如果结果再次偏离目标,先重新确认受众、平台与参考约束,再决定是否沿用整套做法。", ]), - sourceMessageId: `sceneapp-run-${detailView.runId}`, + sourceMessageId: `sceneapp-run-${ + detailView?.runId || summary.runtimeBackflow?.runId || summary.sceneappId + }`, sourceExcerpt, }, initialScaffoldRequestKey: Date.now(), diff --git a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx index 6b24ff397..88dc1c558 100644 --- a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx +++ b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx @@ -6,107 +6,6 @@ import { SceneAppExecutionSummaryCard } from "./SceneAppExecutionSummaryCard"; import { changeLimeLocale } from "@/i18n/createI18n"; const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; -type SceneAppExecutionDetailView = NonNullable< - React.ComponentProps< - typeof SceneAppExecutionSummaryCard - >["latestPackResultDetailView"] ->; - -function createLatestPackResultDetailView( - overrides: Partial = {}, -): SceneAppExecutionDetailView { - return { - runId: "run-fallback", - status: "success", - statusLabel: "成功", - stageLabel: "结果已交付", - summary: "最近一轮样本已经回流了可继续消费的结果文件。", - nextAction: "继续进入编辑或发布。", - sourceLabel: "人工试跑", - artifactCount: 2, - deliveryCompletionLabel: "整包已交齐 2/2 个部件", - deliverySummary: "当前结果包已完整回流。", - deliveryRequiredParts: [ - { key: "brief", label: "任务简报" }, - { key: "storyboard", label: "分镜 / 线框图" }, - ], - deliveryCompletedParts: [ - { key: "brief", label: "任务简报" }, - { key: "storyboard", label: "分镜 / 线框图" }, - ], - deliveryMissingParts: [], - deliveryPartCoverageKnown: true, - deliveryViewerLabel: "结果包查看", - packCompletionStrategyLabel: "按必含部件判断整包完成度", - packViewerLabel: "结果包查看", - plannedDeliveryRequiredParts: [ - { key: "brief", label: "任务简报" }, - { key: "storyboard", label: "分镜 / 线框图" }, - ], - packPlanNotes: ["继续沿当前样本复用。"], - contextBaseline: null, - deliveryArtifactEntries: [ - { - key: "brief-0", - label: "主稿 · 任务简报", - pathLabel: "packs/run-fallback/brief.md", - helperText: "直接打开这次运行已回流的结果文件。", - isPrimary: true, - artifactRef: { - partKey: "brief", - relativePath: "packs/run-fallback/brief.md", - absolutePath: "/tmp/packs/run-fallback/brief.md", - projectId: "project-1", - source: "runtime_evidence", - }, - }, - ], - governanceActionEntries: [ - { - key: "weekly-review-pack", - label: "补结果材料", - helperText: "把证据摘要和人工复核记录一起带回来回看业务结果。", - primaryArtifactKind: "review_decision_markdown", - primaryArtifactLabel: "人工复核记录", - artifactKinds: ["evidence_summary", "review_decision_markdown"], - }, - ], - governanceArtifactEntries: [ - { - key: "evidence_summary:.lime/harness/sessions/session-1/evidence/summary.md", - label: "证据摘要", - pathLabel: ".lime/harness/sessions/session-1/evidence/summary.md", - helperText: "查看当前运行对应的证据摘要。", - artifactRef: { - kind: "evidence_summary", - label: "证据摘要", - relativePath: ".lime/harness/sessions/session-1/evidence/summary.md", - absolutePath: "/tmp/summary.md", - projectId: "project-1", - workspaceId: "project-1", - source: "session_governance", - }, - }, - ], - failureSignalLabel: undefined, - evidenceSourceLabel: "当前已接入会话证据", - requestTelemetryLabel: "已关联请求遥测。", - artifactValidatorLabel: "Artifact 校验没有发现阻塞问题。", - evidenceKnownGaps: [], - verificationFailureOutcomes: [], - startedAtLabel: "2026-04-16 12:00", - finishedAtLabel: "2026-04-16 12:03", - durationLabel: "3 分钟", - entryAction: { - kind: "open_agent_session", - label: "恢复对应 Agent 会话", - helperText: "回到底层执行会话继续看完整上下文。", - sessionId: "session-1", - }, - ...overrides, - }; -} - function renderCard( props: Partial< React.ComponentProps @@ -378,8 +277,7 @@ describe("SceneAppExecutionSummaryCard", () => { ); }); - it("应在生成里展示最近可消费结果并支持打开文件", () => { - const onDeliveryArtifactAction = vi.fn(); + it("应只展示历史摘要、继续动作和当前发布产物", () => { const onReviewCurrentProject = vi.fn(); const onSaveAsInspiration = vi.fn(); const onSaveAsSkill = vi.fn(); @@ -387,14 +285,8 @@ describe("SceneAppExecutionSummaryCard", () => { const onOpenSceneAppGovernance = vi.fn(); const onOpenHumanReview = vi.fn(); const onApplyQuickReview = vi.fn(); - const onGovernanceAction = vi.fn(); - const onGovernanceArtifactAction = vi.fn(); - const onEntryAction = vi.fn(); const onContentPostAction = vi.fn(); - const onPromptAction = vi.fn(); const container = renderCard({ - latestPackResultDetailView: createLatestPackResultDetailView(), - latestPackResultUsesFallback: true, onReviewCurrentProject, onSaveAsInspiration, onSaveAsSkill, @@ -411,10 +303,6 @@ describe("SceneAppExecutionSummaryCard", () => { tone: "positive", }, ], - onDeliveryArtifactAction, - onGovernanceAction, - onGovernanceArtifactAction, - onEntryAction, contentPostEntries: [ { key: "publish", @@ -478,27 +366,25 @@ describe("SceneAppExecutionSummaryCard", () => { }, ], onContentPostAction, - onPromptAction, }); expect( container.querySelector( '[data-testid="sceneapp-execution-summary-runtime-pack"]', - )?.textContent, - ).toContain("最近可消费结果"); - expect(container.textContent).toContain("最近一轮已交付样本"); + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="sceneapp-execution-summary-orchestration"]', + ), + ).toBeNull(); + expect(container.textContent).not.toContain("最近可消费结果"); + expect(container.textContent).not.toContain("同聊推进"); expect( container.querySelector( '[data-testid="sceneapp-execution-summary-followup-actions"]', )?.textContent, ).toContain("继续动作"); - expect( - container.querySelector( - '[data-testid="sceneapp-execution-summary-orchestration"]', - )?.textContent, - ).toContain("结果后的下一步"); - expect(container.textContent).toContain("看结果"); - expect(container.textContent).toContain("同聊推进"); expect( container.querySelector( '[data-testid="sceneapp-execution-summary-content-posts"]', @@ -511,12 +397,6 @@ describe("SceneAppExecutionSummaryCard", () => { expect(container.textContent).toContain("封面信息"); expect(container.textContent).toContain("发布包"); - const button = container.querySelector( - '[data-testid="sceneapp-execution-summary-artifact-entry-brief-0"]', - ); - const detailButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-open-detail"]', - ); const reviewCurrentProjectButton = container.querySelector( '[data-testid="sceneapp-execution-summary-review-current-project"]', ); @@ -526,6 +406,9 @@ describe("SceneAppExecutionSummaryCard", () => { const saveAsSkillButton = container.querySelector( '[data-testid="sceneapp-execution-summary-save-as-skill"]', ); + const detailButton = container.querySelector( + '[data-testid="sceneapp-execution-summary-open-detail"]', + ); const governanceButton = container.querySelector( '[data-testid="sceneapp-execution-summary-open-governance"]', ); @@ -535,34 +418,13 @@ describe("SceneAppExecutionSummaryCard", () => { const quickReviewButton = container.querySelector( '[data-testid="sceneapp-execution-summary-quick-review-accepted"]', ); - const governanceActionButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-governance-action-weekly-review-pack"]', - ); - const governanceArtifactButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-governance-artifact-evidence_summary:.lime/harness/sessions/session-1/evidence/summary.md"]', - ); - const entryActionButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-entry-action"]', - ); const publishArtifactButton = container.querySelector( '[data-testid="sceneapp-execution-summary-content-post-publish"]', ); const previewArtifactButton = container.querySelector( '[data-testid="sceneapp-execution-summary-content-post-preview"]', ); - const publishCheckButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-publish_check"]', - ); - const publishPrepareButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-publish_prepare"]', - ); - const channelPreviewButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-channel_preview"]', - ); - const uploadPrepareButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-upload_prepare"]', - ); - expect(button).not.toBeNull(); + expect(reviewCurrentProjectButton).not.toBeNull(); expect(saveAsInspirationButton).not.toBeNull(); expect(saveAsSkillButton).not.toBeNull(); @@ -570,18 +432,10 @@ describe("SceneAppExecutionSummaryCard", () => { expect(governanceButton).not.toBeNull(); expect(humanReviewButton).not.toBeNull(); expect(quickReviewButton).not.toBeNull(); - expect(governanceActionButton).not.toBeNull(); - expect(governanceArtifactButton).not.toBeNull(); - expect(entryActionButton).not.toBeNull(); expect(publishArtifactButton).not.toBeNull(); expect(previewArtifactButton).not.toBeNull(); - expect(publishCheckButton).not.toBeNull(); - expect(publishPrepareButton).not.toBeNull(); - expect(channelPreviewButton).not.toBeNull(); - expect(uploadPrepareButton).not.toBeNull(); act(() => { - button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); reviewCurrentProjectButton?.dispatchEvent( new MouseEvent("click", { bubbles: true }), ); @@ -601,40 +455,14 @@ describe("SceneAppExecutionSummaryCard", () => { quickReviewButton?.dispatchEvent( new MouseEvent("click", { bubbles: true }), ); - governanceActionButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - governanceArtifactButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - entryActionButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); publishArtifactButton?.dispatchEvent( new MouseEvent("click", { bubbles: true }), ); previewArtifactButton?.dispatchEvent( new MouseEvent("click", { bubbles: true }), ); - publishCheckButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - publishPrepareButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - channelPreviewButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - uploadPrepareButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); }); - expect(onDeliveryArtifactAction).toHaveBeenCalledWith( - expect.objectContaining({ - key: "brief-0", - }), - ); expect(onReviewCurrentProject).toHaveBeenCalledTimes(1); expect(onSaveAsInspiration).toHaveBeenCalledTimes(1); expect(onSaveAsSkill).toHaveBeenCalledTimes(1); @@ -642,62 +470,18 @@ describe("SceneAppExecutionSummaryCard", () => { expect(onOpenSceneAppGovernance).toHaveBeenCalledTimes(1); expect(onOpenHumanReview).toHaveBeenCalledTimes(1); expect(onApplyQuickReview).toHaveBeenCalledWith("accepted"); - expect(onGovernanceAction).toHaveBeenCalledWith( - expect.objectContaining({ - key: "weekly-review-pack", - }), - ); - expect(onGovernanceArtifactAction).toHaveBeenCalledWith( - expect.objectContaining({ - label: "证据摘要", - }), - ); - expect(onEntryAction).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "open_agent_session", - }), - ); expect(onContentPostAction).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ - key: "publish", - }), + expect.objectContaining({ key: "publish" }), ); expect(onContentPostAction).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ - key: "preview", - }), - ); - expect(onPromptAction).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - key: "publish_check", - }), - ); - expect(onPromptAction).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - key: "publish_prepare", - }), - ); - expect(onPromptAction).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - key: "channel_preview", - }), - ); - expect(onPromptAction).toHaveBeenNthCalledWith( - 4, - expect.objectContaining({ - key: "upload_prepare", - }), + expect.objectContaining({ key: "preview" }), ); }); it("结果已沉淀后应把灵感按钮切成已保存状态", () => { const container = renderCard({ - latestPackResultDetailView: createLatestPackResultDetailView(), savedAsInspiration: true, onSaveAsInspiration: vi.fn(), }); @@ -717,7 +501,6 @@ describe("SceneAppExecutionSummaryCard", () => { it("结果已沉淀后应支持直接去灵感库继续", () => { const onOpenInspirationLibrary = vi.fn(); const container = renderCard({ - latestPackResultDetailView: createLatestPackResultDetailView(), savedAsInspiration: true, onSaveAsInspiration: vi.fn(), onOpenInspirationLibrary, @@ -735,190 +518,32 @@ describe("SceneAppExecutionSummaryCard", () => { expect(onOpenInspirationLibrary).toHaveBeenCalledTimes(1); }); - it("缺件时应在同聊推进里禁用进入发布整理并提示阻塞原因", () => { - const onPromptAction = vi.fn(); + it("不应恢复旧运行详情编排入口", () => { const container = renderCard({ - latestPackResultDetailView: createLatestPackResultDetailView({ - deliveryMissingParts: [{ key: "cover", label: "封面图" }], - deliveryCompletedParts: [{ key: "brief", label: "任务简报" }], - deliveryCompletionLabel: "整包已交付 1/2 个部件", - deliverySummary: "当前结果包还缺封面图。", - }), - onPromptAction, + onReviewCurrentProject: vi.fn(), + onSaveAsInspiration: vi.fn(), }); - const fillMissingPartsButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-fill_missing_parts"]', - ) as HTMLButtonElement | null; - const publishPrepareButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-publish_prepare"]', - ) as HTMLButtonElement | null; - const channelPreviewButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-channel_preview"]', - ) as HTMLButtonElement | null; - const uploadPrepareButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-prompt-action-upload_prepare"]', - ) as HTMLButtonElement | null; - - expect(fillMissingPartsButton).not.toBeNull(); - expect(fillMissingPartsButton?.disabled).toBe(false); - expect(publishPrepareButton).not.toBeNull(); - expect(publishPrepareButton?.disabled).toBe(true); - expect(channelPreviewButton).not.toBeNull(); - expect(channelPreviewButton?.disabled).toBe(true); - expect(uploadPrepareButton).not.toBeNull(); - expect(uploadPrepareButton?.disabled).toBe(true); - expect(container.textContent).toContain("当前阻塞:当前还缺 封面图"); - - act(() => { - fillMissingPartsButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - }); - - expect(onPromptAction).toHaveBeenCalledWith( - expect.objectContaining({ - key: "fill_missing_parts", - }), - ); - }); - - it("结果去向卡应支持直接续接到看结果、生成、持续流程和主结果", () => { - const onReviewCurrentProject = vi.fn(); - const onGovernanceAction = vi.fn(); - const onEntryAction = vi.fn(); - const onDeliveryArtifactAction = vi.fn(); - const container = renderCard({ - onReviewCurrentProject, - onGovernanceAction, - onEntryAction, - onDeliveryArtifactAction, - latestPackResultDetailView: createLatestPackResultDetailView({ - governanceActionEntries: [ - { - key: "weekly-review-pack", - label: "补结果材料", - helperText: "把证据摘要和人工复核记录一起带回来回看业务结果。", - primaryArtifactKind: "review_decision_markdown", - primaryArtifactLabel: "人工复核记录", - artifactKinds: ["evidence_summary", "review_decision_markdown"], - }, - { - key: "structured-governance-pack", - label: "补结果记录", - helperText: "把结果记录带回生成继续推进下一步。", - primaryArtifactKind: "review_decision_json", - primaryArtifactLabel: "复核 JSON", - artifactKinds: ["review_decision_json"], - }, - ], - governanceArtifactEntries: [ - { - key: "evidence_summary:.lime/harness/sessions/session-1/evidence/summary.md", - label: "证据摘要", - pathLabel: ".lime/harness/sessions/session-1/evidence/summary.md", - helperText: "查看当前运行对应的证据摘要。", - artifactRef: { - kind: "evidence_summary", - label: "证据摘要", - relativePath: - ".lime/harness/sessions/session-1/evidence/summary.md", - absolutePath: "/tmp/summary.md", - projectId: "project-1", - workspaceId: "project-1", - source: "session_governance", - }, - }, - { - key: "review_decision_markdown:.lime/harness/sessions/session-1/review/decision.md", - label: "人工复核记录", - pathLabel: ".lime/harness/sessions/session-1/review/decision.md", - helperText: "查看人工复核记录。", - artifactRef: { - kind: "review_decision_markdown", - label: "人工复核记录", - relativePath: - ".lime/harness/sessions/session-1/review/decision.md", - absolutePath: "/tmp/decision.md", - projectId: "project-1", - workspaceId: "project-1", - source: "session_governance", - }, - }, - { - key: "review_decision_json:.lime/harness/sessions/session-1/review/decision.json", - label: "复核 JSON", - pathLabel: ".lime/harness/sessions/session-1/review/decision.json", - helperText: "查看结构化复盘记录。", - artifactRef: { - kind: "review_decision_json", - label: "复核 JSON", - relativePath: - ".lime/harness/sessions/session-1/review/decision.json", - absolutePath: "/tmp/decision.json", - projectId: "project-1", - workspaceId: "project-1", - source: "session_governance", - }, - }, - ], - entryAction: { - kind: "open_automation_job", - label: "查看持续流程", - helperText: "跳到当前持续任务查看调度与结果。", - jobId: "automation-job-1", - }, - }), - }); - - const weeklyReviewButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-destination-action-weekly-review"]', - ); - const taskCenterButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-destination-action-task-center"]', - ); - const automationButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-destination-action-automation-job"]', - ); - const deliveryButton = container.querySelector( - '[data-testid="sceneapp-execution-summary-destination-action-delivery-editing"]', - ); - - expect(weeklyReviewButton).not.toBeNull(); - expect(taskCenterButton).not.toBeNull(); - expect(automationButton).not.toBeNull(); - expect(deliveryButton).not.toBeNull(); - - act(() => { - weeklyReviewButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - taskCenterButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - automationButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - deliveryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(onGovernanceAction).toHaveBeenCalledWith( - expect.objectContaining({ - key: "weekly-review-pack", - }), - ); - expect(onReviewCurrentProject).toHaveBeenCalledTimes(1); - expect(onEntryAction).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "open_automation_job", - jobId: "automation-job-1", - }), - ); - expect(onDeliveryArtifactAction).toHaveBeenCalledWith( - expect.objectContaining({ - key: "brief-0", - }), - ); + expect( + container.querySelector( + '[data-testid="sceneapp-execution-summary-runtime-pack"]', + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid="sceneapp-execution-summary-orchestration"]', + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid^="sceneapp-execution-summary-prompt-action-"]', + ), + ).toBeNull(); + expect( + container.querySelector( + '[data-testid^="sceneapp-execution-summary-destination-action-"]', + ), + ).toBeNull(); }); it("没有摘要时不应渲染卡片", () => { diff --git a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx index 04c1d0154..3184a9826 100644 --- a/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx +++ b/src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx @@ -3,19 +3,9 @@ import type { SceneAppContextReferenceItemViewModel, SceneAppDeliveryPartViewModel, SceneAppExecutionSummaryViewModel, - SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; + SceneAppQuickReviewAction, +} from "@/lib/agent/legacySceneAppExecutionSummary"; import { Button } from "@/components/ui/button"; -import { SceneAppProjectPackRuntimePanel } from "@/components/sceneapps/SceneAppProjectPackRuntimePanel"; -import { - buildSceneAppExecutionFollowupDestinations, - type SceneAppExecutionFollowupDestination, -} from "@/components/sceneapps/sceneAppExecutionFollowupDestinations"; -import { - buildSceneAppExecutionPromptActions, - type SceneAppExecutionPromptAction, - type SceneAppQuickReviewAction, -} from "@/lib/sceneapp"; import type { SceneAppExecutionContentPostEntry } from "./sceneAppExecutionContentPosts"; import { buildReviewFeedbackProjectionCopy, @@ -27,9 +17,6 @@ import type { CuratedTaskRecommendationSignal } from "../utils/curatedTaskRecomm interface SceneAppExecutionSummaryCardProps { summary?: SceneAppExecutionSummaryViewModel | null; - latestPackResultDetailView?: SceneAppRunDetailViewModel | null; - latestPackResultLoading?: boolean; - latestPackResultUsesFallback?: boolean; latestReviewFeedbackSignal?: CuratedTaskRecommendationSignal | null; onContinueReviewFeedback?: (taskId: string) => void; onReviewCurrentProject?: () => void; @@ -45,22 +32,8 @@ interface SceneAppExecutionSummaryCardProps { quickReviewPending?: boolean; onOpenHumanReview?: () => void; onApplyQuickReview?: (actionKey: SceneAppQuickReviewAction["key"]) => void; - onDeliveryArtifactAction?: ( - action: SceneAppRunDetailViewModel["deliveryArtifactEntries"][number], - ) => void; - onGovernanceAction?: ( - action: SceneAppRunDetailViewModel["governanceActionEntries"][number], - ) => void; - onGovernanceArtifactAction?: ( - action: SceneAppRunDetailViewModel["governanceArtifactEntries"][number], - ) => void; - onEntryAction?: ( - action: NonNullable, - ) => void; contentPostEntries?: SceneAppExecutionContentPostEntry[]; onContentPostAction?: (entry: SceneAppExecutionContentPostEntry) => void; - promptActionPending?: boolean; - onPromptAction?: (action: SceneAppExecutionPromptAction) => void; } function ReviewFeedbackProjectionBanner({ @@ -199,25 +172,6 @@ function resolveAggregateToneClass( } } -function resolvePromptActionToneClass( - tone: SceneAppExecutionPromptAction["tone"], - disabled: boolean, -): string { - if (disabled) { - return "border-slate-200 bg-slate-100 text-slate-400"; - } - - switch (tone) { - case "positive": - return "border-lime-200 bg-lime-50/80 text-slate-900 hover:border-lime-300 hover:bg-white"; - case "warning": - return "border-amber-200 bg-amber-50/80 text-slate-900 hover:border-amber-300 hover:bg-white"; - case "neutral": - default: - return "border-slate-200 bg-white text-slate-900 hover:border-slate-300 hover:bg-slate-50"; - } -} - function resolveContentPostReadinessToneClass( tone: SceneAppExecutionContentPostEntry["readinessTone"], ): string { @@ -234,9 +188,6 @@ function resolveContentPostReadinessToneClass( export function SceneAppExecutionSummaryCard({ summary, - latestPackResultDetailView = null, - latestPackResultLoading = false, - latestPackResultUsesFallback = false, latestReviewFeedbackSignal = null, onContinueReviewFeedback, onReviewCurrentProject, @@ -252,14 +203,8 @@ export function SceneAppExecutionSummaryCard({ quickReviewPending = false, onOpenHumanReview, onApplyQuickReview, - onDeliveryArtifactAction, - onGovernanceAction, - onGovernanceArtifactAction, - onEntryAction, contentPostEntries = [], onContentPostAction, - promptActionPending = false, - onPromptAction, }: SceneAppExecutionSummaryCardProps) { const { t } = useTranslation("agent"); @@ -272,12 +217,6 @@ export function SceneAppExecutionSummaryCard({ signal: latestReviewFeedbackSignal, }); - const followupDestinations = latestPackResultDetailView - ? buildSceneAppExecutionFollowupDestinations(latestPackResultDetailView) - : []; - const promptActions = latestPackResultDetailView - ? buildSceneAppExecutionPromptActions(latestPackResultDetailView) - : []; const deliveryContractLabel = summary.projectPackPlan?.packKindLabel ?? summary.deliveryContractLabel; const deliveryDestinationLabel = @@ -300,56 +239,8 @@ export function SceneAppExecutionSummaryCard({ onOpenSceneAppGovernance || humanReviewAvailable || quickReviewActions.length || - latestPackResultDetailView, + contentPostEntries.length, ); - const resolveFollowupDestinationAction = ( - destination: SceneAppExecutionFollowupDestination, - ): { label: string; onClick: () => void } | null => { - const action = destination.action; - if (!action) { - return null; - } - - switch (action.kind) { - case "review_current_project": - return onReviewCurrentProject - ? { - label: action.label, - onClick: onReviewCurrentProject, - } - : null; - case "governance_action": - return onGovernanceAction - ? { - label: action.label, - onClick: () => onGovernanceAction(action.entry), - } - : null; - case "governance_artifact": - return onGovernanceArtifactAction - ? { - label: action.label, - onClick: () => onGovernanceArtifactAction(action.entry), - } - : null; - case "entry_action": - return onEntryAction - ? { - label: action.label, - onClick: () => onEntryAction(action.entry), - } - : null; - case "delivery_artifact": - return onDeliveryArtifactAction - ? { - label: action.label, - onClick: () => onDeliveryArtifactAction(action.entry), - } - : null; - default: - return null; - } - }; return (
    - - {hasFollowupSection ? (
    ) : null} - {latestPackResultUsesFallback ? ( -

    - {t("sceneAppExecutionSummary.followup.fallbackNotice")} -

    - ) : null} {humanReviewAvailable && quickReviewActions.length ? (
    @@ -896,247 +770,62 @@ export function SceneAppExecutionSummaryCard({
    ) : null} - {latestPackResultDetailView && - (followupDestinations.length || - promptActions.length || - latestPackResultDetailView.governanceActionEntries.length || - latestPackResultDetailView.governanceArtifactEntries.length || - latestPackResultDetailView.entryAction) ? ( + {contentPostEntries.length ? (
    - {t("sceneAppExecutionSummary.orchestration.title")} + {t("sceneAppExecutionSummary.contentPosts.title")}

    - {t("sceneAppExecutionSummary.orchestration.description")} + {t("sceneAppExecutionSummary.contentPosts.description")}

    - - {followupDestinations.length ? ( -
    - {followupDestinations.map((item) => { - const destinationAction = - resolveFollowupDestinationAction(item); - - return ( -
    + {contentPostEntries.map((entry) => ( + -
    - ) : null} - - ); - })} -
    - ) : null} - - {latestPackResultDetailView.governanceActionEntries.length || - promptActions.length || - latestPackResultDetailView.entryAction ? ( -
    -
    - {t("sceneAppExecutionSummary.orchestration.recommended")} -
    -
    - {latestPackResultDetailView.governanceActionEntries.map( - (entry) => ( - - ), - )} - - {latestPackResultDetailView.entryAction ? ( - - ) : null} -
    -
    - ) : null} - - {promptActions.length ? ( -
    -
    - {t("sceneAppExecutionSummary.orchestration.chat")} -
    -
    - {promptActions.map((action) => { - const disabled = - promptActionPending || Boolean(action.disabledReason); - - return ( - - ); - })} -
    -
    - ) : null} - - {contentPostEntries.length ? ( -
    -
    - {t("sceneAppExecutionSummary.contentPosts.title")} -
    -

    - {t("sceneAppExecutionSummary.contentPosts.description")} -

    -
    - {contentPostEntries.map((entry) => ( - - ))} -
    -
    - ) : null} - - {latestPackResultDetailView.governanceArtifactEntries.length ? ( -
    -
    - {t("sceneAppExecutionSummary.orchestration.artifacts")} -
    -
    - {latestPackResultDetailView.governanceArtifactEntries.map( - (entry) => ( - - ), - )} -
    -
    - ) : null} + ))} +
    + ) : null} +
    + {entry.pathLabel} +
    + + ))} +
    ) : null} diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx index 383abece1..e83e59262 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx @@ -143,13 +143,6 @@ function renderScene( onRefreshSkills: vi.fn(), onLaunchBrowserAssist: vi.fn(), browserAssistLoading: false, - featuredSceneApps: [], - sceneAppsLoading: false, - sceneAppLaunchingId: null, - onLaunchSceneApp: vi.fn(), - canResumeRecentSceneApp: false, - onResumeRecentSceneApp: vi.fn(), - onOpenSceneAppsDirectory: vi.fn(), projectId: null, onProjectChange: vi.fn(), onOpenSettings: vi.fn(), diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index b9273f53d..dbd167017 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -337,18 +337,6 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { typeof EmptyState >["onLaunchBrowserAssist"]; browserAssistLoading: boolean; - featuredSceneApps: NonNullable< - ComponentProps["featuredSceneApps"] - >; - sceneAppsLoading: boolean; - sceneAppLaunchingId: ComponentProps["sceneAppLaunchingId"]; - onLaunchSceneApp?: ComponentProps["onLaunchSceneApp"]; - canResumeRecentSceneApp?: ComponentProps< - typeof EmptyState - >["canResumeRecentSceneApp"]; - onResumeRecentSceneApp?: ComponentProps< - typeof EmptyState - >["onResumeRecentSceneApp"]; recentSessionTitle?: ComponentProps["recentSessionTitle"]; recentSessionSummary?: ComponentProps< typeof EmptyState @@ -359,9 +347,6 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { onResumeRecentSession?: ComponentProps< typeof EmptyState >["onResumeRecentSession"]; - onOpenSceneAppsDirectory?: ComponentProps< - typeof EmptyState - >["onOpenSceneAppsDirectory"]; projectId: string | null; sessionId?: ComponentProps["sessionId"]; onProjectChange?: ComponentProps["onProjectChange"]; @@ -525,17 +510,10 @@ export function WorkspaceConversationScene({ onRefreshSkills, onLaunchBrowserAssist, browserAssistLoading, - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId, - onLaunchSceneApp, - canResumeRecentSceneApp, - onResumeRecentSceneApp, recentSessionTitle, recentSessionSummary, recentSessionActionLabel, onResumeRecentSession, - onOpenSceneAppsDirectory, projectId, sessionId, onProjectChange, @@ -636,17 +614,10 @@ export function WorkspaceConversationScene({ onRefreshSkills, onLaunchBrowserAssist, browserAssistLoading, - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId, - onLaunchSceneApp, - canResumeRecentSceneApp, - onResumeRecentSceneApp, recentSessionTitle, recentSessionSummary, recentSessionActionLabel, onResumeRecentSession, - onOpenSceneAppsDirectory, projectId, sessionId, runtimeToolAvailability, diff --git a/src/components/agent/chat/workspace/WorkspaceShellScene.test.tsx b/src/components/agent/chat/workspace/WorkspaceShellScene.test.tsx index 7bae28e4e..f1d2d62ac 100644 --- a/src/components/agent/chat/workspace/WorkspaceShellScene.test.tsx +++ b/src/components/agent/chat/workspace/WorkspaceShellScene.test.tsx @@ -83,4 +83,21 @@ describe("WorkspaceShellScene", () => { shell?.querySelector('[data-testid="workspace-main"]'), ).not.toBeNull(); }); + + it("应在主工作区右侧渲染可选信息栏节点", () => { + const container = renderShell({ + rightRailNode: , + }); + + const shell = container.querySelector( + '[data-testid="workspace-shell-scene"]', + ); + + expect( + shell?.querySelector('[data-testid="workspace-main"]'), + ).not.toBeNull(); + expect( + shell?.querySelector('[data-testid="workspace-right-rail"]')?.textContent, + ).toContain("专家信息"); + }); }); diff --git a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx index e95512f4a..0a6f9d3d8 100644 --- a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx @@ -18,6 +18,7 @@ interface WorkspaceShellSceneProps { fileManagerNode?: ReactNode; fileManagerToggleNode?: ReactNode; mainAreaNode: ReactNode; + rightRailNode?: ReactNode; sidebarContextVariant?: ComponentProps["contextVariant"]; currentTopicId: ComponentProps["currentTopicId"]; topics: ComponentProps["topics"]; @@ -68,6 +69,7 @@ export function WorkspaceShellScene({ fileManagerNode, fileManagerToggleNode, mainAreaNode, + rightRailNode, sidebarContextVariant = "default", currentTopicId, topics, @@ -147,6 +149,7 @@ export function WorkspaceShellScene({ {fileManagerToggleNode} {mainAreaNode} + {rightRailNode} ); } diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts index 0df5e2412..8a01221a5 100644 --- a/src/components/agent/chat/workspace/chatSurfaceProps.ts +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -153,18 +153,6 @@ interface BuildWorkspaceEmptyStatePropsParams { typeof EmptyState >["onLaunchBrowserAssist"]; browserAssistLoading: boolean; - featuredSceneApps: NonNullable< - ComponentProps["featuredSceneApps"] - >; - sceneAppsLoading: boolean; - sceneAppLaunchingId: ComponentProps["sceneAppLaunchingId"]; - onLaunchSceneApp?: ComponentProps["onLaunchSceneApp"]; - canResumeRecentSceneApp?: ComponentProps< - typeof EmptyState - >["canResumeRecentSceneApp"]; - onResumeRecentSceneApp?: ComponentProps< - typeof EmptyState - >["onResumeRecentSceneApp"]; recentSessionTitle?: ComponentProps["recentSessionTitle"]; recentSessionSummary?: ComponentProps< typeof EmptyState @@ -175,9 +163,6 @@ interface BuildWorkspaceEmptyStatePropsParams { onResumeRecentSession?: ComponentProps< typeof EmptyState >["onResumeRecentSession"]; - onOpenSceneAppsDirectory?: ComponentProps< - typeof EmptyState - >["onOpenSceneAppsDirectory"]; projectId: string | null; sessionId?: string | null; runtimeToolAvailability?: ComponentProps< @@ -278,17 +263,10 @@ export function buildWorkspaceEmptyStateProps({ onRefreshSkills, onLaunchBrowserAssist, browserAssistLoading, - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId, - onLaunchSceneApp, - canResumeRecentSceneApp, - onResumeRecentSceneApp, recentSessionTitle, recentSessionSummary, recentSessionActionLabel, onResumeRecentSession, - onOpenSceneAppsDirectory, projectId, sessionId, runtimeToolAvailability, @@ -364,17 +342,10 @@ export function buildWorkspaceEmptyStateProps({ onRefreshSkills, onLaunchBrowserAssist, browserAssistLoading, - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId, - onLaunchSceneApp, - canResumeRecentSceneApp, - onResumeRecentSceneApp, recentSessionTitle, recentSessionSummary, recentSessionActionLabel, onResumeRecentSession, - onOpenSceneAppsDirectory, projectId, sessionId, runtimeToolAvailability, diff --git a/src/components/agent/chat/workspace/sceneAppLaunch.test.ts b/src/components/agent/chat/workspace/sceneAppLaunch.test.ts deleted file mode 100644 index 617bea43f..000000000 --- a/src/components/agent/chat/workspace/sceneAppLaunch.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - executeSceneAppLaunchAction, - resolveSceneAppLaunchAction, -} from "./sceneAppLaunch"; -import type { SceneAppPlanResult } from "@/lib/sceneapp"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import { - clearAgentUiProjectionEvents, - conversationProjectionStore, - selectAgentUiProjectionEventsByType, -} from "../projection/conversationProjectionStore"; - -type SceneAppPlanResultOverrides = { - descriptor?: Partial; - readiness?: Partial; - plan?: Partial> & { - adapterPlan?: Partial; - }; -}; - -const mockCreateSceneAppAutomationJob = vi.hoisted(() => vi.fn()); - -vi.mock("@/lib/api/sceneapp", async () => { - const actual = - await vi.importActual( - "@/lib/api/sceneapp", - ); - return { - ...actual, - createSceneAppAutomationJob: mockCreateSceneAppAutomationJob, - }; -}); - -const defaultToolPreferences: ChatToolPreferences = { - webSearch: false, - thinking: false, - task: false, - subagent: false, -}; - -function createPlanResult( - overrides: SceneAppPlanResultOverrides = {}, -): SceneAppPlanResult { - const planOverrides = overrides.plan ?? {}; - const adapterPlanOverrides = planOverrides.adapterPlan ?? {}; - - return { - descriptor: { - id: "sceneapp-demo", - title: "SceneApp Demo", - summary: "SceneApp 执行桥接测试样板。", - category: "测试", - sceneappType: "hybrid", - patternPrimary: "pipeline", - patternStack: ["pipeline"], - capabilityRefs: ["sceneapp"], - infraProfile: ["workspace_storage"], - deliveryContract: "artifact_bundle", - outputHint: "测试输出", - entryBindings: [ - { - kind: "service_skill", - bindingFamily: "agent_turn", - }, - ], - launchRequirements: [], - sourcePackageId: "lime-core-sceneapps", - sourcePackageVersion: "2026-04-15", - ...(overrides.descriptor ?? {}), - } as SceneAppPlanResult["descriptor"], - readiness: { - ready: true, - unmetRequirements: [], - ...(overrides.readiness ?? {}), - } as SceneAppPlanResult["readiness"], - plan: { - sceneappId: planOverrides.sceneappId ?? "sceneapp-demo", - executorKind: planOverrides.executorKind ?? "agent_turn", - bindingFamily: planOverrides.bindingFamily ?? "agent_turn", - stepPlan: planOverrides.stepPlan ?? [], - adapterPlan: { - adapterKind: adapterPlanOverrides.adapterKind ?? "agent_turn", - runtimeAction: - adapterPlanOverrides.runtimeAction ?? "submit_agent_turn", - targetRef: adapterPlanOverrides.targetRef ?? "sceneapp-demo", - targetLabel: adapterPlanOverrides.targetLabel ?? "SceneApp Demo", - requestMetadata: adapterPlanOverrides.requestMetadata ?? { - harness: { - sceneapp_runtime_action: "submit_agent_turn", - }, - }, - launchPayload: adapterPlanOverrides.launchPayload ?? { - sceneapp_id: "sceneapp-demo", - message: "请执行 SceneApp Demo", - }, - notes: adapterPlanOverrides.notes ?? [], - linkedServiceSkillId: adapterPlanOverrides.linkedServiceSkillId, - linkedSceneKey: adapterPlanOverrides.linkedSceneKey, - preferredProfileKey: adapterPlanOverrides.preferredProfileKey, - } as SceneAppPlanResult["plan"]["adapterPlan"], - storageStrategy: planOverrides.storageStrategy ?? "workspace_bundle", - artifactContract: planOverrides.artifactContract ?? "artifact_bundle", - governanceHooks: planOverrides.governanceHooks ?? [], - warnings: planOverrides.warnings ?? [], - } as SceneAppPlanResult["plan"], - }; -} - -describe("sceneAppLaunch", () => { - beforeEach(() => { - vi.clearAllMocks(); - clearAgentUiProjectionEvents(); - }); - - it("应把 workspace 类型 SceneApp 翻译成 agent 导航动作", () => { - const result = createPlanResult({ - descriptor: { - id: "story-video-suite", - title: "短视频编排", - }, - plan: { - sceneappId: "story-video-suite", - executorKind: "agent_turn", - bindingFamily: "agent_turn", - adapterPlan: { - adapterKind: "agent_turn", - runtimeAction: "open_service_scene_session", - targetRef: "sceneapp-service-story-video", - targetLabel: "短视频编排", - requestMetadata: { - harness: { - service_scene_launch: { - kind: "local_service_skill", - }, - }, - }, - launchPayload: { - sceneapp_id: "story-video-suite", - workspace_id: "workspace-video", - project_id: "project-video", - content_id: "content-video-1", - user_input: "生成一版产品短视频", - }, - notes: [], - }, - }, - }); - - const resolved = resolveSceneAppLaunchAction({ - planResult: result, - projectId: "project-current", - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - now: () => 123, - }); - - expect(resolved).toEqual( - expect.objectContaining({ - ok: true, - kind: "navigate_agent", - executionDraft: expect.objectContaining({ - kind: "workspace_entry", - runtimeAction: "open_service_scene_session", - }), - resolvedEntry: expect.objectContaining({ - navigationParams: expect.objectContaining({ - projectId: "project-video", - contentId: "content-video-1", - initialUserPrompt: "生成一版产品短视频", - autoRunInitialPromptOnMount: true, - }), - }), - }), - ); - }); - - it("缺少项目时应返回结构化拒绝原因", () => { - const result = createPlanResult({ - descriptor: { - id: "native-scene", - title: "本地分析场景", - }, - plan: { - sceneappId: "native-scene", - executorKind: "native_skill", - bindingFamily: "native_skill", - adapterPlan: { - adapterKind: "native_skill", - runtimeAction: "launch_native_skill", - targetRef: "native-scene", - targetLabel: "本地分析场景", - requestMetadata: {}, - launchPayload: { - sceneapp_id: "native-scene", - message: "请分析这个项目", - }, - notes: [], - }, - }, - }); - - const resolved = resolveSceneAppLaunchAction({ - planResult: result, - projectId: null, - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - now: () => 234, - }); - - expect(resolved).toEqual({ - ok: false, - kind: "workspace_entry", - executionDraft: expect.objectContaining({ - kind: "workspace_entry", - runtimeAction: "launch_native_skill", - }), - reason: "missing_project", - message: "Skill「本地分析场景」需要项目工作区,请先选择项目后再启动。", - }); - }); - - it("native_skill 类型 SceneApp 应导航到服务技能入口而不是普通 prompt", () => { - const result = createPlanResult({ - descriptor: { - id: "project-analysis-copilot", - title: "项目分析 Copilot", - }, - plan: { - sceneappId: "project-analysis-copilot", - executorKind: "native_skill", - bindingFamily: "native_skill", - adapterPlan: { - adapterKind: "native_skill", - runtimeAction: "launch_native_skill", - targetRef: "sceneapp-service-analysis", - targetLabel: "项目分析 Copilot", - requestMetadata: { - sceneapp: { - id: "project-analysis-copilot", - }, - harness: { - sceneapp_runtime_action: "launch_native_skill", - sceneapp_native_skill_launch: { - skill_id: "sceneapp-service-analysis", - skill_key: "project-analysis", - project_id: "project-analysis", - workspace_id: "workspace-analysis", - user_input: "请分析当前项目结构", - slots: { - focus: "架构", - }, - }, - }, - }, - launchPayload: { - sceneapp_id: "project-analysis-copilot", - service_skill_id: "sceneapp-service-analysis", - project_id: "project-analysis", - workspace_id: "workspace-analysis", - user_input: "请分析当前项目结构", - slots: { - focus: "架构", - }, - }, - notes: [], - }, - }, - }); - - const resolved = resolveSceneAppLaunchAction({ - planResult: result, - projectId: "project-current", - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - now: () => 345, - }); - - expect(resolved).toEqual( - expect.objectContaining({ - ok: true, - kind: "navigate_agent", - resolvedEntry: expect.objectContaining({ - navigationParams: expect.objectContaining({ - projectId: "project-analysis", - initialPendingServiceSkillLaunch: expect.objectContaining({ - skillId: "sceneapp-service-analysis", - skillKey: "project-analysis", - requestKey: 345, - initialSlotValues: { - focus: "架构", - }, - launchUserInput: "请分析当前项目结构", - }), - }), - }), - }), - ); - }); - - it("执行 workspace 类型 SceneApp 时应调用导航", async () => { - const onNavigate = vi.fn(); - const result = createPlanResult({ - descriptor: { - id: "browser-scene", - title: "网页采集场景", - }, - plan: { - sceneappId: "browser-scene", - executorKind: "browser_assist", - bindingFamily: "browser_assist", - adapterPlan: { - adapterKind: "browser_assist", - runtimeAction: "launch_browser_assist", - targetRef: "x/article-export", - targetLabel: "网页采集场景", - requestMetadata: { - harness: { - service_skill_launch: { - adapter_name: "x/article-export", - }, - }, - }, - launchPayload: { - sceneapp_id: "browser-scene", - project_id: "project-browser", - args: { - url: "https://x.com/openai/article/123", - }, - }, - notes: [], - }, - }, - }); - - const executed = await executeSceneAppLaunchAction({ - planResult: result, - projectId: "project-current", - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - onNavigate, - now: () => 345, - }); - - expect(executed).toEqual( - expect.objectContaining({ - ok: true, - kind: "navigate_agent", - }), - ); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - projectId: "project-browser", - openBrowserAssistOnMount: true, - lockTheme: true, - }), - ); - }); - - it("执行 automation 类型 SceneApp 时应优先打开标准自动化表单", async () => { - const onOpenAutomationDialog = vi.fn(); - const result = createPlanResult({ - descriptor: { - id: "daily-trend-briefing", - title: "每日趋势摘要", - }, - plan: { - sceneappId: "daily-trend-briefing", - executorKind: "automation_job", - bindingFamily: "automation_job", - adapterPlan: { - adapterKind: "automation_job", - runtimeAction: "create_automation_job", - targetRef: "sceneapp-service-daily-trend", - targetLabel: "每日趋势摘要", - requestMetadata: { - sceneapp: { - id: "daily-trend-briefing", - }, - }, - launchPayload: { - sceneapp_id: "daily-trend-briefing", - name: "每日趋势摘要 自动化", - execution_mode: "intelligent", - schedule: { - kind: "every", - every_secs: 3600, - }, - launch_intent: { - sceneapp_id: "daily-trend-briefing", - workspace_id: "workspace-trend", - project_id: "project-trend", - user_input: "关注 AI Agent 行业变化", - }, - }, - notes: [], - }, - }, - }); - - const executed = await executeSceneAppLaunchAction({ - planResult: result, - projectId: "project-current", - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - onOpenAutomationDialog, - now: () => 456, - }); - - expect(executed).toEqual( - expect.objectContaining({ - ok: true, - kind: "open_automation_dialog", - }), - ); - expect(onOpenAutomationDialog).toHaveBeenCalledWith({ - initialValues: expect.objectContaining({ - workspace_id: "workspace-trend", - schedule_kind: "every", - }), - automationIntent: expect.objectContaining({ - name: "每日趋势摘要 自动化", - launchIntent: expect.objectContaining({ - sceneappId: "daily-trend-briefing", - }), - }), - executionDraft: expect.objectContaining({ - kind: "automation_job", - }), - }); - expect(mockCreateSceneAppAutomationJob).not.toHaveBeenCalled(); - }); - - it("执行 automation 类型 SceneApp 且未提供表单回调时应直接创建任务", async () => { - mockCreateSceneAppAutomationJob.mockResolvedValueOnce({ - sceneappId: "daily-trend-briefing", - jobId: "sceneapp-job-1", - jobName: "每日趋势摘要 自动化", - enabled: true, - workspaceId: "workspace-trend", - nextRunAt: null, - runNowResult: null, - }); - - const result = createPlanResult({ - descriptor: { - id: "daily-trend-briefing", - title: "每日趋势摘要", - }, - plan: { - sceneappId: "daily-trend-briefing", - executorKind: "automation_job", - bindingFamily: "automation_job", - adapterPlan: { - adapterKind: "automation_job", - runtimeAction: "create_automation_job", - targetRef: "sceneapp-service-daily-trend", - targetLabel: "每日趋势摘要", - requestMetadata: {}, - launchPayload: { - sceneapp_id: "daily-trend-briefing", - launch_intent: { - sceneapp_id: "daily-trend-briefing", - workspace_id: "workspace-trend", - user_input: "关注 AI Agent 行业变化", - }, - }, - notes: [], - }, - }, - }); - - const executed = await executeSceneAppLaunchAction({ - planResult: result, - projectId: "project-current", - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - now: () => 567, - }); - - expect(mockCreateSceneAppAutomationJob).toHaveBeenCalledWith( - expect.objectContaining({ - launchIntent: expect.objectContaining({ - sceneappId: "daily-trend-briefing", - workspaceId: "workspace-trend", - }), - }), - ); - expect(executed).toEqual( - expect.objectContaining({ - ok: true, - kind: "automation_job_created", - result: expect.objectContaining({ - jobId: "sceneapp-job-1", - }), - }), - ); - expect( - selectAgentUiProjectionEventsByType( - conversationProjectionStore.getSnapshot(), - "agent.changed", - ), - ).toEqual([ - expect.objectContaining({ - sourceType: "automation_job_projection", - taskId: "sceneapp-job-1", - agentId: "sceneapp-job-1", - agentName: "每日趋势摘要 自动化", - surface: "background_teammate", - runtimeEntity: "automation_job", - }), - ]); - }); - - it("workspace 类型 SceneApp 缺少导航回调时应返回结构化错误", async () => { - const result = createPlanResult({ - descriptor: { - id: "story-video-suite", - title: "短视频编排", - }, - plan: { - sceneappId: "story-video-suite", - executorKind: "agent_turn", - bindingFamily: "agent_turn", - adapterPlan: { - adapterKind: "agent_turn", - runtimeAction: "open_service_scene_session", - targetRef: "sceneapp-service-story-video", - targetLabel: "短视频编排", - requestMetadata: {}, - launchPayload: { - sceneapp_id: "story-video-suite", - project_id: "project-video", - message: "生成一版产品短视频", - }, - notes: [], - }, - }, - }); - - const executed = await executeSceneAppLaunchAction({ - planResult: result, - projectId: "project-current", - activeTheme: "general", - creationMode: "guided", - defaultToolPreferences, - now: () => 678, - }); - - expect(executed).toEqual({ - ok: false, - kind: "workspace_entry", - executionDraft: expect.objectContaining({ - kind: "workspace_entry", - runtimeAction: "open_service_scene_session", - }), - reason: "missing_navigate", - message: "当前入口暂不支持切换到 Skills 工作区,请从桌面主界面重试。", - }); - }); -}); diff --git a/src/components/agent/chat/workspace/sceneAppLaunch.ts b/src/components/agent/chat/workspace/sceneAppLaunch.ts deleted file mode 100644 index ffdd49865..000000000 --- a/src/components/agent/chat/workspace/sceneAppLaunch.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@/lib/sceneapp/launchBridge"; diff --git a/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx b/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx index fdff1a943..623ce0a63 100644 --- a/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx @@ -1,18 +1,9 @@ import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { useSceneAppExecutionSummaryRuntime } from "./useSceneAppExecutionSummaryRuntime"; -import type { SceneAppExecutionSummaryViewModel } from "@/lib/sceneapp/product"; - -const listSceneAppRunsMock = vi.fn(); -const getSceneAppScorecardMock = vi.fn(); - -vi.mock("@/lib/api/sceneapp", () => ({ - listSceneAppRuns: (...args: unknown[]) => listSceneAppRunsMock(...args), - getSceneAppScorecard: (...args: unknown[]) => - getSceneAppScorecardMock(...args), -})); +import type { SceneAppExecutionSummaryViewModel } from "@/lib/agent/legacySceneAppExecutionSummary"; const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; @@ -59,7 +50,22 @@ function createInitialSummary(): SceneAppExecutionSummaryViewModel { primaryPart: "brief", }, }, - runtimeBackflow: null, + runtimeBackflow: { + runId: "run-archived-1", + statusLabel: "成功", + statusTone: "success", + summary: "历史结果已经写入摘要 payload。", + nextAction: "继续把这轮运行沉淀到判断与选品基线。", + sourceLabel: "人工试跑", + startedAtLabel: "2026-04-16 12:00", + finishedAtLabel: "2026-04-16 12:03", + deliveryCompletionLabel: "已交付 2/2 个部件", + evidenceSourceLabel: "当前已接入会话证据", + deliveryCompletedParts: [], + deliveryMissingParts: [], + observedFailureSignals: [], + governanceArtifacts: [], + }, }; } @@ -89,26 +95,20 @@ function renderHook(props: HookProbeProps) { mountedRoots.push({ root, container }); return { getValue: () => latestValue, + rerender: (nextProps: HookProbeProps) => { + act(() => { + root.render(); + }); + }, }; } -async function flushEffects() { - await act(async () => { - await Promise.resolve(); - }); - await act(async () => { - await Promise.resolve(); - }); -} - beforeEach(() => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; - listSceneAppRunsMock.mockReset(); - getSceneAppScorecardMock.mockReset(); }); afterEach(() => { @@ -125,305 +125,60 @@ afterEach(() => { }); describe("useSceneAppExecutionSummaryRuntime", () => { - it("应把当前 session 对应的运行回流补进执行摘要", async () => { - listSceneAppRunsMock.mockResolvedValue([ - { - runId: "run-1", - sceneappId: "story-video-suite", - status: "success", - source: "chat", - sourceRef: "source-1", - sessionId: "session-1", - startedAt: "2026-04-17T12:00:00.000Z", - finishedAt: "2026-04-17T12:03:00.000Z", - artifactCount: 2, - deliveryRequiredParts: ["brief", "storyboard"], - deliveryCompletedParts: ["brief"], - deliveryMissingParts: ["storyboard"], - deliveryPartCoverageKnown: true, - failureSignal: "pack_incomplete", - runtimeEvidenceUsed: true, - evidenceKnownGaps: [], - verificationFailureOutcomes: [], - requestTelemetryAvailable: true, - requestTelemetryMatchedCount: 1, - artifactValidatorApplicable: true, - artifactValidatorIssueCount: 0, - artifactValidatorRecoveredCount: 0, - governanceArtifactRefs: [ - { - kind: "evidence_summary", - label: "证据摘要", - relativePath: - ".lime/harness/sessions/session-1/evidence/summary.md", - absolutePath: "/tmp/summary.md", - projectId: "project-1", - workspaceId: "project-1", - source: "session_governance", - }, - ], - deliveryArtifactRefs: [ - { - partKey: "brief", - relativePath: "packs/run-1/brief.md", - absolutePath: "/tmp/packs/run-1/brief.md", - projectId: "project-1", - source: "runtime_evidence", - }, - ], - }, - ]); - getSceneAppScorecardMock.mockResolvedValue({ - sceneappId: "story-video-suite", - updatedAt: "2026-04-17T12:04:00.000Z", - summary: "当前建议继续优化缺失部件后再放量。", - metrics: [], - recommendedAction: "optimize", - observedFailureSignals: ["pack_incomplete"], - topFailureSignal: "pack_incomplete", - }); - + it("应只保留初始摘要中的历史只读信息", () => { const summary = createInitialSummary(); const { getValue } = renderHook({ initialSummary: summary, sessionId: "session-1", - isSending: false, - }); - - await flushEffects(); - - expect(listSceneAppRunsMock).toHaveBeenCalledWith("story-video-suite"); - expect(getSceneAppScorecardMock).toHaveBeenCalledWith("story-video-suite"); - expect(getValue()?.summary?.runtimeBackflow).toEqual( - expect.objectContaining({ - runId: "run-1", - statusLabel: "成功", - statusTone: "watch", - deliveryCompletionLabel: "已交付 1/2 个部件", - evidenceSourceLabel: "当前已接入会话证据", - sourceLabel: "人工试跑", - scorecardActionLabel: "建议继续优化", - topFailureSignalLabel: "整包不完整", - }), - ); - expect(getValue()?.latestPackResultDetailView).toEqual( - expect.objectContaining({ - runId: "run-1", - deliveryCompletionLabel: "已交付 1/2 个部件", - }), - ); - expect(getValue()?.latestPackResultUsesFallback).toBe(false); - expect(getValue()?.reviewTargetRunSummary).toEqual( - expect.objectContaining({ - runId: "run-1", - sessionId: "session-1", - }), - ); - }); - - it("找不到当前 session 对应运行时应保留启动摘要", async () => { - listSceneAppRunsMock.mockResolvedValue([ - { - runId: "run-other", - sceneappId: "story-video-suite", - status: "success", - source: "chat", - sourceRef: "source-other", - sessionId: "other-session", - startedAt: "2026-04-17T12:00:00.000Z", - finishedAt: "2026-04-17T12:03:00.000Z", - artifactCount: 1, - runtimeEvidenceUsed: false, - }, - ]); - getSceneAppScorecardMock.mockResolvedValue({ - sceneappId: "story-video-suite", - updatedAt: "2026-04-17T12:04:00.000Z", - summary: "当前还没有足够样本。", - metrics: [], - recommendedAction: "launch", - observedFailureSignals: [], - topFailureSignal: null, - }); - - const summary = createInitialSummary(); - const { getValue } = renderHook({ - initialSummary: summary, - sessionId: "session-1", - isSending: false, - }); - - await flushEffects(); - - expect(getValue()?.summary?.runtimeBackflow).toBeNull(); - expect(getValue()?.summary?.planningSummary).toBe(summary.planningSummary); - expect(getValue()?.latestPackResultDetailView).toBeNull(); - expect(getValue()?.reviewTargetRunSummary).toBeNull(); - }); - - it("当前 session 结果还没回流文件时应回退到最近可消费样本", async () => { - listSceneAppRunsMock.mockResolvedValue([ - { - runId: "run-current", - sceneappId: "story-video-suite", - status: "running", - source: "chat", - sourceRef: "source-current", - sessionId: "session-1", - startedAt: "2026-04-17T12:10:00.000Z", - finishedAt: null, - artifactCount: 0, - deliveryRequiredParts: ["brief", "storyboard"], - deliveryCompletedParts: [], - deliveryMissingParts: ["brief", "storyboard"], - deliveryPartCoverageKnown: true, - runtimeEvidenceUsed: true, - deliveryArtifactRefs: [], - }, - { - runId: "run-fallback", - sceneappId: "story-video-suite", - status: "success", - source: "chat", - sourceRef: "source-fallback", - sessionId: "older-session", - startedAt: "2026-04-16T12:00:00.000Z", - finishedAt: "2026-04-16T12:03:00.000Z", - artifactCount: 2, - deliveryRequiredParts: ["brief", "storyboard"], - deliveryCompletedParts: ["brief", "storyboard"], - deliveryMissingParts: [], - deliveryPartCoverageKnown: true, - runtimeEvidenceUsed: true, - deliveryArtifactRefs: [ - { - partKey: "brief", - relativePath: "packs/run-fallback/brief.md", - absolutePath: "/tmp/packs/run-fallback/brief.md", - projectId: "project-1", - source: "runtime_evidence", - }, - ], - }, - ]); - getSceneAppScorecardMock.mockResolvedValue({ - sceneappId: "story-video-suite", - updatedAt: "2026-04-17T12:04:00.000Z", - summary: "当前还在等待主运行写回结果。", - metrics: [], - recommendedAction: "keep", - observedFailureSignals: [], - topFailureSignal: null, - }); - - const { getValue } = renderHook({ - initialSummary: createInitialSummary(), - sessionId: "session-1", isSending: true, }); - await flushEffects(); - + expect(getValue()?.summary).toBe(summary); expect(getValue()?.summary?.runtimeBackflow).toEqual( expect.objectContaining({ - runId: "run-current", - statusLabel: "执行中", - }), - ); - expect(getValue()?.latestPackResultDetailView).toEqual( - expect.objectContaining({ - runId: "run-fallback", - deliveryArtifactEntries: [ - expect.objectContaining({ - label: "主稿 · 任务简报", - pathLabel: "packs/run-fallback/brief.md", - }), - ], - }), - ); - expect(getValue()?.latestPackResultUsesFallback).toBe(true); - expect(getValue()?.reviewTargetRunSummary).toEqual( - expect.objectContaining({ - runId: "run-fallback", - sessionId: "older-session", + runId: "run-archived-1", + statusLabel: "成功", }), ); + expect(getValue()?.loading).toBe(false); + expect(getValue()?.latestPackResultDetailView).toBeNull(); + expect(getValue()?.latestPackResultUsesFallback).toBe(false); + expect(getValue()?.reviewTargetRunSummary).toBeNull(); }); - it("请求刷新时应重新拉取最新运行摘要与 scorecard", async () => { - listSceneAppRunsMock.mockResolvedValue([ - { - runId: "run-1", - sceneappId: "story-video-suite", - status: "success", - source: "chat", - sourceRef: "source-1", - sessionId: "session-1", - startedAt: "2026-04-17T12:00:00.000Z", - finishedAt: "2026-04-17T12:03:00.000Z", - artifactCount: 2, - deliveryRequiredParts: ["brief", "storyboard"], - deliveryCompletedParts: ["brief", "storyboard"], - deliveryMissingParts: [], - deliveryPartCoverageKnown: true, - runtimeEvidenceUsed: true, - deliveryArtifactRefs: [ - { - partKey: "brief", - relativePath: "packs/run-1/brief.md", - absolutePath: "/tmp/packs/run-1/brief.md", - projectId: "project-1", - source: "runtime_evidence", - }, - ], - }, - ]); - getSceneAppScorecardMock.mockResolvedValueOnce({ - sceneappId: "story-video-suite", - updatedAt: "2026-04-17T12:04:00.000Z", - summary: "当前建议继续保留,先观察下一轮样本。", - metrics: [], - recommendedAction: "keep", - observedFailureSignals: [], - topFailureSignal: null, + it("缺少初始摘要时不再尝试恢复旧 SceneApp 运行态", () => { + const { getValue } = renderHook({ + initialSummary: null, + sessionId: "session-1", + isSending: true, }); - const { getValue } = renderHook({ - initialSummary: createInitialSummary(), + expect(getValue()?.summary).toBeNull(); + expect(getValue()?.loading).toBe(false); + expect(getValue()?.latestPackResultDetailView).toBeNull(); + expect(getValue()?.reviewTargetRunSummary).toBeNull(); + }); + + it("请求刷新是 no-op,不再触发旧 SceneApp API 轮询", () => { + const summary = createInitialSummary(); + const { getValue, rerender } = renderHook({ + initialSummary: summary, sessionId: "session-1", isSending: false, }); + const firstRefresh = getValue()?.requestRefresh; - await flushEffects(); - - expect(getValue()?.summary?.runtimeBackflow).toEqual( - expect.objectContaining({ - scorecardActionLabel: "建议维持现状", - }), - ); - - getSceneAppScorecardMock.mockResolvedValueOnce({ - sceneappId: "story-video-suite", - updatedAt: "2026-04-17T12:08:00.000Z", - summary: "当前建议继续优化后再放量。", - metrics: [], - recommendedAction: "optimize", - observedFailureSignals: ["artifact_validation_issue"], - topFailureSignal: "artifact_validation_issue", - }); - - await act(async () => { + act(() => { getValue()?.requestRefresh(); }); - await flushEffects(); + rerender({ + initialSummary: summary, + sessionId: "session-1", + isSending: true, + }); - expect(listSceneAppRunsMock).toHaveBeenCalledTimes(2); - expect(getSceneAppScorecardMock).toHaveBeenCalledTimes(2); - expect(getValue()?.summary?.runtimeBackflow).toEqual( - expect.objectContaining({ - scorecardActionLabel: "建议继续优化", - topFailureSignalLabel: "结果结构校验问题", - }), - ); + expect(getValue()?.summary).toBe(summary); + expect(getValue()?.requestRefresh).toBe(firstRefresh); + expect(getValue()?.loading).toBe(false); }); }); diff --git a/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts b/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts index 04b02d75c..8e0ce87d8 100644 --- a/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts +++ b/src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts @@ -1,71 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { - getSceneAppScorecard, - listSceneAppRuns, - type SceneAppRunSummary, - type SceneAppScorecard, -} from "@/lib/api/sceneapp"; -import { - backfillSceneAppExecutionSummaryViewModel, - buildSceneAppExecutionSummaryRunDetailViewModel, - type SceneAppExecutionSummaryViewModel, - type SceneAppRunDetailViewModel, -} from "@/lib/sceneapp/product"; -import { findLatestSceneAppPackResultRun } from "@/lib/sceneapp"; - -const SCENEAPP_RUNTIME_POLL_INTERVAL_MS = 4_000; - -function findSceneAppRunForSession( - runs: SceneAppRunSummary[], - sessionId: string, -): SceneAppRunSummary | null { - const normalizedSessionId = sessionId.trim(); - if (!normalizedSessionId) { - return null; - } - - return ( - runs.find((run) => run.sessionId?.trim() === normalizedSessionId) ?? null - ); -} - -function shouldContinuePolling(params: { - sessionId: string; - run: SceneAppRunSummary | null; - isSending: boolean; -}): boolean { - if (!params.sessionId.trim()) { - return false; - } - - if (params.isSending) { - return true; - } - - return ( - !params.run || - params.run.status === "queued" || - params.run.status === "running" - ); -} - -async function loadSceneAppExecutionRuntimeSnapshot( - sceneappId: string, -): Promise<{ - runs: SceneAppRunSummary[]; - scorecard: SceneAppScorecard | null; -}> { - const [runsResult, scorecardResult] = await Promise.allSettled([ - listSceneAppRuns(sceneappId), - getSceneAppScorecard(sceneappId), - ]); - - return { - runs: runsResult.status === "fulfilled" ? runsResult.value : [], - scorecard: - scorecardResult.status === "fulfilled" ? scorecardResult.value : null, - }; -} +import { useCallback, useMemo } from "react"; +import type { SceneAppRunSummary } from "@/lib/agent/legacySceneAppExecutionSummary"; +import type { + SceneAppExecutionSummaryViewModel, + SceneAppRunDetailViewModel, +} from "@/lib/agent/legacySceneAppExecutionSummary"; interface UseSceneAppExecutionSummaryRuntimeParams { initialSummary?: SceneAppExecutionSummaryViewModel | null; @@ -84,125 +22,30 @@ export interface SceneAppExecutionSummaryRuntimeState { function createInitialRuntimeState(params: { initialSummary?: SceneAppExecutionSummaryViewModel | null; - sessionId?: string | null; }): Omit { return { - summary: params.initialSummary, + summary: params.initialSummary ?? null, latestPackResultDetailView: null, latestPackResultUsesFallback: false, reviewTargetRunSummary: null, - loading: Boolean(params.initialSummary && params.sessionId?.trim()), + loading: false, }; } export function useSceneAppExecutionSummaryRuntime({ initialSummary, - sessionId, - isSending, }: UseSceneAppExecutionSummaryRuntimeParams): | SceneAppExecutionSummaryRuntimeState | undefined { - const [state, setState] = useState( - () => ({ - ...createInitialRuntimeState({ - initialSummary, - sessionId, - }), - requestRefresh: () => undefined, - }), - ); - const [refreshNonce, setRefreshNonce] = useState(0); const requestRefresh = useCallback(() => { - setRefreshNonce((current) => current + 1); + // SceneApp 独立运行面已下线;历史摘要只保留当前 payload 内的只读信息。 }, []); - useEffect(() => { - setState({ - ...createInitialRuntimeState({ - initialSummary, - sessionId, - }), - requestRefresh, - }); - }, [initialSummary, requestRefresh, sessionId]); - - useEffect(() => { - const baseSummary = initialSummary; - const normalizedSessionId = sessionId?.trim() || ""; - if (!baseSummary || !normalizedSessionId) { - return; - } - - let cancelled = false; - let pollTimer: ReturnType | null = null; - - const refresh = async () => { - setState((current) => ({ - ...current, - summary: current.summary ?? baseSummary, - loading: true, - })); - const snapshot = await loadSceneAppExecutionRuntimeSnapshot( - baseSummary.sceneappId, - ); - if (cancelled) { - return; - } - - const matchedRun = findSceneAppRunForSession( - snapshot.runs, - normalizedSessionId, - ); - const latestPackResultRun = findLatestSceneAppPackResultRun({ - selectedRun: matchedRun, - runs: snapshot.runs, - }); - setState({ - summary: backfillSceneAppExecutionSummaryViewModel({ - summary: baseSummary, - run: matchedRun, - scorecard: snapshot.scorecard, - }), - latestPackResultDetailView: latestPackResultRun - ? buildSceneAppExecutionSummaryRunDetailViewModel({ - summary: baseSummary, - run: latestPackResultRun, - }) - : null, - latestPackResultUsesFallback: - Boolean(latestPackResultRun) && - latestPackResultRun?.runId !== matchedRun?.runId, - reviewTargetRunSummary: latestPackResultRun ?? matchedRun ?? null, - loading: false, - requestRefresh, - }); - - if ( - shouldContinuePolling({ - sessionId: normalizedSessionId, - run: matchedRun, - isSending, - }) - ) { - pollTimer = setTimeout(refresh, SCENEAPP_RUNTIME_POLL_INTERVAL_MS); - } - }; - - void refresh(); - - return () => { - cancelled = true; - if (pollTimer) { - clearTimeout(pollTimer); - } - }; - }, [initialSummary, isSending, refreshNonce, requestRefresh, sessionId]); - return useMemo( () => ({ - ...state, + ...createInitialRuntimeState({ initialSummary }), requestRefresh, }), - [requestRefresh, state], + [initialSummary, requestRefresh], ); } diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index b9868d28d..8aa34dc54 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -354,17 +354,10 @@ interface UseWorkspaceConversationSceneRuntimeParams { handleRefreshSkills: ConversationScenePresentationParams["scene"]["onRefreshSkills"]; handleOpenBrowserAssistInCanvas: ConversationScenePresentationParams["scene"]["onLaunchBrowserAssist"]; browserAssistLaunching: ConversationScenePresentationParams["scene"]["browserAssistLoading"]; - featuredSceneApps: ConversationScenePresentationParams["scene"]["featuredSceneApps"]; - sceneAppsLoading: ConversationScenePresentationParams["scene"]["sceneAppsLoading"]; - sceneAppLaunchingId: ConversationScenePresentationParams["scene"]["sceneAppLaunchingId"]; - handleLaunchSceneApp?: ConversationScenePresentationParams["scene"]["onLaunchSceneApp"]; - canResumeRecentSceneApp?: ConversationScenePresentationParams["scene"]["canResumeRecentSceneApp"]; - handleResumeRecentSceneApp?: ConversationScenePresentationParams["scene"]["onResumeRecentSceneApp"]; recentSessionTitle?: ConversationScenePresentationParams["scene"]["recentSessionTitle"]; recentSessionSummary?: ConversationScenePresentationParams["scene"]["recentSessionSummary"]; recentSessionActionLabel?: ConversationScenePresentationParams["scene"]["recentSessionActionLabel"]; handleResumeRecentSession?: ConversationScenePresentationParams["scene"]["onResumeRecentSession"]; - handleOpenSceneAppsDirectory?: ConversationScenePresentationParams["scene"]["onOpenSceneAppsDirectory"]; projectId: string | null; deferWorkspaceListLoad?: ConversationScenePresentationParams["scene"]["deferWorkspaceListLoad"]; workspaceHintMessage?: ConversationScenePresentationParams["scene"]["workspaceHintMessage"]; @@ -518,17 +511,10 @@ export function useWorkspaceConversationSceneRuntime({ handleRefreshSkills, handleOpenBrowserAssistInCanvas, browserAssistLaunching, - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId, - handleLaunchSceneApp, - canResumeRecentSceneApp, - handleResumeRecentSceneApp, recentSessionTitle, recentSessionSummary, recentSessionActionLabel, handleResumeRecentSession, - handleOpenSceneAppsDirectory, projectId, deferWorkspaceListLoad, workspaceHintMessage, @@ -1091,12 +1077,6 @@ export function useWorkspaceConversationSceneRuntime({ onRefreshSkills: handleRefreshSkills, onLaunchBrowserAssist: handleOpenBrowserAssistInCanvas, browserAssistLoading: browserAssistLaunching, - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId, - onLaunchSceneApp: handleLaunchSceneApp, - canResumeRecentSceneApp, - onResumeRecentSceneApp: handleResumeRecentSceneApp, recentSessionTitle: messageListEmptyStateVariant === "task-center" ? undefined @@ -1113,7 +1093,6 @@ export function useWorkspaceConversationSceneRuntime({ messageListEmptyStateVariant === "task-center" ? undefined : handleResumeRecentSession, - onOpenSceneAppsDirectory: handleOpenSceneAppsDirectory, projectId, deferWorkspaceListLoad, workspaceHintMessage, diff --git a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx deleted file mode 100644 index a01b054a2..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.test.tsx +++ /dev/null @@ -1,619 +0,0 @@ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import { - clearAgentUiProjectionEvents, - conversationProjectionStore, - selectAgentUiProjectionEventsByType, -} from "../projection/conversationProjectionStore"; -import { useWorkspaceSceneAppEntryActions } from "./useWorkspaceSceneAppEntryActions"; -import type { SceneAppCatalog, SceneAppPlanResult } from "@/lib/sceneapp"; - -type SceneAppPlanResultOverrides = { - descriptor?: Partial; - readiness?: Partial; - plan?: Partial> & { - adapterPlan?: Partial; - }; -}; - -const mockListSceneAppCatalog = vi.fn(); -const mockPlanSceneAppLaunch = vi.fn(); -const mockCreateAutomationJob = vi.fn(); -const mockListProjects = vi.fn(); -const mockGetOrCreateDefaultProject = vi.fn(); -const mockReadStoredSceneAppCatalog = vi.fn(); -const mockToastSuccess = vi.fn(); -const mockToastError = vi.fn(); - -vi.mock("sonner", () => ({ - toast: { - success: (...args: unknown[]) => mockToastSuccess(...args), - error: (...args: unknown[]) => mockToastError(...args), - }, -})); - -vi.mock("@/lib/api/sceneapp", async () => { - const actual = - await vi.importActual( - "@/lib/api/sceneapp", - ); - return { - ...actual, - listSceneAppCatalog: () => mockListSceneAppCatalog(), - planSceneAppLaunch: (intent: unknown) => mockPlanSceneAppLaunch(intent), - }; -}); - -vi.mock("@/lib/api/automation", () => ({ - createAutomationJob: (request: unknown) => mockCreateAutomationJob(request), -})); - -vi.mock("@/lib/api/project", () => ({ - listProjects: () => mockListProjects(), - getOrCreateDefaultProject: () => mockGetOrCreateDefaultProject(), -})); - -vi.mock("@/lib/sceneapp", async () => { - const actual = - await vi.importActual("@/lib/sceneapp"); - return { - ...actual, - readStoredSceneAppCatalog: () => mockReadStoredSceneAppCatalog(), - }; -}); - -type HookProps = Parameters[0]; - -const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; -const DEFAULT_CHAT_TOOL_PREFERENCES: ChatToolPreferences = { - webSearch: false, - thinking: false, - task: false, - subagent: false, -}; - -function createSceneAppCatalog(): SceneAppCatalog { - return { - version: "2026-04-15", - generatedAt: "2026-04-15T00:00:00.000Z", - items: [ - { - id: "story-video-suite", - title: "短视频编排", - summary: "把文本、线框图、配乐和短视频草稿收口成结果链。", - category: "Scene Apps", - sceneappType: "hybrid", - patternPrimary: "pipeline", - patternStack: ["pipeline", "generator", "inversion"], - capabilityRefs: ["agent_turn"], - infraProfile: ["composition_blueprint", "workspace_storage"], - deliveryContract: "artifact_bundle", - outputHint: "短视频结果包", - entryBindings: [ - { - kind: "service_skill", - bindingFamily: "agent_turn", - }, - ], - launchRequirements: [ - { - kind: "project", - message: "需要项目目录承接结果。", - }, - ], - sourcePackageId: "lime-core-sceneapps", - sourcePackageVersion: "2026-04-15", - }, - { - id: "x-article-export", - title: "网页导出", - summary: "在浏览器上下文中抓取网页并沉淀为资料包。", - category: "Scene Apps", - sceneappType: "browser_grounded", - patternPrimary: "pipeline", - patternStack: ["pipeline", "tool_wrapper", "generator"], - capabilityRefs: ["browser_assist"], - infraProfile: [ - "browser_connector", - "workspace_storage", - "artifact_bundle", - ], - deliveryContract: "project_pack", - outputHint: "网页资料包", - entryBindings: [ - { - kind: "service_skill", - bindingFamily: "browser_assist", - }, - ], - launchRequirements: [ - { - kind: "browser_session", - message: "需要真实浏览器上下文。", - }, - { - kind: "project", - message: "需要项目目录来保存资料包。", - }, - ], - sourcePackageId: "lime-core-sceneapps", - sourcePackageVersion: "2026-04-15", - }, - { - id: "daily-trend-briefing", - title: "每日趋势摘要", - summary: "把研究主题变成每天自动回流的观察任务。", - category: "Scene Apps", - sceneappType: "local_durable", - patternPrimary: "pipeline", - patternStack: ["pipeline", "reviewer"], - capabilityRefs: ["automation_job"], - infraProfile: ["automation_schedule", "db_store", "json_snapshot"], - deliveryContract: "table_report", - outputHint: "趋势摘要", - entryBindings: [ - { - kind: "service_skill", - bindingFamily: "automation_job", - }, - ], - launchRequirements: [ - { - kind: "project", - message: "需要工作区保存运行历史。", - }, - { - kind: "automation", - message: "需要调度能力。", - }, - ], - sourcePackageId: "lime-core-sceneapps", - sourcePackageVersion: "2026-04-15", - }, - ], - }; -} - -function createProject(id = "project-1") { - return { - id, - name: "项目一", - workspaceType: "general", - rootPath: "", - isDefault: false, - createdAt: 1, - updatedAt: 1, - isFavorite: false, - isArchived: false, - tags: [], - }; -} - -function createPlanResult( - overrides: SceneAppPlanResultOverrides = {}, -): SceneAppPlanResult { - const planOverrides = overrides.plan ?? {}; - const adapterPlanOverrides = planOverrides.adapterPlan ?? {}; - - return { - descriptor: { - id: "story-video-suite", - title: "短视频编排", - summary: "SceneApp 测试样板。", - category: "Scene Apps", - sceneappType: "hybrid", - patternPrimary: "pipeline", - patternStack: ["pipeline"], - capabilityRefs: ["sceneapp"], - infraProfile: ["workspace_storage"], - deliveryContract: "artifact_bundle", - outputHint: "测试输出", - entryBindings: [ - { - kind: "service_skill", - bindingFamily: "agent_turn", - }, - ], - launchRequirements: [], - sourcePackageId: "lime-core-sceneapps", - sourcePackageVersion: "2026-04-15", - ...(overrides.descriptor ?? {}), - } as SceneAppPlanResult["descriptor"], - readiness: { - ready: true, - unmetRequirements: [], - ...(overrides.readiness ?? {}), - } as SceneAppPlanResult["readiness"], - plan: { - sceneappId: planOverrides.sceneappId ?? "story-video-suite", - executorKind: planOverrides.executorKind ?? "agent_turn", - bindingFamily: planOverrides.bindingFamily ?? "agent_turn", - stepPlan: planOverrides.stepPlan ?? [], - adapterPlan: { - adapterKind: adapterPlanOverrides.adapterKind ?? "agent_turn", - runtimeAction: - adapterPlanOverrides.runtimeAction ?? "open_service_scene_session", - targetRef: - adapterPlanOverrides.targetRef ?? "sceneapp-service-story-video", - targetLabel: adapterPlanOverrides.targetLabel ?? "短视频编排", - requestMetadata: adapterPlanOverrides.requestMetadata ?? { - sceneapp: { - id: "story-video-suite", - title: "短视频编排", - }, - }, - launchPayload: adapterPlanOverrides.launchPayload ?? { - sceneapp_id: "story-video-suite", - workspace_id: "project-1", - project_id: "project-1", - content_id: "content-1", - user_input: "做一条新品发布短视频", - }, - notes: adapterPlanOverrides.notes ?? [], - linkedServiceSkillId: adapterPlanOverrides.linkedServiceSkillId, - linkedSceneKey: adapterPlanOverrides.linkedSceneKey, - preferredProfileKey: adapterPlanOverrides.preferredProfileKey, - } as SceneAppPlanResult["plan"]["adapterPlan"], - storageStrategy: planOverrides.storageStrategy ?? "workspace_bundle", - artifactContract: planOverrides.artifactContract ?? "artifact_bundle", - governanceHooks: planOverrides.governanceHooks ?? [], - warnings: planOverrides.warnings ?? [], - } as SceneAppPlanResult["plan"], - }; -} - -function renderHook(props?: Partial) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - let latestValue: ReturnType | null = - null; - - const defaultProps: HookProps = { - activeTheme: "general", - creationMode: "guided", - projectId: "project-1", - input: "", - selectedText: "", - defaultToolPreferences: DEFAULT_CHAT_TOOL_PREFERENCES, - onNavigate: vi.fn(), - }; - - function Probe(currentProps: HookProps) { - latestValue = useWorkspaceSceneAppEntryActions(currentProps); - return null; - } - - const render = async (nextProps?: Partial) => { - await act(async () => { - root.render(); - await Promise.resolve(); - await Promise.resolve(); - }); - }; - - mountedRoots.push({ container, root }); - - return { - render, - getValue: () => { - if (!latestValue) { - throw new Error("hook 尚未初始化"); - } - return latestValue; - }, - getDefaultProps: () => ({ ...defaultProps, ...props }), - }; -} - -async function waitForSceneAppCatalogReady( - harness: ReturnType, -): Promise { - const loadCall = mockListSceneAppCatalog.mock.results.at(0); - if (loadCall?.type === "return") { - await loadCall.value; - } - - await act(async () => { - for (let attempt = 0; attempt < 12; attempt += 1) { - const state = harness.getValue(); - if ( - state.sceneAppsLoading === false && - state.featuredSceneApps.length > 0 - ) { - return; - } - await Promise.resolve(); - } - }); -} - -beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - mockListSceneAppCatalog.mockResolvedValue(createSceneAppCatalog()); - mockReadStoredSceneAppCatalog.mockReturnValue(null); - mockListProjects.mockResolvedValue([createProject()]); - mockGetOrCreateDefaultProject.mockResolvedValue(createProject()); - mockCreateAutomationJob.mockResolvedValue({ - id: "job-1", - name: "每日趋势摘要 自动化", - }); - clearAgentUiProjectionEvents(); -}); - -afterEach(() => { - clearAgentUiProjectionEvents(); - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) break; - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } - vi.clearAllMocks(); -}); - -describe("useWorkspaceSceneAppEntryActions", () => { - it("支持延后加载 SceneApp 目录,避免空白完整首页阻塞首屏", async () => { - vi.useFakeTimers(); - try { - const harness = renderHook({ - catalogLoadMode: "deferred", - catalogDeferredDelayMs: 12_000, - }); - await harness.render(); - - expect(mockListSceneAppCatalog).not.toHaveBeenCalled(); - expect(harness.getValue().sceneAppsLoading).toBe(false); - - await act(async () => { - vi.advanceTimersByTime(11_999); - await Promise.resolve(); - }); - expect(mockListSceneAppCatalog).not.toHaveBeenCalled(); - - await act(async () => { - vi.advanceTimersByTime(1); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mockListSceneAppCatalog).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it("应按业务入口构建 featured SceneApp 卡片,并在出现 URL 时显示网页导出", async () => { - const harness = renderHook({ - input: "给我一条新品发布短视频方案", - }); - await harness.render(); - - expect(harness.getValue().featuredSceneApps.map((item) => item.id)).toEqual( - ["story-video-suite", "daily-trend-briefing"], - ); - - await harness.render({ - input: "请整理这篇文章 https://example.com/agent-trends", - }); - - expect(harness.getValue().featuredSceneApps.map((item) => item.id)).toEqual( - ["story-video-suite", "x-article-export", "daily-trend-briefing"], - ); - }); - - it("应把可在当前会话继续的 SceneApp 翻译成 agent 导航动作", async () => { - const onNavigate = vi.fn(); - mockPlanSceneAppLaunch.mockResolvedValueOnce( - createPlanResult({ - descriptor: { - id: "story-video-suite", - title: "短视频编排", - }, - plan: { - sceneappId: "story-video-suite", - executorKind: "agent_turn", - bindingFamily: "agent_turn", - adapterPlan: { - adapterKind: "agent_turn", - runtimeAction: "open_service_scene_session", - targetRef: "sceneapp-service-story-video", - targetLabel: "短视频编排", - requestMetadata: { - sceneapp: { - id: "story-video-suite", - }, - }, - launchPayload: { - sceneapp_id: "story-video-suite", - workspace_id: "project-1", - project_id: "project-1", - content_id: "content-video-1", - user_input: "做一条新品发布短视频", - }, - notes: [], - }, - }, - }), - ); - - const harness = renderHook({ - input: "做一条新品发布短视频", - onNavigate, - }); - await harness.render(); - await waitForSceneAppCatalogReady(harness); - expect( - harness - .getValue() - .featuredSceneApps.some((item) => item.id === "story-video-suite"), - ).toBe(true); - - await act(async () => { - await harness.getValue().handleLaunchSceneApp("story-video-suite"); - }); - - expect(mockToastError).not.toHaveBeenCalled(); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - projectId: "project-1", - initialUserPrompt: "做一条新品发布短视频", - autoRunInitialPromptOnMount: true, - }), - ); - }); - - it("应为 durable SceneApp 打开标准自动化表单,并保留 SceneApp metadata", async () => { - mockPlanSceneAppLaunch.mockResolvedValueOnce( - createPlanResult({ - descriptor: { - id: "daily-trend-briefing", - title: "每日趋势摘要", - }, - plan: { - sceneappId: "daily-trend-briefing", - executorKind: "automation_job", - bindingFamily: "automation_job", - adapterPlan: { - adapterKind: "automation_job", - runtimeAction: "create_automation_job", - targetRef: "sceneapp-service-daily-trend", - targetLabel: "每日趋势摘要", - requestMetadata: { - sceneapp: { - id: "daily-trend-briefing", - title: "每日趋势摘要", - }, - harness: { - sceneapp_runtime_action: "create_automation_job", - }, - }, - launchPayload: { - sceneapp_id: "daily-trend-briefing", - name: "每日趋势摘要 自动化", - enabled: true, - execution_mode: "intelligent", - schedule: { - kind: "every", - every_secs: 3600, - }, - delivery: { - mode: "none", - channel: null, - target: null, - best_effort: true, - output_schema: "text", - output_format: "text", - }, - launch_intent: { - sceneapp_id: "daily-trend-briefing", - workspace_id: "project-1", - project_id: "project-1", - user_input: "关注 AI Agent 产品趋势", - slots: {}, - }, - }, - notes: [], - }, - }, - }), - ); - - const harness = renderHook({ - input: "关注 AI Agent 产品趋势", - }); - await harness.render(); - - await act(async () => { - await harness.getValue().handleLaunchSceneApp("daily-trend-briefing"); - }); - - expect(harness.getValue().automationDialogOpen).toBe(true); - expect(harness.getValue().automationDialogInitialValues).toEqual( - expect.objectContaining({ - name: "每日趋势摘要 自动化", - workspace_id: "project-1", - }), - ); - - await act(async () => { - await harness.getValue().handleAutomationDialogSubmit({ - mode: "create", - request: { - name: "每日趋势摘要 自动化", - description: "每天跟踪 AI Agent 方向", - enabled: true, - workspace_id: "project-1", - execution_mode: "intelligent", - schedule: { - kind: "every", - every_secs: 1800, - }, - payload: { - kind: "agent_turn", - prompt: "请持续跟踪 AI Agent 产品和云厂商动作。", - system_prompt: null, - web_search: true, - request_metadata: { - custom: true, - }, - }, - delivery: { - mode: "none", - channel: null, - target: null, - best_effort: true, - output_schema: "text", - output_format: "text", - }, - timeout_secs: null, - max_retries: 3, - }, - }); - }); - - expect(mockCreateAutomationJob).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - kind: "agent_turn", - prompt: "请持续跟踪 AI Agent 产品和云厂商动作。", - request_metadata: expect.objectContaining({ - custom: true, - sceneapp: expect.objectContaining({ - id: "daily-trend-briefing", - }), - }), - }), - }), - ); - expect(harness.getValue().automationDialogOpen).toBe(false); - expect(mockToastSuccess).toHaveBeenCalledWith( - "SceneApp 自动化已创建:每日趋势摘要 自动化", - ); - expect( - selectAgentUiProjectionEventsByType( - conversationProjectionStore.getSnapshot(), - "agent.changed", - ), - ).toEqual([ - expect.objectContaining({ - sourceType: "automation_job_projection", - taskId: "job-1", - agentId: "job-1", - agentName: "每日趋势摘要 自动化", - surface: "background_teammate", - runtimeEntity: "automation_job", - }), - ]); - }); -}); diff --git a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts deleted file mode 100644 index 2d2c5e19b..000000000 --- a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { listSceneAppCatalog, type SceneAppCatalog } from "@/lib/api/sceneapp"; -import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; -import { - buildSceneAppEntryCard, - FEATURED_SCENEAPP_IDS, - readStoredSceneAppCatalog, - resolveSceneAppSeed, - useSceneAppLaunchRuntime, -} from "@/lib/sceneapp"; -import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; -import type { CreationMode } from "../components/types"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import { extractExplicitUrlFromText } from "../utils/browserAssistIntent"; -import type { Page, PageParams } from "@/types/page"; - -interface UseWorkspaceSceneAppEntryActionsParams { - activeTheme: string; - creationMode: CreationMode; - projectId?: string | null; - input: string; - selectedText?: string; - defaultToolPreferences: ChatToolPreferences; - onNavigate?: (page: Page, params?: PageParams) => void; - catalogLoadMode?: "immediate" | "deferred"; - catalogDeferredDelayMs?: number; -} - -const SCENEAPP_CATALOG_IDLE_TIMEOUT_MS = 1_500; - -export function useWorkspaceSceneAppEntryActions({ - activeTheme, - creationMode, - projectId, - input, - selectedText, - defaultToolPreferences, - onNavigate, - catalogLoadMode = "immediate", - catalogDeferredDelayMs, -}: UseWorkspaceSceneAppEntryActionsParams) { - const [catalog, setCatalog] = useState(null); - const [sceneAppsLoading, setSceneAppsLoading] = useState(false); - const launchRuntime = useSceneAppLaunchRuntime({ - activeTheme, - creationMode, - projectId, - defaultToolPreferences, - onNavigate, - }); - - useEffect(() => { - if (activeTheme !== "general") { - setCatalog(null); - setSceneAppsLoading(false); - return; - } - - let cancelled = false; - const loadCatalog = () => { - if (cancelled) { - return; - } - - setSceneAppsLoading(true); - void listSceneAppCatalog() - .then((nextCatalog) => { - if (cancelled) { - return; - } - setCatalog(nextCatalog); - }) - .catch((error) => { - if (cancelled) { - return; - } - - const storedCatalog = readStoredSceneAppCatalog(); - if (storedCatalog) { - setCatalog(storedCatalog); - return; - } - - console.warn("[SceneApp] 加载目录失败:", error); - setCatalog(null); - }) - .finally(() => { - if (!cancelled) { - setSceneAppsLoading(false); - } - }); - }; - - const cancelDeferredLoad = - catalogLoadMode === "deferred" - ? scheduleMinimumDelayIdleTask(loadCatalog, { - minimumDelayMs: catalogDeferredDelayMs, - idleTimeoutMs: SCENEAPP_CATALOG_IDLE_TIMEOUT_MS, - }) - : null; - - if (!cancelDeferredLoad) { - loadCatalog(); - } else { - setSceneAppsLoading(false); - } - - return () => { - cancelled = true; - cancelDeferredLoad?.(); - }; - }, [activeTheme, catalogDeferredDelayMs, catalogLoadMode]); - - const descriptorMap = useMemo( - () => - new Map( - (catalog?.items ?? []).map((descriptor) => [descriptor.id, descriptor]), - ), - [catalog], - ); - - const urlCandidate = useMemo( - () => - extractExplicitUrlFromText(input) || - extractExplicitUrlFromText(selectedText || ""), - [input, selectedText], - ); - - const featuredSceneApps = useMemo(() => { - if (activeTheme !== "general") { - return [] as SceneAppEntryCardItem[]; - } - - return FEATURED_SCENEAPP_IDS.flatMap((sceneappId) => { - const descriptor = descriptorMap.get(sceneappId); - if (!descriptor) { - return []; - } - - const item = buildSceneAppEntryCard({ - descriptor, - projectId, - input, - selectedText, - urlCandidate, - }); - - return item ? [item] : []; - }); - }, [ - activeTheme, - descriptorMap, - input, - projectId, - selectedText, - urlCandidate, - ]); - - const handleLaunchSceneApp = useCallback( - async (sceneappId: string) => { - const descriptor = descriptorMap.get(sceneappId); - if (!descriptor) { - toast.error("当前全部 Skills 页尚未就绪,请稍后重试"); - return; - } - - const seed = resolveSceneAppSeed({ - descriptor, - input, - selectedText, - urlCandidate, - }); - if (!seed) { - toast.error("当前这套做法还缺少启动内容,请先补充输入"); - return; - } - - await launchRuntime.launchSceneApp({ - descriptor, - seed, - entrySource: "workspace_card", - }); - }, - [descriptorMap, input, launchRuntime, selectedText, urlCandidate], - ); - - return { - featuredSceneApps, - sceneAppsLoading, - sceneAppLaunchingId: launchRuntime.sceneAppLaunchingId, - automationDialogOpen: launchRuntime.automationDialogOpen, - automationDialogInitialValues: launchRuntime.automationDialogInitialValues, - automationWorkspaces: launchRuntime.automationWorkspaces, - automationJobSaving: launchRuntime.automationJobSaving, - handleLaunchSceneApp, - handleAutomationDialogOpenChange: - launchRuntime.handleAutomationDialogOpenChange, - handleAutomationDialogSubmit: launchRuntime.handleAutomationDialogSubmit, - }; -} diff --git a/src/components/agent/chat/workspaceEntry.ts b/src/components/agent/chat/workspaceEntry.ts index 605905f7b..3eb2c3ee1 100644 --- a/src/components/agent/chat/workspaceEntry.ts +++ b/src/components/agent/chat/workspaceEntry.ts @@ -6,7 +6,7 @@ import type { AgentProjectFileOpenTarget, AgentSiteSkillLaunchParams, } from "@/types/page"; -import type { SceneAppExecutionSummaryViewModel } from "@/lib/sceneapp/product"; +import type { SceneAppExecutionSummaryViewModel } from "@/lib/agent/legacySceneAppExecutionSummary"; import type { CreationMode } from "./components/types"; import type { MessageImage } from "./types"; import type { ChatToolPreferences } from "./utils/chatToolPreferences"; diff --git a/src/components/automation/AutomationPage.tsx b/src/components/automation/AutomationPage.tsx index 9689922fa..70e8d7b3a 100644 --- a/src/components/automation/AutomationPage.tsx +++ b/src/components/automation/AutomationPage.tsx @@ -18,7 +18,6 @@ export function AutomationPage({ mode="workspace" initialSelectedJobId={pageParams?.selectedJobId} initialWorkspaceTab={pageParams?.workspaceTab} - onNavigate={onNavigate} onOpenSettings={() => onNavigate?.("settings", { tab: SettingsTabs.Automation }) } diff --git a/src/components/experts/ExpertPlazaPage.test.tsx b/src/components/experts/ExpertPlazaPage.test.tsx new file mode 100644 index 000000000..552ff6995 --- /dev/null +++ b/src/components/experts/ExpertPlazaPage.test.tsx @@ -0,0 +1,305 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ExpertPlazaPage } from "./ExpertPlazaPage"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import { + expertAnalyticsStorageKeys, + getSeededExpertCatalog, + saveCachedExpertCatalog, + upsertExpertAgentInstance, +} from "@/features/experts"; + +interface MountedContent { + container: HTMLDivElement; + root: Root; +} + +const mountedContents: MountedContent[] = []; + +function renderPage(onNavigate = vi.fn()) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedContents.push({ container, root }); + return { container, onNavigate }; +} + +async function flushEffects(times = 4) { + await act(async () => { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } + }); +} + +function readQueuedExpertEvents() { + return JSON.parse( + window.localStorage.getItem(expertAnalyticsStorageKeys.queue) || "[]", + ) as Array<{ expertId: string; eventName: string; metadata?: unknown }>; +} + +function cacheCloudExpertCatalog() { + const catalog = getSeededExpertCatalog(); + saveCachedExpertCatalog({ + ...catalog, + tenantId: "tenant-0001", + version: "tenant-0001:test", + items: catalog.items.map((item, index) => ({ + ...item, + source: "cloud_catalog" as const, + release: { + ...item.release, + releaseId: `expert-release-${String(index + 1).padStart(4, "0")}`, + }, + })), + }); + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://lime.example.com", + tenantId: "tenant-0001", + }; +} + +describe("ExpertPlazaPage", () => { + beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await changeLimeLocale("zh-CN"); + window.localStorage.clear(); + }); + + afterEach(() => { + while (mountedContents.length > 0) { + const mounted = mountedContents.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + vi.unstubAllGlobals(); + delete window.__LIME_OEM_CLOUD__; + }); + + it("应渲染 seeded 专家广场卡片与榜单", async () => { + const { container } = renderPage(); + await flushEffects(); + + expect( + container.querySelector('[data-testid="expert-plaza-page"]'), + ).not.toBeNull(); + expect( + container.querySelector( + '[data-testid="expert-card-marketing-strategist"]', + ), + ).not.toBeNull(); + expect(container.textContent).toContain("营销策略专家"); + expect(container.textContent).toContain("热门精选"); + }); + + it("点击开始对话应进入 Agent 并携带专家 request metadata", async () => { + const onNavigate = vi.fn(); + const { container } = renderPage(onNavigate); + await flushEffects(); + + const startButton = container.querySelector( + '[data-testid="expert-start-marketing-strategist"]', + ); + expect(startButton).not.toBeNull(); + + act(() => { + startButton?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + agentEntry: "claw", + projectId: "default", + initialSessionName: "营销策略专家", + initialUserPrompt: + expect.stringContaining("请以「营销策略专家」专家身份工作"), + autoRunInitialPromptOnMount: true, + newChatAt: expect.any(Number), + expertAgentLaunch: expect.objectContaining({ + tenantId: "local-seeded", + expertId: "marketing-strategist", + releaseId: "rel-marketing-strategist-20260515", + launchMode: "resume_or_create", + agentInstanceKey: + "local-seeded:marketing-strategist:rel-marketing-strategist-20260515", + }), + initialRequestMetadata: { + expert: expect.objectContaining({ + expertId: "marketing-strategist", + personaRef: "expert-persona:marketing-strategist@1.0.0", + }), + harness: { + expert: expect.objectContaining({ + expert_id: "marketing-strategist", + persona_ref: "expert-persona:marketing-strategist@1.0.0", + }), + }, + }, + initialAutoSendRequestMetadata: { + expert: expect.objectContaining({ + expertId: "marketing-strategist", + }), + harness: { + expert: expect.objectContaining({ + expert_id: "marketing-strategist", + }), + }, + }, + }), + ); + }); + + it("再次点击已有专家 Agent 时应恢复最近会话且不重复自动发送", async () => { + upsertExpertAgentInstance({ + tenantId: "local-seeded", + expertId: "marketing-strategist", + releaseId: "rel-marketing-strategist-20260515", + latestSessionId: "session-expert-1", + skillRefsOverride: ["service-skill:daily-trend-briefing", "skill:docx"], + now: 1, + }); + const onNavigate = vi.fn(); + const { container } = renderPage(onNavigate); + await flushEffects(); + + const startButton = container.querySelector( + '[data-testid="expert-start-marketing-strategist"]', + ); + expect(startButton?.textContent).toContain("继续对话"); + + act(() => { + startButton?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + initialSessionId: "session-expert-1", + autoRunInitialPromptOnMount: false, + expertAgentLaunch: expect.objectContaining({ + latestSessionId: "session-expert-1", + skillRefsOverride: [ + "service-skill:daily-trend-briefing", + "skill:docx", + ], + }), + initialRequestMetadata: expect.objectContaining({ + expert: expect.objectContaining({ + skillRefs: ["service-skill:daily-trend-briefing", "skill:docx"], + }), + }), + }), + ); + const [, params] = onNavigate.mock.calls[0] || []; + expect(params).not.toHaveProperty("initialUserPrompt"); + expect(params).not.toHaveProperty("newChatAt"); + }); + + it("点击添加应把专家写入本地 overlay", async () => { + cacheCloudExpertCatalog(); + const { container } = renderPage(); + await flushEffects(); + + const addButton = container.querySelector( + '[data-testid="expert-add-data-analyst"]', + ); + expect(addButton).not.toBeNull(); + + act(() => { + addButton?.click(); + }); + + const raw = window.localStorage.getItem("lime:expert-install-overlay:v1"); + expect(raw).toContain("data-analyst"); + expect(readQueuedExpertEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + expertId: "data-analyst", + eventName: "expert_installed", + }), + ]), + ); + }); + + it("打开详情应记录非内容型运营事件", async () => { + cacheCloudExpertCatalog(); + const { container } = renderPage(); + await flushEffects(); + + const expertCardHead = container.querySelector( + '[data-testid="expert-card-marketing-strategist"] button', + ); + expect(expertCardHead).not.toBeNull(); + + act(() => { + expertCardHead?.click(); + }); + + expect(readQueuedExpertEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + expertId: "marketing-strategist", + eventName: "expert_detail_opened", + }), + ]), + ); + }); + + it("详情操作区应并列保留主对话与新对话入口", async () => { + const onNavigate = vi.fn(); + const { container } = renderPage(onNavigate); + await flushEffects(); + + const expertCardHead = container.querySelector( + '[data-testid="expert-card-code-literature"] button', + ); + expect(expertCardHead).not.toBeNull(); + + act(() => { + expertCardHead?.click(); + }); + + const detailActions = container.querySelector( + '[data-testid="expert-detail-actions-code-literature"]', + ); + expect(detailActions?.textContent).toContain("开始对话"); + expect(detailActions?.textContent).toContain("新对话"); + + const newThreadButton = container.querySelector( + '[data-testid="expert-new-thread-code-literature"]', + ); + expect(newThreadButton).not.toBeNull(); + + act(() => { + newThreadButton?.click(); + }); + + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + initialSessionName: "代码文学家", + autoRunInitialPromptOnMount: true, + newChatAt: expect.any(Number), + expertAgentLaunch: expect.objectContaining({ + expertId: "code-literature", + releaseId: "rel-code-literature-20260515", + launchMode: "new_thread", + }), + }), + ); + }); +}); diff --git a/src/components/experts/ExpertPlazaPage.tsx b/src/components/experts/ExpertPlazaPage.tsx new file mode 100644 index 000000000..375ee45d5 --- /dev/null +++ b/src/components/experts/ExpertPlazaPage.tsx @@ -0,0 +1,1007 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import type { ExpertAgentLaunchMode, Page, PageParams } from "@/types/page"; +import { buildClawAgentParams } from "@/lib/workspace/navigation"; +import { + buildExpertRuntimeMetadata, + buildExpertCatalogProjection, + buildExpertAgentInstanceKey, + findExpertAgentInstance, + formatExpertRefList, + getExpertCatalog, + getSeededExpertCatalog, + recordExpertLaunch, + recordExpertCatalogEvent, + refreshExpertAgentInstancesFromCloud, + readExpertInstallOverlay, + upsertInstalledExpert, + type ExpertCatalog, + type ExpertCatalogEventName, + type ExpertCatalogProjectionItem, + type ExpertInstallOverlayRecord, +} from "@/features/experts"; + +interface ExpertPlazaPageProps { + onNavigate?: (page: Page, params?: PageParams) => void; +} + +const Shell = styled.div.attrs({ + className: "lime-workbench-theme-scope", +})` + flex: 1; + min-height: 0; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + box-sizing: border-box; + background: + radial-gradient( + circle at 8% 0%, + var(--lime-home-glow-secondary, rgba(14, 165, 233, 0.08)), + transparent 28rem + ), + radial-gradient( + circle at 96% 2%, + var(--lime-home-glow-primary, rgba(16, 185, 129, 0.08)), + transparent 24rem + ), + var(--lime-stage-surface, #f8fafc); + padding: 24px; + color: var(--lime-text-strong, #0f172a); + + @media (max-width: 720px) { + padding: 14px; + } +`; + +const Content = styled.div` + margin: 0 auto; + display: flex; + min-width: 0; + width: min(1440px, 100%); + flex-direction: column; + gap: 18px; +`; + +const Header = styled.header` + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + + @media (max-width: 720px) { + align-items: flex-start; + flex-direction: column; + } +`; + +const TitleGroup = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const Eyebrow = styled.span` + color: var(--lime-brand-strong, #0f766e); + font-size: 12px; + font-weight: 760; +`; + +const Title = styled.h1` + margin: 0; + color: var(--lime-text-strong, #0f172a); + font-size: clamp(24px, 3vw, 34px); + letter-spacing: -0.03em; +`; + +const Subtitle = styled.p` + margin: 0; + max-width: 680px; + color: var(--lime-text-muted, #64748b); + font-size: 14px; + line-height: 1.7; +`; + +const SyncPill = styled.span` + border-radius: 999px; + border: 1px solid var(--lime-surface-border-strong, rgba(148, 163, 184, 0.45)); + background: var(--lime-surface, #fff); + padding: 8px 12px; + color: var(--lime-text, #475569); + font-size: 12px; + font-weight: 680; +`; + +const RankingGrid = styled.section` + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + + @media (max-width: 960px) { + grid-template-columns: minmax(0, 1fr); + } +`; + +const RankingCard = styled.article` + min-width: 0; + box-sizing: border-box; + border: 1px solid var(--lime-card-subtle-border, rgba(226, 232, 240, 0.92)); + border-radius: 24px; + background: var(--lime-surface, #fff); + padding: 18px; + box-shadow: 0 18px 42px + color-mix( + in srgb, + var(--lime-shadow-color, rgba(15, 23, 42, 0.12)) 34%, + transparent + ); +`; + +const RankingTitle = styled.h2` + margin: 0 0 12px; + font-size: 15px; +`; + +const RankingList = styled.ol` + margin: 0; + display: flex; + list-style: none; + flex-direction: column; + gap: 10px; + padding: 0; +`; + +const RankingItem = styled.li` + display: flex; + align-items: center; + gap: 10px; + color: var(--lime-text, #334155); + font-size: 13px; + font-weight: 650; +`; + +const Rank = styled.span` + display: inline-flex; + height: 22px; + width: 22px; + align-items: center; + justify-content: center; + border-radius: 8px; + background: var(--lime-brand-soft, #ecfeff); + color: var(--lime-brand-strong, #0f766e); + font-size: 12px; +`; + +const Toolbar = styled.section` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92)); + border-radius: 22px; + background: var(--lime-surface, #fff); + padding: 12px; +`; + +const CategoryList = styled.div` + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 8px; +`; + +const CategoryButton = styled.button<{ $active: boolean }>` + border: 1px solid + ${(props) => + props.$active + ? "var(--lime-brand, #10b981)" + : "var(--lime-surface-border, #e2e8f0)"}; + border-radius: 999px; + background: ${(props) => + props.$active + ? "var(--lime-brand-soft, #ecfdf5)" + : "var(--lime-surface-soft, #f8fafc)"}; + padding: 7px 12px; + color: ${(props) => + props.$active + ? "var(--lime-brand-strong, #166534)" + : "var(--lime-text, #475569)"}; + font-size: 12px; + font-weight: 720; + cursor: pointer; + transition: + border-color 0.16s ease, + background 0.16s ease, + color 0.16s ease; + + &:hover { + border-color: var(--lime-brand, #10b981); + background: var(--lime-surface-hover, #f4fdf4); + color: var(--lime-brand-strong, #166534); + } +`; + +const SearchInput = styled.input` + flex: 1 1 240px; + min-height: 36px; + min-width: 0; + width: min(320px, 100%); + border: 1px solid var(--lime-surface-border, #e2e8f0); + border-radius: 999px; + background: var(--lime-surface-soft, #f8fafc); + padding: 0 14px; + color: var(--lime-text-strong, #0f172a); + outline: none; + + &::placeholder { + color: var(--lime-text-muted, #94a3b8); + } + + &:focus { + border-color: var(--lime-brand, #10b981); + box-shadow: 0 0 0 4px var(--lime-focus-ring, rgba(52, 171, 103, 0.18)); + } +`; + +const CardGrid = styled.section` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); + gap: 14px; +`; + +const ExpertCard = styled.article` + display: flex; + min-height: 188px; + min-width: 0; + box-sizing: border-box; + flex-direction: column; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--lime-card-subtle-border, rgba(226, 232, 240, 0.94)); + border-radius: 22px; + background: var(--lime-surface, #fff); + padding: 18px; + box-shadow: 0 16px 34px + color-mix( + in srgb, + var(--lime-shadow-color, rgba(15, 23, 42, 0.12)) 30%, + transparent + ); + transition: + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; + + &:hover { + border-color: var( + --lime-home-card-hover-border, + var(--lime-surface-border-strong, #c7e7d1) + ); + box-shadow: 0 18px 40px + color-mix( + in srgb, + var(--lime-shadow-color, rgba(15, 23, 42, 0.12)) 42%, + transparent + ); + transform: translateY(-1px); + } +`; + +const ExpertHead = styled.button` + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + border: 0; + background: transparent; + padding: 0; + text-align: left; + cursor: pointer; +`; + +const Avatar = styled.span` + display: inline-flex; + height: 42px; + width: 42px; + align-items: center; + justify-content: center; + border-radius: 16px; + background: var(--lime-brand-soft, #ecfeff); + font-size: 23px; +`; + +const ExpertTitle = styled.h3` + margin: 0; + color: var(--lime-text-strong, #0f172a); + font-size: 15px; + overflow-wrap: anywhere; +`; + +const ExpertSummary = styled.p` + margin: 6px 0 0; + color: var(--lime-text-muted, #64748b); + font-size: 12px; + line-height: 1.7; + overflow-wrap: anywhere; +`; + +const CardMeta = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--lime-text-muted, #94a3b8); + font-size: 12px; +`; + +const Tag = styled.span` + border-radius: 999px; + background: var(--lime-surface-muted, #f1f5f9); + padding: 5px 8px; + color: var(--lime-text, #475569); + font-size: 11px; + font-weight: 650; +`; + +const ActionRow = styled.div` + display: grid; + grid-template-columns: minmax(0, 1.18fr) minmax(84px, 0.82fr); + gap: 8px; + + @media (max-width: 420px) { + grid-template-columns: 1fr; + } +`; + +const PrimaryButton = styled.button` + display: inline-flex; + min-height: 38px; + min-width: 0; + align-items: center; + justify-content: center; + border: 1px solid + color-mix( + in srgb, + var(--lime-brand, #10b981) 72%, + var(--lime-surface-border, #e2e8f0) + ); + border-radius: 16px; + background: var( + --lime-primary-gradient-simple, + linear-gradient(135deg, #0ea5e9 0%, #10b981 100%) + ); + padding: 0 14px; + color: #fff; + font-size: 12px; + font-weight: 760; + line-height: 1; + white-space: nowrap; + cursor: pointer; + box-shadow: 0 8px 18px + color-mix(in srgb, var(--lime-brand, #10b981) 18%, transparent); + transition: + box-shadow 0.16s ease, + filter 0.16s ease, + transform 0.16s ease; + + &:hover { + filter: saturate(1.02) brightness(1.01); + box-shadow: 0 10px 22px + color-mix(in srgb, var(--lime-brand, #10b981) 24%, transparent); + transform: translateY(-1px); + } +`; + +const SecondaryButton = styled.button` + display: inline-flex; + min-height: 38px; + min-width: 0; + align-items: center; + justify-content: center; + border: 1px solid + color-mix( + in srgb, + var(--lime-brand, #10b981) 24%, + var(--lime-surface-border, #e2e8f0) + ); + border-radius: 16px; + background: color-mix( + in srgb, + var(--lime-brand-soft, #ecfdf5) 58%, + var(--lime-surface, #fff) + ); + padding: 0 14px; + color: var(--lime-brand-strong, #166534); + font-size: 12px; + font-weight: 720; + line-height: 1; + white-space: nowrap; + cursor: pointer; + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 46%, transparent); + transition: + border-color 0.16s ease, + background 0.16s ease, + color 0.16s ease, + transform 0.16s ease; + + &:hover { + border-color: color-mix( + in srgb, + var(--lime-brand, #10b981) 52%, + var(--lime-surface-border, #e2e8f0) + ); + background: color-mix( + in srgb, + var(--lime-brand-soft, #ecfdf5) 78%, + var(--lime-surface, #fff) + ); + color: var(--lime-brand-strong, #166534); + transform: translateY(-1px); + } +`; + +const EmptyState = styled.div` + border: 1px dashed var(--lime-surface-border-strong, #cbd5e1); + border-radius: 22px; + background: var(--lime-surface, #fff); + padding: 34px; + text-align: center; + color: var(--lime-text-muted, #64748b); +`; + +const DetailOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.38); + padding: 24px; +`; + +const DetailDialog = styled.div` + display: grid; + width: min(980px, 100%); + max-height: min(720px, 88vh); + grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); + gap: 18px; + overflow: auto; + border: 1px solid var(--lime-card-subtle-border, rgba(226, 232, 240, 0.95)); + border-radius: 28px; + background: var(--lime-surface, #fff); + padding: 24px; + box-shadow: 0 24px 80px + color-mix( + in srgb, + var(--lime-shadow-color, rgba(15, 23, 42, 0.18)) 78%, + transparent + ); + + @media (max-width: 820px) { + grid-template-columns: 1fr; + } +`; + +const DetailMain = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const DetailSide = styled.aside` + display: flex; + flex-direction: column; + gap: 12px; + border-radius: 22px; + background: + linear-gradient( + 180deg, + var(--lime-brand-soft, #ecfdf5) 0%, + var(--lime-surface-soft, #f8fafc) 100% + ); + padding: 16px; +`; + +const DetailActions = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; + margin-top: 6px; + border-top: 1px solid + color-mix( + in srgb, + var(--lime-brand, #10b981) 16%, + var(--lime-surface-border, #e2e8f0) + ); + padding-top: 12px; + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +const DetailPrimaryButton = styled(PrimaryButton)` + width: 100%; + min-height: 44px; + padding: 0 18px; + font-size: 13px; +`; + +const DetailSecondaryButton = styled(SecondaryButton)` + width: 100%; + min-height: 44px; + padding: 0 18px; + font-size: 13px; +`; + +const DetailTitleRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +`; + +const CloseButton = styled.button` + border: 1px solid var(--lime-surface-border, #e2e8f0); + border-radius: 999px; + background: var(--lime-surface, #fff); + padding: 8px 10px; + color: var(--lime-text, #334155); + cursor: pointer; + + &:hover { + border-color: var(--lime-brand, #10b981); + background: var(--lime-brand-soft, #ecfdf5); + color: var(--lime-brand-strong, #166534); + } +`; + +const DetailBlock = styled.section` + border: 1px solid var(--lime-surface-border, #e2e8f0); + border-radius: 18px; + background: var(--lime-surface-soft, #f8fafc); + padding: 14px; +`; + +const DetailBlockTitle = styled.h4` + margin: 0 0 8px; + color: var(--lime-text-strong, #0f172a); + font-size: 13px; +`; + +function resolveAvatarValue(item: ExpertCatalogProjectionItem) { + return item.avatar.kind === "emoji" ? item.avatar.value : "✨"; +} + +function formatCount(value: number) { + if (value >= 10_000) { + return `${(value / 10_000).toFixed(1)}w`; + } + return String(value); +} + +function findOverlayForExpert( + overlays: ExpertInstallOverlayRecord[], + expertId: string, +) { + return overlays.find((overlay) => overlay.expertId === expertId) ?? null; +} + +function resolveExpertAgentIdentity( + catalog: ExpertCatalog, + item: ExpertCatalogProjectionItem, +) { + return { + tenantId: catalog.tenantId, + expertId: item.id, + releaseId: item.release.releaseId, + }; +} + +function buildExpertCatalogEvent( + item: ExpertCatalogProjectionItem, + catalog: ExpertCatalog, + eventName: ExpertCatalogEventName, + sourceSurface: string, + metadata: Record = {}, +) { + if (catalog.tenantId === "local-seeded" || item.source !== "cloud_catalog") { + return null; + } + return { + expertId: item.id, + releaseId: item.release.releaseId, + eventName, + sourceSurface, + catalogVersion: catalog.version, + occurredAt: new Date().toISOString(), + metadata: { + category: item.category, + installed: String(item.installed), + ...metadata, + }, + }; +} + +export function ExpertPlazaPage({ onNavigate }: ExpertPlazaPageProps) { + const { t } = useTranslation("agent"); + const [catalog, setCatalog] = useState(() => + getSeededExpertCatalog(), + ); + const [overlays, setOverlays] = useState(() => + readExpertInstallOverlay(), + ); + const [category, setCategory] = useState("all"); + const [query, setQuery] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const [syncedFromCloud, setSyncedFromCloud] = useState(false); + const [, setInstanceSyncVersion] = useState(0); + const impressionKeysRef = useRef>(new Set()); + + useEffect(() => { + let cancelled = false; + void getExpertCatalog({ refreshRemote: true }).then((nextCatalog) => { + if (cancelled) { + return; + } + setCatalog(nextCatalog); + setSyncedFromCloud(nextCatalog.tenantId !== "local-seeded"); + }); + void refreshExpertAgentInstancesFromCloud() + .then(() => { + if (!cancelled) { + setInstanceSyncVersion((version) => version + 1); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + const projection = useMemo( + () => buildExpertCatalogProjection(catalog, { category, query, overlays }), + [catalog, category, overlays, query], + ); + const selected = + projection.items.find((item) => item.id === selectedId) ?? null; + + useEffect(() => { + projection.items.slice(0, 12).forEach((item, index) => { + const key = `${catalog.version}:${item.release.releaseId}:expert_impression`; + if (impressionKeysRef.current.has(key)) { + return; + } + impressionKeysRef.current.add(key); + const event = buildExpertCatalogEvent( + item, + catalog, + "expert_impression", + "expert_plaza", + { + position: String(index + 1), + queryActive: String(Boolean(query.trim())), + rankingKeys: item.rankingKeys.slice(0, 3).join(","), + }, + ); + if (event) { + void recordExpertCatalogEvent(event); + } + }); + }, [catalog, projection.items, query]); + + const handleOpenDetail = (item: ExpertCatalogProjectionItem) => { + setSelectedId(item.id); + const event = buildExpertCatalogEvent( + item, + catalog, + "expert_detail_opened", + "expert_plaza", + { queryActive: String(Boolean(query.trim())) }, + ); + if (event) { + void recordExpertCatalogEvent(event); + } + }; + + const handleInstall = (item: ExpertCatalogProjectionItem) => { + setOverlays((current) => upsertInstalledExpert(current, item)); + const event = buildExpertCatalogEvent( + item, + catalog, + "expert_installed", + "expert_plaza", + ); + if (event) { + void recordExpertCatalogEvent(event); + } + }; + + const handleStart = ( + item: ExpertCatalogProjectionItem, + launchMode: ExpertAgentLaunchMode = "resume_or_create", + ) => { + const nextOverlays = recordExpertLaunch(overlays, item); + setOverlays(nextOverlays); + const identity = resolveExpertAgentIdentity(catalog, item); + const existingInstance = + launchMode === "resume_or_create" + ? findExpertAgentInstance(identity) + : null; + const shouldResumeExisting = Boolean(existingInstance?.latestSessionId); + const event = buildExpertCatalogEvent( + item, + catalog, + "expert_chat_started", + "expert_plaza", + { + launchMode, + reusedSession: String(shouldResumeExisting), + }, + ); + if (event) { + void recordExpertCatalogEvent(event); + } + + const starter = + item.promptStarters[0] || + t("agentExperts.chat.defaultPrompt", { title: item.title }); + const noneLabel = t("agentExperts.meta.none"); + const requestMetadata = buildExpertRuntimeMetadata(item, { + catalogVersion: catalog.version, + tenantId: catalog.tenantId, + overlay: findOverlayForExpert(nextOverlays, item.id), + skillRefsOverride: existingInstance?.skillRefsOverride, + }); + const initialUserPrompt = t("agentExperts.chat.runtimePrompt", { + title: item.title, + summary: item.summary, + personaRef: item.release.personaRef, + memoryTemplateRef: item.release.memoryTemplateRef || noneLabel, + skillRefs: formatExpertRefList( + requestMetadata.expert.skillRefs, + noneLabel, + ), + workflowRefs: formatExpertRefList( + requestMetadata.expert.workflowRefs, + noneLabel, + ), + starter, + }); + onNavigate?.( + "agent", + buildClawAgentParams({ + projectId: "default", + ...(shouldResumeExisting ? {} : { initialUserPrompt }), + initialSessionName: item.title, + initialSessionId: existingInstance?.latestSessionId, + entryBannerMessage: t("agentExperts.chat.banner", { + title: item.title, + }), + ...(shouldResumeExisting + ? { autoRunInitialPromptOnMount: false } + : { + autoRunInitialPromptOnMount: true, + newChatAt: Date.now(), + initialAutoSendRequestMetadata: requestMetadata, + }), + initialRequestMetadata: requestMetadata, + expertAgentLaunch: { + ...identity, + agentInstanceKey: buildExpertAgentInstanceKey(identity), + catalogVersion: catalog.version, + launchMode, + title: item.title, + latestSessionId: existingInstance?.latestSessionId, + skillRefsOverride: existingInstance?.skillRefsOverride, + }, + }), + ); + }; + + const getStartActionLabel = (item: ExpertCatalogProjectionItem) => + findExpertAgentInstance(resolveExpertAgentIdentity(catalog, item)) + ?.latestSessionId + ? t("agentExperts.actions.continue") + : t("agentExperts.actions.start"); + + const readinessLabel = (item: ExpertCatalogProjectionItem) => { + if (item.release.readiness?.missingSkillRefs?.length) { + return t("agentExperts.readiness.missingSkills", { + count: item.release.readiness.missingSkillRefs.length, + }); + } + if (item.release.readiness?.requiresProject) { + return t("agentExperts.readiness.requiresProject"); + } + if (item.release.readiness?.requiresBrowser) { + return t("agentExperts.readiness.requiresBrowser"); + } + if (item.release.readiness?.requiresModel) { + return t("agentExperts.readiness.requiresModel"); + } + return t("agentExperts.readiness.ready"); + }; + + return ( + + +
    + + {t("agentExperts.eyebrow")} + {t("agentExperts.title")} + {t("agentExperts.subtitle")} + + + {syncedFromCloud + ? t("agentExperts.sync.cloud") + : t("agentExperts.sync.seeded")} + +
    + + + {projection.rankings.slice(0, 3).map((ranking) => ( + + {ranking.title} + + {ranking.profiles.slice(0, 3).map((profile, index) => ( + + {index + 1} + {resolveAvatarValue(profile)} + {profile.title} + + ))} + + + ))} + + + + + {projection.categories.map((item) => ( + setCategory(item.key)} + > + {item.title} + + ))} + + setQuery(event.target.value)} + /> + + + {projection.items.length > 0 ? ( + + {projection.items.map((item) => ( + + handleOpenDetail(item)} + > + {resolveAvatarValue(item)} +
    + {item.title} + {item.summary} +
    +
    + + + {formatCount(item.stats.usageCount)}{" "} + {t("agentExperts.meta.usage")} + + + {formatCount(item.stats.likeCount)}{" "} + {t("agentExperts.meta.likes")} + + {item.tags.slice(0, 2).map((tag) => ( + {tag} + ))} + {readinessLabel(item)} + + + handleStart(item)} + > + {getStartActionLabel(item)} + + handleInstall(item)} + > + {item.installed + ? t("agentExperts.actions.installed") + : t("agentExperts.actions.add")} + + +
    + ))} +
    + ) : ( + {t("agentExperts.empty")} + )} +
    + + {selected ? ( + + + + + + {selected.category} + {selected.title} + {selected.summary} + + setSelectedId(null)}> + {t("agentExperts.detail.close")} + + + {selected.showcase.map((showcase) => ( + + {showcase.title} + {showcase.body} + + ))} + + + {t("agentExperts.detail.workflow")} + + + {selected.release.workflowRefs.join(" · ")} + + + + + {t("agentExperts.detail.memory")} + + + {selected.release.memoryTemplateRef || + t("agentExperts.meta.none")} + + + + + {resolveAvatarValue(selected)} + {selected.title} + {selected.summary} + {readinessLabel(selected)} + + {selected.release.skillRefs.map((skillRef) => ( + {skillRef} + ))} + + + handleStart(selected)} + > + {getStartActionLabel(selected)} + + handleStart(selected, "new_thread")} + > + {t("agentExperts.actions.newThread")} + + + + + + ) : null} +
    + ); +} diff --git a/src/components/experts/index.ts b/src/components/experts/index.ts new file mode 100644 index 000000000..0a7cf3acd --- /dev/null +++ b/src/components/experts/index.ts @@ -0,0 +1 @@ +export { ExpertPlazaPage } from "./ExpertPlazaPage"; diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index c406fe4f1..bf619ad6b 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -1041,15 +1041,18 @@ describe("MemoryPage", () => { await Promise.resolve(); }); - expect(onNavigate).toHaveBeenCalledWith("sceneapps", { - view: "catalog", - projectId: "project-42", - referenceMemoryIds: ["memory-1"], - search: "夏日短视频语气", - prefillIntent: expect.stringContaining( - "围绕这条风格灵感继续创作:夏日短视频语气", - ), - }); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + agentEntry: "new-task", + projectId: "project-42", + entryBannerMessage: + "SceneApp 独立页已下线,已改从 Agent App 主链继续。", + initialUserPrompt: expect.stringContaining( + "围绕这条风格灵感继续创作:夏日短视频语气", + ), + }), + ); }); it("应支持落到成果分区并对准当前续接的灵感条目", async () => { diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 85e2b9221..053363d6a 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -1550,13 +1550,24 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ? buildScenePrefillFromInspiration(projectedEntry) : `围绕这条灵感继续创作:${entry.title}`; - onNavigate("sceneapps", { - view: "catalog", - projectId: projectId || undefined, - referenceMemoryIds: [entry.id], - search: entry.title, - prefillIntent, - }); + onNavigate( + "agent", + buildHomeAgentParams({ + projectId: projectId || undefined, + entryBannerMessage: "SceneApp 独立页已下线,已改从 Agent App 主链继续。", + initialUserPrompt: prefillIntent, + initialRequestMetadata: + buildMemoryEntryCreationReplayRequestMetadata({ + id: entry.id, + projectId: projectId || undefined, + category: entry.category, + title: entry.title, + summary: entry.summary, + content: entry.content, + tags: entry.tags, + }), + }), + ); } const handleSelectDurableMemory = useCallback((memoryId: string) => { diff --git a/src/components/sceneapps/SceneAppDetailPanel.tsx b/src/components/sceneapps/SceneAppDetailPanel.tsx deleted file mode 100644 index d1e03ed93..000000000 --- a/src/components/sceneapps/SceneAppDetailPanel.tsx +++ /dev/null @@ -1,958 +0,0 @@ -import { - type SceneAppDetailViewModel, - type SceneAppRunDetailViewModel, -} from "@/lib/sceneapp"; -import { ProjectSelector } from "@/components/projects/ProjectSelector"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; -import { cn } from "@/lib/utils"; -import { SceneAppProjectPackRuntimePanel } from "./SceneAppProjectPackRuntimePanel"; - -interface SceneAppDetailPanelProps { - detailView: SceneAppDetailViewModel | null; - packRuntimeView: SceneAppRunDetailViewModel | null; - packRuntimeLoading?: boolean; - packRuntimeUsesFallback?: boolean; - projectId: string | null; - launchInput: string; - planLoading: boolean; - planError?: string | null; - saveBaselineDisabledReason?: string; - launchDisabledReason?: string; - savingContextBaseline: boolean; - launching: boolean; - onProjectChange: (projectId: string) => void; - onLaunchInputChange: (value: string) => void; - onPackRuntimeArtifactAction?: ( - action: SceneAppRunDetailViewModel["deliveryArtifactEntries"][number], - ) => void; - onSaveContextBaseline: () => void; - onLaunch: () => void; -} - -export function SceneAppDetailPanel({ - detailView, - packRuntimeView, - packRuntimeLoading = false, - packRuntimeUsesFallback = false, - projectId, - launchInput, - planLoading, - planError, - saveBaselineDisabledReason, - launchDisabledReason, - savingContextBaseline, - launching, - onProjectChange, - onLaunchInputChange, - onPackRuntimeArtifactAction, - onSaveContextBaseline, - onLaunch, -}: SceneAppDetailPanelProps) { - if (!detailView) { - return ( -
    - 先回到全部 Skills 选一个 Skill,再来补参考、启动信息和结果落点。 -
    - ); - } - - return ( -
    -
    -
    -
    -
    - {detailView.businessLabel} -
    -

    - {detailView.title} -

    -

    - {detailView.summary} -

    -
    - -
    - - {detailView.deliveryContractLabel} - - - {detailView.planning.statusLabel} - - {detailView.planning.unmetRequirements.length ? ( - - 待补 {detailView.planning.unmetRequirements.length} 项 - - ) : null} -
    -
    - -
    -
    - {detailView.valueStatement} -
    -
    - - 会拿到:{detailView.outputHint} - - - 推进方式:{detailView.executionChainLabel} - -
    -
    -
    - -
    -
    完整说明
    -
    - 需要时再看来源、步骤、结果口径和默认判断。 -
    -
    - - 展开 - - - 收起 - -
    - -
    -
    -
    - 来源: - {detailView.sourcePackageId} -
    -
    - 版本: - {detailView.sourcePackageVersion} -
    -
    - -
    -
    -
    - 这个 Skill 擅长 -
    -
    - {detailView.patternLabels.map((patternLabel) => ( - - {patternLabel} - - ))} -
    -
    - -
    -
    - 启动前先确认 -
    - {detailView.launchRequirements.length === 0 ? ( -

    - 这个 Skill 没有额外前置条件,可以直接进入生成。 -

    - ) : ( -
    - {detailView.launchRequirements.map((message, index) => ( -
    - {message} -
    - ))} -
    - )} -
    -
    - -
    -
    -
    - 默认会拿到 -
    -

    - {detailView.deliveryNarrative} -

    -
    -
    - - 默认主结果: - - {detailView.outputHint} -
    - {detailView.deliveryPrimaryPart ? ( -
    - - 默认主件: - - {detailView.deliveryPrimaryPart} -
    - ) : null} - {detailView.deliveryViewerLabel ? ( -
    - - 默认入口: - - {detailView.deliveryViewerLabel} -
    - ) : null} - {detailView.artifactProfileRef ? ( -
    - - 结果说明: - - {detailView.artifactProfileRef} -
    - ) : null} -
    - {detailView.deliveryRequiredParts.length ? ( -
    - {detailView.deliveryRequiredParts.map((part) => ( - - {part.label} - - ))} -
    - ) : ( -

    - 当前这个 Skill 还没有明确必含结果,后续需要继续补齐。 -

    - )} -
    - -
    -
    - 通常会这样推进 -
    - {detailView.compositionBlueprintRef ? ( -
    -
    - - 步骤说明: - - {detailView.compositionBlueprintRef} -
    -
    - - 阶段数: - - {detailView.compositionStepCount} -
    - {detailView.compositionSteps.length ? ( -
    - {detailView.compositionSteps.map((step) => ( -
    -
    - {step.title} -
    - {step.bindingLabel ? ( -
    - 承接面:{step.bindingLabel} -
    - ) : null} -
    - ))} -
    - ) : null} -
    - ) : ( -

    - 当前这个 Skill - 还没有拆出明确步骤,先按当前默认路径继续推进。 -

    - )} -
    - -
    -
    - 默认怎么判断 -
    -

    - {detailView.scorecardNarrative} -

    - {detailView.scorecardProfileRef ? ( -
    - - 判断说明: - - {detailView.scorecardProfileRef} -
    - ) : null} - {detailView.scorecardMetricKeys.length ? ( -
    - {detailView.scorecardMetricKeys.map((metric) => ( - - {metric.label} - - ))} -
    - ) : ( -

    - 当前这个 Skill - 还没有整理出显式判断指标,后续会继续按真实结果补齐。 -

    - )} - {detailView.scorecardFailureSignals.length ? ( -
    -
    - 先盯这些 -
    -
    - {detailView.scorecardFailureSignals.map((signal) => ( - - {signal.label} - - ))} -
    -
    - ) : null} -
    -
    - -
    -
    -
    -
    - 这轮准备情况 -
    - - {detailView.planning.statusLabel} - -
    -

    - {detailView.planning.summary} -

    - {planLoading ? ( -

    - 正在根据当前项目与输入刷新准备情况… -

    - ) : null} - {planError ? ( -

    - {planError} -

    - ) : null} - {detailView.planning.unmetRequirements.length ? ( -
    -
    - 还差这些 -
    -
    - {detailView.planning.unmetRequirements.map( - (message, index) => ( -
    - {message} -
    - ), - )} -
    -
    - ) : null} - {detailView.contextPlan ? ( -
    -
    -
    -
    - 已带入层 -
    -
    - {detailView.contextPlan.activeLayers.length} 个 -
    -
    -
    -
    - 参考对象 -
    -
    - {detailView.contextPlan.referenceCount} 条 -
    -
    -
    -
    - 作用范围 -
    -
    - {detailView.contextPlan.scopeLabel ?? "当前这轮"} -
    -
    -
    - - {detailView.contextPlan.tasteSummary ? ( -
    - 当前风格方向: - {detailView.contextPlan.tasteSummary} -
    - ) : null} - {detailView.contextPlan.feedbackSummary ? ( -
    - 最近判断: - {detailView.contextPlan.feedbackSummary} -
    - ) : null} - {detailView.planning.warnings.length ? ( -
    - {detailView.planning.warnings.map((warning, index) => ( -
    - {warning} -
    - ))} -
    - ) : null} - -
    - -
    -
    - 带入清单 -
    -
    - 参考条目、关键词和判断信号。 -
    -
    - - 展开 - - - 收起 - -
    - -
    -
    - {detailView.contextPlan.activeLayers.length ? ( -
    -
    - 这轮已带入 -
    -
    - {detailView.contextPlan.activeLayers.map( - (layer) => ( - - {layer.label} - - ), - )} -
    -
    - ) : null} -
    - - 参考对象: - - {detailView.contextPlan.referenceCount} 条 -
    - {detailView.contextPlan.scopeLabel ? ( -
    - - 作用范围: - - {detailView.contextPlan.scopeLabel} -
    - ) : null} - {detailView.contextPlan.skillRefs.length ? ( -
    -
    - 已接入 Skill -
    -
    - {detailView.contextPlan.skillRefs.map( - (skillRef) => ( - - {skillRef.label} - - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.memoryRefs.length ? ( -
    -
    - 历史经验 -
    -
    - {detailView.contextPlan.memoryRefs.map( - (memoryRef) => ( - - {memoryRef.label} - - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.toolRefs.length ? ( -
    -
    - 可用能力 -
    -
    - {detailView.contextPlan.toolRefs.map( - (toolRef) => ( - - {toolRef.label} - - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.referenceItems.length ? ( -
    -
    - 参考条目 -
    -
    - {detailView.contextPlan.referenceItems.map( - (reference) => ( -
    -
    - - {reference.label} - - - {reference.sourceLabel} - - - {reference.contentTypeLabel} - -
    - {reference.summary ? ( -
    - {reference.summary} -
    - ) : null} - {reference.uri ? ( -
    - {reference.uri} -
    - ) : null} - {reference.usageLabel || - reference.feedbackLabel ? ( -
    - {reference.usageLabel ? ( - - {reference.usageLabel} - - ) : null} - {reference.feedbackLabel ? ( - - {reference.feedbackLabel} - - ) : null} -
    - ) : null} -
    - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.feedbackUpdatedAtLabel ? ( -
    - - 反馈更新时间: - - {detailView.contextPlan.feedbackUpdatedAtLabel} -
    - ) : null} - {detailView.contextPlan.feedbackSignals.length ? ( -
    -
    - 判断信号 -
    -
    - {detailView.contextPlan.feedbackSignals.map( - (signal) => ( - - {signal.label} - - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.tasteKeywords.length ? ( -
    -
    - 偏好关键词 -
    -
    - {detailView.contextPlan.tasteKeywords.map( - (keyword) => ( - - {keyword.label} - - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.avoidKeywords.length ? ( -
    -
    - 避免方向 -
    -
    - {detailView.contextPlan.avoidKeywords.map( - (keyword) => ( - - {keyword.label} - - ), - )} -
    -
    - ) : null} - {detailView.contextPlan.notes.length ? ( -
    - {detailView.contextPlan.notes.map( - (note, index) => ( -
    - {note} -
    - ), - )} -
    - ) : null} -
    -
    -
    -
    - ) : ( -

    - 当前还没有整理出这轮带入对象,继续补项目或启动输入后会自动刷新。 -

    - )} -
    -
    -
    -
    - 默认结果去向 -
    - {detailView.projectPackPlan ? ( - - {detailView.projectPackPlan.completionStrategyLabel} - - ) : null} -
    - {detailView.projectPackPlan ? ( - <> -
    - - {detailView.projectPackPlan.packKindLabel} - - {detailView.projectPackPlan.primaryPart ? ( - - 主件:{detailView.projectPackPlan.primaryPart} - - ) : null} - {detailView.projectPackPlan.viewerLabel ? ( - - {detailView.projectPackPlan.viewerLabel} - - ) : null} -
    - {detailView.projectPackPlan.requiredParts.length ? ( -
    - {detailView.projectPackPlan.requiredParts.map( - (part) => ( - - {part.label} - - ), - )} -
    - ) : ( -

    - 当前这个 Skill - 还没有明确整套结果的必含部分,继续沿现有结果回流主链执行。 -

    - )} - {detailView.projectPackPlan.notes.length ? ( -
    - - - 结果规则 - - - 展开 - - - 收起 - - -
    -
    - {detailView.projectPackPlan.notes.map( - (note, index) => ( -
    - {note} -
    - ), - )} -
    -
    -
    - ) : null} - - ) : ( -

    - 当前这个 Skill 还没有明确结果去向,继续沿现有结果约定运行。 -

    - )} -
    -
    -
    -
    - -
    -
    -
    -
    -
    - 开始生成 -
    -

    - 只填项目和目标。 -

    -
    - - {detailView.launchActionLabel} - -
    - -
    -
    -
    - 结果项目 -
    -
    - -
    -
    -
    -
    - 这次目标 -
    -