diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7f2da598a..1d99276af 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,92 +1,88 @@ -## Lime v1.23.0 +## Lime v1.24.0 -发布日期:`2026-04-29` +发布日期:`2026-04-30` ### 发布概览 -- 本次发布目标 tag 为 `v1.23.0`。 -- 本次发布聚焦稳定版 GitHub Release / R2 分发链路收口、lime-cli 独立产物发布、Provider / Credential 旧路径清退、云端用户中心商业边界收口,以及 Agent 会话恢复与模型选择体验稳定性。 -- 本轮待递交内容覆盖 Rust 后端、Tauri 配置、发布工作流、release asset 脚本、Provider / Model / Credential 治理、前端 Workspace / Settings / Provider API Key 主路径、测试覆盖、版本锁文件与执行计划文档。 +- 本次发布目标 tag 为 `v1.24.0`。 +- 本次发布聚焦多模态运行合同底座、Browser / PDF / Voice / Research / Text Transform 合同闭环、任务中心与旧会话打开体验、工作台首页技能入口,以及版本发布配置同步。 +- 本轮待递交内容覆盖 Rust 后端、Tauri 配置、前端 Agent Workspace / Home / Settings / Provider / DevBridge、治理合同 registry、测试覆盖、版本锁文件、图片素材与执行计划文档。 ### 重点更新 -#### 1. 版本号同步到 v1.23.0 +#### 1. 版本号同步到 v1.24.0 -- 应用版本已同步为 `1.23.0`: +- 应用版本已同步为 `1.24.0`: - `package.json` - `package-lock.json` - `src-tauri/Cargo.toml` - `src-tauri/Cargo.lock` - `src-tauri/tauri.conf.json` - `src-tauri/tauri.conf.headless.json` -- `packages/lime-cli-npm/package.json` 与 `packages/lime-cli-npm/README.md` 已同步到 `1.23.0`,保持 CLI wrapper 与桌面 release 版本一致。 -- 浏览器模式默认 mock 的 update current version 已同步为 `1.23.0`。 -- GitHub release asset staging 测试中的当前发布样例已同步到 `v1.23.0`。 +- `packages/lime-cli-npm/package.json` 与 `packages/lime-cli-npm/README.md` 已同步到 `1.24.0`,保持 CLI wrapper 与桌面 release 版本一致。 +- 浏览器模式默认 mock 的 update current version 已同步为 `1.24.0`。 +- GitHub release asset staging 测试中的当前发布样例已同步到 `v1.24.0`。 -#### 2. 稳定版发布与 R2 分发链路 +#### 2. 多模态运行合同底座 -- `.github/workflows/release.yml` 将桌面应用构建、GitHub Release 资产发布、R2 updater 发布和 lime-cli 资产发布拆成更清晰的阶段。 -- 新增 `scripts/prepare-github-release-assets.mjs`,在上传 GitHub Release 前统一整理资产名,避免 macOS `Lime.app.tar.gz` / `.sig` 同名跨架构冲突。 -- GitHub Release 上传改为使用 `release-github-assets` 暂存目录,并在资产上传后显式发布 release、标记 latest。 -- R2 updater 发布改为独立 job,从 GitHub Release 或 `RELEASE_NOTES.md` 准备 updater release notes,再生成稳定版 manifest。 -- Cloudflare R2 上传 / 列表 / 删除命令补齐 `--remote`,并在 wrangler 不支持 `r2 object list` 时跳过旧版本清理而不是阻塞发布。 -- lime-cli release binary 与 npm wrapper 资产改为独立矩阵 job 发布,保留 macOS / Windows / Linux CLI 产物,不再耦合桌面安装包矩阵。 +- 新增并扩展 `ModalityRuntimeContract` 前端 registry 与 Rust 侧合同常量,统一 `image_generation`、`browser_control`、`pdf_extract`、`voice_generation`、`web_research`、`text_transform` 六类底层运行合同。 +- `scripts/check-modality-runtime-contracts.mjs` 与 `npm run governance:modality-contracts` 继续作为合同 registry 的机器校验入口,保护 contract key、entry binding、executor binding 与 metadata path 不漂移。 +- 图片生成任务、媒体任务 API、DevBridge mock 与治理目录册同步合同字段,避免入口层直接拥有底层 task / model / artifact / evidence 事实源。 +- `docs/roadmap/warp/contract-schema.md` 与 `docs/exec-plans/multimodal-runtime-contract-plan.md` 更新合同 schema、执行阶段与 current / compat / deprecated / dead 分类。 -#### 3. Provider / Credential 旧路径清退 +#### 3. Browser / PDF / Voice / Research / Text Transform 合同闭环 -- 清退旧 Provider Pool 页面、凭证卡片、Credential 表单、OAuth / Kiro / Antigravity / Claude OAuth / usage 等旧命令与服务路径。 -- Rust 后端删除旧 credential crate、provider pool DAO / service、Kiro credential handler、旧 provider converter / translator / fingerprint 模型等 dead surface。 -- 前端保留当前 API Key Provider 设置主路径,并继续收口模型启用、模型能力、Prompt Cache 与 companion provider 概览口径。 -- `agentCommandCatalog`、`legacySurfaceCatalog`、DevBridge mock 与相关测试同步更新,避免已删除命令继续作为 current surface 出现。 -- 模型资源索引删除旧 Antigravity / Kiro / Codex alias/provider 静态入口,减少 provider 真相源分叉。 +- `@浏览器` / Browser Assist 发送链路注入 `browser_control` 合同快照,Rust 侧保留 Browser Assist runtime hint,并把 browser tool timeline metadata 纳入 evidence / replay。 +- `@读PDF` 入口收敛到 `pdf_extract` 合同,前端 metadata、Rust prompt 准备、Skill(pdf_read) timeline、evidence pack 与 replay 检查保持同一份合同事实源。 +- `@配音` 服务型入口收敛到 `voice_generation` 合同,ServiceSkill / voice runtime 的 request metadata、timeline args 与 replay grader 统一识别音频运行证据。 +- `@搜索`、`@深搜`、`@站点搜索`、`@研报`、`@竞品` 统一归入 `web_research` 合同,并补齐 evidence `snapshotIndex`,让联网研究可以按 contract/source/routing/tool trace 检索。 +- `@读文件`、`@总结`、`@翻译`、`@分析`、`@发布合规`、`@Logo拆解` 统一归入 `text_transform` 合同,禁止回退为前端直出、ToolSearch / WebSearch 或普通聊天摘要。 -#### 4. Agent 会话恢复与工作台稳定性 +#### 4. Evidence Pack / Replay / Task Runtime -- 会话切换 / 恢复详情默认按 `historyLimit: 40` 拉取近期历史,完整历史加载仍通过显式 `historyLimit: 0` 入口完成。 -- `useAsterAgentChat` 回归断言已同步新的 session detail 拉取参数,覆盖 stop refresh、timeline cache hydrate、workspace guard 与 stale 快照刷新路径。 -- 工作台消息流、模型选择、Provider selector、Team Workspace、artifact / saved content 展示继续保持与 runtime execution metadata 对齐。 -- `ModelSelector`、`useConfiguredProviders`、`useProviderModels`、Prompt Cache 支持判断与 companion provider overview 补齐回归覆盖。 +- `runtime_evidence_pack_service` 与 `runtime_replay_case_service` 扩展多模态合同快照、索引、expected checks 与 grader 规则,回放样本能判断是否真实命中对应 executor trace。 +- 媒体 runtime 与 `media_task_cmd` 补齐合同字段归一化、能力缺口提示、artifact payload 与任务预览字段。 +- 前端 `agentRuntime` media/session 类型、`mediaTasks` API、ImageTask viewer 与工作台预览 runtime 同步消费合同字段,减少 viewer 自行猜测 artifact 类型。 +- `modalityRuntimeContracts` 的前端/Rust 双侧测试覆盖合同解析、fallback 与 mock 分发,保持浏览器模式不是假绿。 -#### 5. 云端用户中心与商业边界 +#### 5. 工作台首页、任务中心与旧会话体验 -- 新增 `docs/exec-plans/cloud-commerce-user-center-boundary.md`,明确套餐购买、支付、账单、用量明细统一收敛到 `limecore` 用户中心网页。 -- Lime 客户端云端服务设置面继续收口为会话状态、当前套餐、积分余额、待支付提醒与用户中心跳转入口。 -- 客户端移除直接创建套餐 / 充值订单的旧处理面,避免本地商业工作台与用户中心形成双轨。 -- `useOemCloudAccess` 与 OEM cloud / LimeHub provider 同步测试继续覆盖登录态、权益摘要、API Key 与回跳刷新路径。 +- Agent Chat 新增 Home Start Surface、技能卡片画廊、更多技能抽屉与场景技能管理弹窗,并提交对应 home cover 素材。 +- EmptyState / Workspace / AppSidebar 接入新的首页入口、补充快捷入口与任务中心事件桥,一级“新建任务”与会话侧栏切换保持可取消草稿语义。 +- 旧会话打开性能继续收口:首屏优先最近消息、timeline 延后物化、历史消息与流式 delta overlap 合并,降低旧会话切换卡顿与布局跳动。 +- MessageList / Team Workspace / Workspace Send Actions 增补回归,覆盖新建草稿、旧会话切换、图片任务预览和合同 metadata 发送。 -#### 6. 文档、治理与回归 +#### 6. Provider / 设置页 / 云端能力边界 -- `docs/aiprompts/` 下 Provider、Credential Pool、Services、Hooks、Components、Overview 等导航文档同步当前 provider / credential / model registry 事实源。 -- `docs/content/03.providers/1.overview.md` 与 `src/components/api-key-provider/README.md` 更新当前 Provider 配置入口说明。 -- `scripts/release-updater-manifest.test.mjs` 增加 GitHub release asset staging 覆盖,保护 macOS 同名 updater bundle 重命名逻辑。 -- `src-tauri/proptest-regressions/` 已纳入本轮待递交范围,保留 property test 回归种子。 +- Provider 与 API Key 设置页进一步收口模型能力、Prompt Cache、云端套餐 / 权益 / API Key 展示口径,避免本地设置页重新承载用户中心商业工作台。 +- OEM cloud access、LimeHub provider sync、model registry service 与 provider list 工具函数补齐测试,保护登录态、权益摘要、模型支持和 provider capability 展示。 +- `withI18nPatch`、Settings Provider 页面与相关 hooks 做格式化和回归更新,保持当前设置入口与 mock / Rust 模型事实源一致。 ### 待递交范围确认 -- 版本与发布:版本文件、lockfile、Tauri 配置、CLI wrapper、release workflow、GitHub release asset staging 脚本与测试。 -- Rust 主链:Provider / Credential / Server / Services / Agent / DevBridge / model registry / router / websocket 相关 current surface 收口。 -- 前端主链:Agent Chat Workspace、MessageList、ModelSelector、Settings Provider、API Key Provider、Provider hooks、mock 与治理目录册。 -- 商业边界:云端用户中心执行计划、OEM cloud access / LimeHub provider sync、设置页云端服务入口。 -- 验证与治理:新增/更新测试、legacy catalog、release updater contract、删除旧 Provider Pool / credential / Kiro / Antigravity 等 dead surface。 +- 版本与发布:版本文件、lockfile、Tauri 配置、CLI wrapper、release updater 测试样例与 release notes。 +- Rust 主链:多模态合同、媒体任务、Browser Assist、PDF / Research / Summary / Translation / Analysis / ServiceSkill launch、evidence pack、replay case、model registry。 +- 前端主链:Agent Chat Workspace、Home Start Surface、MessageList、ImageTask Viewer、任务中心事件桥、Settings Provider、API Key Provider、DevBridge、tauri mock。 +- 治理与文档:`modalityRuntimeContracts` registry / 校验脚本、Warp contract schema、执行计划与旧会话性能计划。 +- 素材与测试:home cover 素材、新增/更新的 Vitest、Rust 回归与治理合同测试。 ### 校验状态 -- 已通过: - - `npm run verify:app-version` +- 已执行: - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` - - `cargo test --manifest-path "src-tauri/Cargo.toml" --target-dir "src-tauri/target/codex-release-v123"` — 1085 passed / 0 failed / 2 ignored - - `cargo clippy --manifest-path "src-tauri/Cargo.toml" --target-dir "src-tauri/target/codex-release-v123" --all-targets --all-features` + - `npm run format` + - `npm run verify:app-version` + - `cargo test --manifest-path "src-tauri/Cargo.toml" --target-dir "src-tauri/target/codex-release-v124"` + - `cargo clippy --manifest-path "src-tauri/Cargo.toml" --target-dir "src-tauri/target/codex-release-v124" --all-targets --all-features` - `npm run lint` - - `npm test` — 43 个 Vitest smart 批次通过 + - `npm test` - `npm run test:contracts` + - `npm run verify:gui-smoke` - `git diff --check` -- `cargo clippy` 通过,当前存在 5 条预存 warning: - - `crates/services/src/aster_session_store.rs` 的 `manual_repeat_n` - - `crates/skills/src/lime_llm_provider.rs` 的 2 处 `too_many_arguments` - - `crates/agent/src/request_tool_policy.rs` 的 `too_many_arguments` - - `crates/agent/src/session_execution_runtime.rs` 的 `needless_lifetimes` -- GUI 主路径未额外执行 `npm run verify:gui-smoke`;本轮发布收口以版本、发布链路、Provider / Credential 治理和前端 / Rust 回归为主要风险覆盖。 +- 结果:上述校验均已通过;`cargo test` 结果为 `1103 passed; 0 failed; 0 ignored`,另有 2 个真实联网测试按预期 ignored。 +- 备注:`cargo clippy` 仍保留既有 warning(`manual_repeat_n`、`too_many_arguments`、`needless_lifetimes`),本轮未扩大 warning 面。 +- GUI 主路径:`npm run verify:gui-smoke` 已通过,覆盖 DevBridge、默认 workspace、browser runtime、site adapter catalog、服务技能入口与 runtime tool surface 页面烟测。 --- -**完整变更**: `v1.22.0` -> `v1.23.0` +**完整变更**: `v1.23.0` -> `v1.24.0` diff --git a/artifacts/message-footer-runtime-status-line.png b/artifacts/message-footer-runtime-status-line.png deleted file mode 100644 index 1647b0d00..000000000 Binary files a/artifacts/message-footer-runtime-status-line.png and /dev/null differ diff --git a/docs/exec-plans/multimodal-runtime-contract-plan.md b/docs/exec-plans/multimodal-runtime-contract-plan.md index 8f82eb7ed..b3be01aba 100644 --- a/docs/exec-plans/multimodal-runtime-contract-plan.md +++ b/docs/exec-plans/multimodal-runtime-contract-plan.md @@ -62,6 +62,87 @@ runtime identity 2. `src/lib/governance/modalityCapabilityMatrix.json` 3. contract 守卫检查 `required_capabilities` 与 `routing_slot` 是否引用已登记能力和模型角色 +### Phase 7:上层入口绑定 + +状态:进行中。 + +本阶段输出: + +1. `image_generation.bound_entries` 先登记 `@配图`、`@海报`、`@修图`、`@重绘`、`@分镜` 与显式图片动作。 +2. 前端图片 launch 从 `ModalityRuntimeContract` registry 读取 contract 字段,不再在入口文件重复维护底层 contract 常量。 +3. 守卫要求 entry binding 声明 `entry_source` 且 `launch_metadata_path` 保持在 `harness.*`。 + +### Phase 8:Browser Assist 底层合同注入 + +状态:进行中。 + +本阶段输出: + +1. `browser_control` 先作为底层 Browser Assist 运行合同进入 `harness.browser_assist`,不新增独立 browser task 协议。 +2. `@浏览器 / @Browser Agent / @Mini Tester` 仍保留原始用户消息,只把 `browser_requirement`、launch URL 与 contract snapshot 交给后端 runtime 决策。 +3. `browser_control.bound_entries` 暂不扩展;`@` 命令只是入口层,不能成为底层 contract 事实源。 +4. 后端 `BrowserAssistRuntimeHint` 必须解析并保留 `browser_control` 合同快照,browser tool result metadata 需要携带同一组 contract 字段。 +5. Evidence Pack 从 browser tool timeline metadata 导出 `browser_control` 的 `modalityRuntimeContracts`,使浏览器动作也进入 replay 可用的事实源。 +6. Replay / grader 必须把 `browser_control` 识别成 Browser Assist 回归样本,检查 browser action trace、禁止 WebSearch 替代真实浏览器动作。 + +### Phase 9:PDF Extract 底层合同注入 + +状态:进行中。 + +本阶段输出: + +1. `pdf_extract` 作为文档型底层运行合同进入 `harness.pdf_read_skill_launch.pdf_read_request`,`@读PDF` 仅保留为 entry source。 +2. 前端 `@读PDF` 发送时保留原始消息,并注入 `modality_contract_key=pdf_extract`、`required_capabilities`、`routing_slot=base_model` 与 `runtime_contract` 快照。 +3. 后端 `prepare_pdf_read_skill_launch_request_metadata` 必须补齐并保留同一组合同字段,避免旧客户端或缺省 metadata 退回普通聊天总结。 +4. `Skill(pdf_read)` prompt 必须显式要求真实读取文件、保留合同字段,并禁止 `frontend_direct_pdf_parse` / `generic_chat_summary_only` 偏航。 +5. Evidence Pack 必须能从 `Skill(pdf_read)` timeline metadata 导出 `pdf_extract` 的 `modalityRuntimeContracts`,使 PDF 读取不再只停留在入口 prompt 合同里。 +6. Replay / grader 必须把 `pdf_extract` 识别成 PDF 读取回归样本,检查 `Skill(pdf_read)` / 文件读取 trace,并禁止 WebSearch、ToolSearch、Grep 或普通聊天替代真实读取。 +7. 本阶段暂不把 PDF 单独建成 task artifact 协议;如果后续需要 PDF viewer / artifact UI,再从同一份 `pdf_extract` contract 继续下沉,不能新增平行事实源。 +8. 完成底层闭环后,`@读PDF / @pdf / @read_pdf` 只登记为 `pdf_extract.bound_entries`,前端从 registry 读取 `entry_source`,不再把入口当作运行合同事实源。 + +### Phase 10:Voice Generation 服务型合同注入 + +状态:进行中。 + +本阶段输出: + +1. `voice_generation` 作为音频型底层运行合同进入 `harness.service_scene_launch.service_scene_run`,`@配音` 仅保留为 entry source。 +2. 前端 `@配音 / @voice / @dubbing` 发送时保留原始消息,并注入 `modality_contract_key=voice_generation`、`required_capabilities`、`routing_slot=voice_generation_model` 与 `runtime_contract` 快照。 +3. 后端 `prepare_service_scene_launch_request_metadata` 对 `scene_key=voice_runtime` / `entry_source=at_voice_command` 的旧客户端 metadata 补齐同一组合同字段。 +4. 服务型场景 prompt 必须显式说明当前底层合同,禁止退回 `legacy_tts_test_command` 或伪造“云端已提交”。 +5. Evidence Pack 必须能从 voice runtime / service scene timeline args 或 metadata 导出 `voice_generation` 的 `modalityRuntimeContracts`,使配音合同进入 replay 可评分事实源。 +6. Replay / grader 必须把 `voice_generation` 识别成本地 ServiceSkill/voice runtime 回归样本,检查 `service_scene_launch(scene_key=voice_runtime)` 或后续 audio_task/audio_output 证据,并禁止 `legacy_tts_test_command`、伪造云端提交、普通聊天文本或通用文件卡替代。 +7. 本阶段新增最小 `audio_generate` task artifact 协议,把 `voice_generation` 写入标准 `audio_task/audio_output` 产物;`audio viewer` 与真实音频 worker 继续后置,避免提前拆出第二套音频执行事实源。 + +### Phase 11:Web Research 合同注入 + +状态:进行中。 + +本阶段输出: + +1. `web_research` 作为混合型底层运行合同进入 `harness.research_skill_launch.research_request`、`harness.deep_search_skill_launch.deep_search_request`、`harness.site_search_skill_launch.site_search_request` 与 `harness.report_skill_launch.report_request`。 +2. `@搜索 / @深搜 / @站点搜索 / @研报 / @竞品` 仅保留为 entry source;前端发送时注入 `modality_contract_key=web_research`、`required_capabilities`、`routing_slot=report_generation_model` 与 `runtime_contract` 快照。 +3. 后端 `prepare_*_skill_launch_request_metadata` 需要为旧客户端或缺省 metadata 补齐同一组合同字段,避免回退成模型记忆回答、本地文件搜索或通用 WebSearch 旁路。 +4. research / deep search prompt 必须显式要求首刀 `Skill(research)`,site search prompt 必须说明它是 `web_research` 合同下的站点搜索子入口且首刀 `Skill(site_search)`。 +5. Evidence Pack 必须能从 `Skill(research)` / `Skill(site_search)` / `Skill(report_generate)` timeline args 或 metadata 导出 `web_research` 的 `modalityRuntimeContracts`,使联网研究进入 replay 可评分事实源。 +6. Evidence Pack 的 `modalityRuntimeContracts.snapshotIndex` 必须提供可检索摘要:按 contract key、source、routing outcome、expected routing slot 与 tool trace 汇总,使联网研究不只能靠 raw snapshots 人工查找。 +7. Replay / grader 必须把 `web_research` 识别成联网研究回归样本,检查 `Skill(research)` / `Skill(site_search)` / `Skill(report_generate)`、`search_query` / `lime_site_*` 工具时间线,并禁止模型记忆、本地文件搜索、ToolSearch 或通用 WebSearch 旁路替代真实研究。 +8. 本阶段不新增独立 `report_generation` contract;`@研报 / @竞品` 先作为 `web_research` 下的报告型子入口,后续只有当 report artifact、执行器或 viewer 形成独立事实源时才拆子合同。 + +### Phase 12:Text Transform 合同注入 + +状态:进行中。 + +本阶段输出: + +1. `text_transform` 作为文档型底层运行合同进入 `harness.summary_skill_launch.summary_request`、`harness.translation_skill_launch.translation_request` 与 `harness.analysis_skill_launch.analysis_request`。 +2. `@读文件 / @总结 / @翻译 / @分析 / @发布合规 / @Logo拆解` 仅保留为 entry source;前端发送时注入 `modality_contract_key=text_transform`、`required_capabilities`、`routing_slot=base_model` 与 `runtime_contract` 快照。 +3. 后端 `prepare_*_skill_launch_request_metadata` 需要为旧客户端或缺省 metadata 补齐同一组合同字段,避免回退成前端直出文本、ToolSearch、WebSearch 或普通聊天摘要。 +4. summary / translation / analysis prompt 必须显式要求首刀 `Skill(summary)` / `Skill(translation)` / `Skill(analysis)`,显式文件路径场景可保留 `list_directory` / `read_file` 证据,但不能绕过底层合同。 +5. Evidence Pack 必须能从 `Skill(summary)` / `Skill(translation)` / `Skill(analysis)` timeline args 或 metadata 导出 `text_transform` 的 `modalityRuntimeContracts`,使轻量文本/文档转换也进入 replay 可评分事实源。 +6. Replay / grader 必须把 `text_transform` 识别成文本转换回归样本,检查文本转换 Skill trace 与必要文件读取证据,并禁止 `frontend_direct_text_transform`、ToolSearch、WebSearch 或普通聊天替代。 +7. 本阶段不拆 `summary_generation`、`translation`、`analysis`、`publish_compliance` 或 `logo_decomposition` 平行 contract;只有当独立 artifact、执行器或 viewer 形成稳定事实源时才拆子合同。 + ## 本轮最小闭环 第一轮已完成治理底座;当前纠偏后的最小闭环必须至少贯通一个真实运行时 vertical slice。 @@ -81,12 +162,31 @@ runtime identity 3. `lime_create_image_generation_task` 工具 schema、别名归一化与 request builder 接受并补齐 contract 字段。 4. `image_generate` task artifact payload 固化同一组 contract 字段,artifact 成为图片生成 contract 的 truth source。 5. DevBridge / browser mock 与前端 `CreateImageGenerationTaskArtifactRequest` 类型同步 contract 字段,避免浏览器链路变成假绿。 +6. 图片工作台 viewer 从 task artifact / 工作台状态读取 `runtimeContract`,展示“按合同路由 / 合同阻止 / registry 能力缺口”。 +7. `browser_control` 第九刀从 registry 解析 `browser_reasoning_model` 合同,并写入 `harness.browser_assist`,使 Browser Assist 首刀能看到 `modality_contract_key=browser_control`、`required_capabilities`、`routing_slot` 与 `runtime_contract` 快照。 +8. `browser_control` 第十/十一刀在 Rust 侧解析同一份合同,并把 browser tool timeline metadata 纳入 evidence `modalityRuntimeContracts`,不再只停留在前端 request metadata。 +9. `browser_control` 第十二刀把 evidence 快照继续写入 replay tags / expected checks / grader,要求回放继续走 Browser Assist、产生 browser action trace,并显式禁止 WebSearch 替代。 +10. `pdf_extract` 第十三刀把 `@读PDF` 上层入口接到底层 `Skill(pdf_read)` 运行合同,前端和 Rust 侧都会保留同一份 contract snapshot。 +11. `pdf_extract` 第十四刀把 `Skill(pdf_read)` timeline metadata 纳入 evidence `modalityRuntimeContracts`,并让 replay / grader 继续检查 PDF 读取 trace,形成入口 -> executor -> evidence -> replay 的闭环。 +12. `pdf_extract` 第十五刀把 `@读PDF / @pdf / @read_pdf` 收为 registry entry binding,前端发送只消费 registry 派生的 `entry_source`,入口层不再拥有底层 contract 常量。 +13. `voice_generation` 第十六刀把 `@配音` 服务型入口接到底层 `voice_runtime` 运行合同,前端与 Rust prompt 侧都会保留同一份 contract snapshot。 +14. `voice_generation` 第二十二刀把 voice runtime / service scene timeline args 或 metadata 纳入 evidence `modalityRuntimeContracts`,并让 replay / grader 继续检查 `service_scene_launch(scene_key=voice_runtime)` 或后续 audio_task/audio_output 证据。 +15. `web_research` 第十七刀把 `@搜索 / @深搜 / @站点搜索` 收为 registry entry binding,并把三条 research launch metadata 与 Rust prompt 收敛到同一份 `web_research` contract snapshot。 +16. `web_research` 第十八刀把 `Skill(research)` / `Skill(site_search)` tool timeline args 或 metadata 纳入 evidence `modalityRuntimeContracts`,并让 replay / grader 继续检查联网研究 executor trace,形成入口 -> executor -> evidence -> replay 的闭环。 +17. `web_research` 第十九刀把 evidence `modalityRuntimeContracts` 从 raw snapshots 扩展为 `snapshotIndex`,提供 contract/source/routing/tool trace 汇总,使联网研究合同进入可检索索引层。 +18. `web_research` 第二十刀把 `@研报 / @竞品` 收为报告型子入口:入口仍走 `report_skill_launch -> Skill(report_generate)`,底层合同、evidence、replay 统一归入 `web_research`。 +19. `text_transform` 第二十一刀把 `@读文件 / @总结 / @翻译 / @分析 / @发布合规 / @Logo拆解` 收为文本/文档转换子入口:入口仍分别走 `summary_skill_launch`、`translation_skill_launch`、`analysis_skill_launch`,底层合同、evidence、replay 统一归入 `text_transform`。 +20. `voice_generation` 第二十三刀新增最小 `audio_generate` artifact protocol:`create_audio_generation_task_artifact` / `lime_create_audio_generation_task` 写入标准 `audio_task/audio_output` payload,前端 API、DevBridge mock、task index、evidence pack 与 replay grader 都消费同一份 `voice_generation` 合同快照。 暂不做: -1. 暂不把所有 `@`、按钮、Scene 批量登记到 `bound_entries`;仍等 Phase 7。 -2. 暂不改 viewer 或 workspace UI 视觉面。 -3. 暂不把 LimeCore 写成默认执行器;LimeCore 仍只作为 catalog / policy / offer / audit 控制面。 +1. 暂不把所有 `@`、按钮、Scene 批量登记到 `bound_entries`;本轮只绑定 `image_generation` 第一条 vertical slice。 +2. 暂不把 LimeCore 写成默认执行器;LimeCore 仍只作为 catalog / policy / offer / audit 控制面。 +3. 暂不批量改造视频、语音、PDF、搜索等其他 viewer;先让 `image_generation` vertical slice 收口。 +4. 暂不新增 PDF task artifact / reader viewer;PDF 本轮只收 executor trace、evidence 和 replay,避免在底层事实源未完全稳定前再开一套产物协议。 +5. 暂不新增 audio viewer / 真实音频 worker;voice 本轮只落最小 `audio_generate` artifact protocol,不伪造云端提交,也不把普通聊天文本或通用文件卡当作音频产物。 +6. 暂不新增独立 `report_generation` 合同;`@研报 / @竞品` 继续走 `report_skill_launch -> Skill(report_generate)` 主链,但其底层能力归属先收敛到 `web_research`,避免把 report artifact 协议提前扩张成第二套事实源。 +7. 暂不新增独立 `summary_generation`、`translation`、`analysis`、`publish_compliance` 或 `logo_decomposition` 合同;这组轻量文本/文档转换入口先统一收敛到 `text_transform`,避免把上层 `@` 命令提前扩张成平行底层事实源。 ## 分类 @@ -137,3 +237,31 @@ runtime identity - 2026-04-29:继续 `image_generation` vertical slice 第五刀:图片任务 worker 的模型能力 preflight 从纯字符串启发式推进到 `ModelRegistryState` / `EnhancedModelMetadata`,优先按 `task_families` 与 `output_modalities` 判断 `ImageGeneration` / `ImageEdit` / `Image` 输出能力;只有 registry 找不到模型时才回退到文本模型启发式,避免 `gpt-5.2` 这类普通文本模型误进图片执行器,同时允许 registry 明确声明的图片模型通过。 - 2026-04-29:第五刀进一步把 registry 判定结果持久化为 `image_generate` task payload 的 `model_capability_assessment`,并同步进入 evidence `modalityRuntimeContracts.snapshots[].modelCapabilityAssessment` 与 replay 的 `modalityContractChecks`,让后续回归能区分 `model_registry` 判定和 heuristic fallback,而不是只看到最终失败码。 - 2026-04-29:第五刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml assess_image_generation_model_capability_should_use_output_modality`、`assess_image_generation_model_capability_should_reject_registered_text_model`、`assess_image_generation_model_capability_should_match_provider_model_id`、`validate_image_generation_task_execution_contract_should_reject_registry_text_model`、`validate_image_generation_task_execution_contract_should_trust_registry_image_model`、`patch_image_task_model_capability_assessment_should_persist_registry_snapshot`、`should_export_modality_runtime_contract_snapshot_from_failed_image_task`、`should_carry_modality_runtime_contract_into_replay_case`、`npm run test:contracts` 与本轮触达文件 `git diff --check`。 +- 2026-04-29:继续 `image_generation` vertical slice 第六刀:`list_media_task_artifacts` 现在可以按 `modalityContractKey` 与 `routingOutcome` 筛选图片任务,并在返回结果中附带 `modality_runtime_contracts` 索引摘要;索引包含 contract keys、routing outcome 计数、blocked 计数、registry assessment 计数与每个 task 的 routing snapshot,使 task index 成为多模态运行合同的可检索事实源,而不是只能靠单个 task file 或 evidence pack 反查。 +- 2026-04-29:第六刀同步前端 `ListMediaTaskArtifactsRequest` / `ListMediaTaskArtifactsOutput` 类型与浏览器 fallback mock;图片任务恢复测试继续使用统一媒体任务接口,mock 也返回相同 `modality_runtime_contracts` 结构,避免浏览器链路假绿。 +- 2026-04-29:第六刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml list_media_task_artifacts_inner_should_index_modality_contract_routing_blocks`、`media_task_artifact_controls_should_share_same_task_file_protocol`、`npm test -- src/lib/api/mediaTasks.test.ts src/lib/tauri-mock/core.test.ts src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`、`npm run typecheck`、`npm run test:contracts` 与本轮触达文件 `git diff --check`。 +- 2026-04-29:继续 `image_generation` vertical slice 第七刀:前端图片任务恢复 runtime 现在会从 task artifact payload / `last_error` 解析 `runtimeContract` 快照,写入 `ImageWorkbenchTask` 与 `MessageImageWorkbenchPreview`;图片工作台 viewer 增加轻量胶囊标签,区分“运行合同 · 已按 image_generation 路由”“运行合同阻止 · image_generation_model_capability_gap”以及“模型能力来自 model_registry · 不支持图片生成”,让第六刀 task index 的路由事实进入用户可见 viewer。 +- 2026-04-29:第七刀验证通过 `npm test -- src/components/agent/chat/components/ImageTaskViewer.test.tsx src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。 +- 2026-04-29:继续 `image_generation` vertical slice 第八刀:`image_generation.bound_entries` 现在登记 `@配图 / @image / /image / @Vision 1`、`@海报 / @Flyer 3`、`@修图`、`@重绘`、`@分镜`、文稿正文配图和图片工作台续做入口;entry binding 只声明 `entry_source`、`launch_metadata_path` 和 input mapping,不拥有 task / artifact / viewer / evidence 事实源。 +- 2026-04-29:第八刀新增 `src/lib/governance/modalityRuntimeContracts.ts`,前端 `imageSkillLaunch.ts` 改为从 registry 读取 `contractKey / modality / requiredCapabilities / routingSlot / runtimeContract`,避免上层入口继续硬编码底层 contract;守卫同步要求 entry binding 声明 `entry_source` 且 launch metadata 留在 `harness.*`。 +- 2026-04-29:第八刀验证通过 `npm run governance:modality-contracts`、`npm test -- src/lib/governance/modalityRuntimeContracts.test.ts`、`npm test -- src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。 +- 2026-04-29:继续第九刀 `browser_control`:泛化 `ModalityRuntimeContract` registry resolver,新增 Browser Assist launch metadata helper;`@浏览器 / @Browser Agent / @Mini Tester` 发送时保留原始消息并关闭 WebSearch,同时把 `browser_control` 合同快照注入 `harness.browser_assist`,`buildHarnessRequestMetadata` 负责保留这些底层合同字段并补齐 profile / backend / auto launch。 +- 2026-04-29:第九刀验证通过 `npm test -- src/lib/governance/modalityRuntimeContracts.test.ts src/components/agent/chat/utils/harnessRequestMetadata.test.ts src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx`、`npm run governance:modality-contracts`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 whitespace / `git diff --check`。 +- 2026-04-29:继续第十刀 `browser_control` 后端消费:新增 Rust 侧 `BROWSER_CONTROL_*` contract 常量与 `browser_control_runtime_contract()`,`BrowserAssistRuntimeHint` 现在会从 `harness.browser_assist` 解析 `modality_contract_key=browser_control`、`required_capabilities`、`routing_slot`、`runtime_contract` 与 `entry_source`;`LimeBrowserMcpTool` 成功或失败结果都会把这组字段写入 tool result metadata,让 Browser Assist 动作进入 runtime timeline 事实源。 +- 2026-04-29:继续第十一刀 `browser_control` evidence:`runtime_evidence_pack_service` 现在会从 browser tool call metadata 导出 `modalityRuntimeContracts` snapshot,`source=browser_action_trace.modality_runtime_contract`、`routingEvent=browser_action_requested`、`routingOutcome=accepted`,使 Browser Assist 不再只能依赖图片 task artifact 的合同导出路径。 +- 2026-04-29:第十/十一刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml test_extract_browser_assist_runtime_hint_from_harness_metadata`、`test_lime_browser_tool_attaches_modality_contract_metadata`、`should_export_browser_control_contract_snapshot_from_tool_metadata`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。 +- 2026-04-29:继续第十二刀 `browser_control` replay/grader:`runtime_replay_case_service` 现在会把 `browser_control` 合同识别为 Browser Assist 回归样本,补 `browser-control`、`browser-assist`、`browser-action-trace` suite tags;`expected.json` / `grader.md` 会要求继续使用 `mcp__lime-browser__*`,禁止 WebSearch / 普通聊天替代真实浏览器动作,并检查 `browser_action_trace` / `browser_action_requested` 证据。 +- 2026-04-29:第十二刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml should_carry_browser_control_contract_into_replay_grader_checks`、`should_carry_modality_runtime_contract_into_replay_case`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。 +- 2026-04-29:继续第十三刀 `pdf_extract`:新增前端 registry resolver `resolvePdfExtractRuntimeContractBinding()`,`@读PDF` 发送时在 `harness.pdf_read_skill_launch.pdf_read_request` 注入 `pdf_extract` contract 快照;Rust 侧新增 `PDF_EXTRACT_*` 常量、`pdf_extract_runtime_contract()` 与 `insert_pdf_extract_contract_fields()`,`prepare_pdf_read_skill_launch_request_metadata` 会把缺省合同字段补齐到 launch 与 request,系统 prompt 显式要求 Skill(pdf_read) 保留 contract 字段、真实读取 PDF,并禁止 `frontend_direct_pdf_parse` / `generic_chat_summary_only`。 +- 2026-04-29:第十三刀验证通过 `npm test -- src/lib/governance/modalityRuntimeContracts.test.ts src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx`、`npm run governance:modality-contracts`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke`、`cargo fmt --manifest-path src-tauri/Cargo.toml --check`、`CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml pdf_read_skill_launch`、`cargo test --manifest-path src-tauri/Cargo.toml` 与本轮触达文件 `git diff --check`;`npm run verify:local` 初跑曾在全量 Rust 阶段暴露 PDF 断言误落到 resource_search 测试,已修复并用全量 `cargo test` 复验通过。 +- 2026-04-29:继续第十四刀 `pdf_extract` evidence/replay:`runtime_evidence_pack_service` 现在会从 `Skill(pdf_read)` tool timeline metadata 或其 `args.pdf_read_request` 合同快照导出 `pdf_extract` 的 `modalityRuntimeContracts`,`source=pdf_read_skill_trace.modality_runtime_contract`、`routingEvent=executor_invoked`、`routingOutcome=accepted/failed`、`expectedRoutingSlot=base_model`。 +- 2026-04-29:第十四刀同步 `runtime_replay_case_service`:replay tags 增加 `modality-pdf_extract`、`pdf-extract`、`pdf-read-skill`、`pdf-read-trace`,`expected.json` / `grader.md` 明确要求继续走 `Skill(pdf_read)` 或真实 `list_directory` / `read_file` 文件读取证据,并禁止 `frontend_direct_pdf_parse`、`generic_chat_summary_only`、ToolSearch、WebSearch 或 Grep 替代真实读 PDF。 +- 2026-04-29:继续第十五刀 `pdf_extract` entry binding:`modalityRuntimeContracts.json` 现在登记 `at_pdf_read_command`,覆盖 `@读PDF / @pdf / @read_pdf`,并把 launch path 固定为 `harness.pdf_read_skill_launch.pdf_read_request`;`useWorkspaceSendActions` 从 `resolvePdfExtractRuntimeContractBinding().boundEntrySources` 读取 `entry_source`,只保留 `PDF_EXTRACT_DEFAULT_ENTRY_SOURCE` 作为 registry 缺省 fallback。 +- 2026-04-29:继续第十六刀 `voice_generation`:新增前端 registry resolver `resolveVoiceGenerationRuntimeContractBinding()`,`voice_generation.bound_entries` 登记 `at_voice_command`;`@配音` 发送时在 `harness.service_scene_launch.service_scene_run` 注入 `voice_generation` contract 快照,后端 `prepare_service_scene_launch_request_metadata` 也会为 `voice_runtime` 旧 metadata 补齐合同字段,服务型场景 prompt 会输出底层合同、所需能力与 `runtime_contract(JSON)`,并禁止退回 `legacy_tts_test_command`。 +- 2026-04-29:继续第十七刀 `web_research`:新增前端 registry resolver `resolveWebResearchRuntimeContractBinding()`,`web_research.bound_entries` 登记 `at_search_command`、`at_deep_search_command` 与 `at_site_search_command`;`@搜索 / @深搜 / @站点搜索` 发送时分别在 research / deep search / site search request 中注入 `web_research` contract 快照,Rust 侧 prepare 函数也会把缺省合同字段补齐到 launch 与 request,prompt 显式输出底层合同、所需能力与 `runtime_contract(JSON)`,并禁止退回模型记忆、本地文件搜索、通用 WebSearch 或 ToolSearch 偏航。 +- 2026-04-30:继续第十八刀 `web_research` evidence/replay:`runtime_evidence_pack_service` 现在会从 `Skill(research)` / `Skill(site_search)` tool timeline 的 args 或 metadata 导出 `web_research` 的 `modalityRuntimeContracts`,`source=web_research_skill_trace.modality_runtime_contract`、`routingEvent=executor_invoked`、`routingOutcome=accepted/failed`、`expectedRoutingSlot=report_generation_model`;`runtime_replay_case_service` 同步增加 `web-research`、`research-skill`、`web-research-trace` tags,并在 expected / grader 中要求继续走 `Skill(research)` / `Skill(site_search)` 与真实 search_query / `lime_site_*` 证据,禁止 `model_memory_only_answer`、`local_file_search_before_research_skill`、ToolSearch 或普通聊天替代。 +- 2026-04-30:继续第十九刀 `web_research` 索引化:`runtime_evidence_pack_service` 的 `modalityRuntimeContracts` 现在输出 `snapshotIndex`,汇总 `contractKeys`、`sourceCounts`、`routingOutcomeCounts`、`expectedRoutingSlots` 与 `toolTraceIndex.items`;`web_research` replay input 会携带同一份索引,使回归样本能直接定位 `executorBindingKey=research`、`entrySource=at_search_command` 与 `web_research_skill_trace`,不再只能扫描 raw snapshots。 +- 2026-04-30:继续第二十刀 `web_research` 报告型子入口:`web_research.bound_entries` 纳入 `at_report_command` 与 `at_competitor_command`,前端 `@研报 / @竞品` 发送会在 `harness.report_skill_launch.report_request` 注入同一份 `web_research` contract;Rust `report_skill_launch` prepare / prompt 也补齐合同字段,同时保留首刀 `Skill(report_generate)`,并把 evidence / replay 扩展到 `report_generate` trace,避免另开 `report_generation` 平行事实源。 +- 2026-04-30:继续第二十一刀 `text_transform`:新增 `text_transform` contract 并登记 `at_file_read_command`、`at_summary_command`、`at_translation_command`、`at_analysis_command`、`at_publish_compliance_command`、`at_logo_decomposition_command`;前端 `@读文件 / @总结 / @翻译 / @分析 / @发布合规 / @Logo拆解`、Rust summary/translation/analysis prepare 与 prompt、evidence `text_transform_skill_trace`、replay tags / expected / grader 已统一收敛到同一份底层合同,入口层不再新增 summary / translation / analysis 平行事实源。 +- 2026-04-30:继续第二十二刀 `voice_generation` evidence/replay:`runtime_evidence_pack_service` 现在会从 voice runtime / service scene timeline args 或 metadata 导出 `voice_generation` 的 `modalityRuntimeContracts`,`source=voice_generation_service_scene_trace.modality_runtime_contract`、`routingEvent=executor_invoked`、`routingOutcome=accepted/failed`、`expectedRoutingSlot=voice_generation_model`;`runtime_replay_case_service` 同步增加 `voice-generation`、`voice-runtime`、`voice-generation-trace` tags,并在 expected / grader 中要求继续走 `service_scene_launch(scene_key=voice_runtime)` / 本地 ServiceSkill runtime 或后续 audio_task/audio_output 证据,禁止 `legacy_tts_test_command`、伪造云端提交、普通聊天文本或通用文件卡替代。 +- 2026-04-30:继续第二十三刀 `voice_generation` audio artifact protocol:新增 `audio_generate` task type、`create_audio_generation_task_artifact` Tauri 命令与 `lime_create_audio_generation_task` Agent tool,只写入标准 `audio_task/audio_output` 产物,不生成真实音频、不提交云端;前端 API、DevBridge mock、`list_media_task_artifacts` contract index、evidence `audio_task.modality_runtime_contract` 与 replay grader 已同步消费同一份 `voice_generation` 合同快照,下一刀再补 audio viewer / 真实 worker。 diff --git a/docs/exec-plans/old-session-open-performance-plan.md b/docs/exec-plans/old-session-open-performance-plan.md index 9fad14fce..bf6e55a3e 100644 --- a/docs/exec-plans/old-session-open-performance-plan.md +++ b/docs/exec-plans/old-session-open-performance-plan.md @@ -11,6 +11,11 @@ - `P2` 已推进:加载更多历史已从递增 tail window 改为分页加载;首屏返回最近 `40` 条并带 `history_cursor.oldest_message_id`,加载更早历史时优先用 `historyBeforeMessageId` cursor,每次请求 `50` 条,`historyOffset` 保留为兼容 fallback;历史会话的 MessageList 首帧先渲染文本,timeline 在 idle 后补齐;timeline turn 绑定减少中间数组分配;折叠的 `AgentThreadTimeline` 明细改为展开时再物化;旧历史中已完成的单步 timeline 明细默认只渲染摘要,展开时再物化;已排序的 turn/thread item 不再重复复制排序。 - `2026-04-29` 追加收口:过大的旧会话 transient / persisted snapshot 在恢复前直接丢弃;读取命中的会话快照不再同步刷新 `lastAccessedAt` 以避免点击时重写整张 snapshot map;历史消息与流式 text_delta 统一做 overlap 合并,修复累计快照式 delta 导致的重复吐字;完成态旧消息的执行过程与正文视觉分离,历史 timeline 默认折叠明细;浏览器 DevBridge 模式下跳过低优先级 metadata backfill,并且仅在活跃运行时订阅 team SSE,避免旧会话点击后抢占 bridge 连接。 +- `2026-04-29` 追加 GUI 收口:复现旧会话顶部 `+` 新建后新 tab 仍显示旧消息的问题;`createFreshSession` 现在显式清空新建会话消息快照,同时 `ensureSession` 的首轮发送保留本地草稿,避免发送链路回归;旧会话 MessageList 仅在活跃发送/等待态底部锚定,已完成历史默认吸顶。 +- `2026-04-29` 追加任务中心收口:复现旧会话中点击侧栏会话/顶部标签后被 `initialSessionId` 路由抢回的问题;任务中心本地切换现在在 `switchTopic` 前先标记 local override,失败时再回滚,避免异步切换期间显示旧 tab 或空态。 +- `2026-04-29` 追加发送中吸顶收口:复现用户首条消息发送中仍贴近输入区、完成后才跳到上方的问题;MessageList 现在无论发送中、排队中还是完成态都保持 `justify-start`,避免完成前后布局跳动。 +- `2026-04-29` 追加一级新建任务收口:任务中心内点击侧栏一级 `新建任务` 现在与对话架 `新建对话` 复用同一个可取消草稿事件;只有任务中心真实接管后才阻止路由,避免旧会话 A 新增草稿、切到旧会话 B 后再次点击侧栏新建时误跳回 `new-task-home`。 + ## 剩余事项 - Cursor 分页已完成:`agent_runtime_get_session` 继续作为 current 主链,新增 `historyBeforeMessageId` 请求字段与 `history_cursor` 响应字段;无 cursor 或缓存恢复缺少 cursor 时继续 fallback 到 `historyOffset`。 @@ -61,3 +66,68 @@ - `npx eslint "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.ts" "src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" "src/components/agent/chat/hooks/agentChatHistory.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/skillCommand.ts" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/AgentThreadTimeline.tsx" "src/components/agent/chat/hooks/agentChatStorage.ts" "src/components/agent/chat/hooks/agentSessionScopedStorage.ts" "src/components/agent/chat/hooks/agentChatHistory.test.ts" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/components/AgentThreadTimeline.test.tsx" "src/components/agent/chat/hooks/agentChatStorage.test.ts" "src/components/agent/chat/hooks/agentSessionScopedStorage.test.ts" --max-warnings 0` 通过。 - `npm run bridge:health -- --timeout-ms 120000` 通过,DevBridge `/health` 约 `20ms` 就绪。 - Playwright E2E(`http://127.0.0.1:1420/`):连续切换 `E2E layout anchor -> 你好!👋 很高兴见到你! -> 你好!有什么我可以帮你的吗?😊 -> E2E layout anchor`,每次 `restoring=0`,耗时约 `109-425ms`,`body/html cursor=auto`,最终 `heap≈121MB`;`assistant-primary-timeline-shell` 高度 `94px`,`openDetails=0`,最终正文不在执行过程 shell 内,未检测到 assistant greeting 重复;等待 `9s` 后 `eventRequests=0`,控制台 `0 error / 1 warning`(仅浏览器模式 i18n 默认语言提示)。 +- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"` 通过(2 files / 230 tests),覆盖旧会话吸顶、新建任务清空旧消息与首轮发送草稿保留。 +- `npx eslint "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" --max-warnings 0` 通过。 +- Playwright E2E(`http://127.0.0.1:1420/`):从新建空态点击侧边栏 `E2E layout anchor`,旧会话约 `237-331ms` 可见且 `message-list-column` 为 `justify-start`;再点击顶部 `+` 或侧边栏 `新建任务` 均回到 `青柠一下,灵感即来` 空态,旧会话文本不可见,`body cursor=auto`。本轮仅剩 DevBridge 偶发 `get_local_skills_for_app` / `project_memory_get` 1800ms timeout 控制台噪音,主交互未阻塞。 +- Playwright E2E(`http://127.0.0.1:1420/`):从首页点击侧栏 `E2E layout anchor` 约 `419ms` 可见旧会话;从空态点击顶部旧会话标签约 `304ms` 可见旧会话;旧会话中点击顶部 `+` 约 `124ms` 回到新建空态;旧会话中点击侧栏 `新建任务` 约 `106ms` 回到新建空态;旧会话 `message-list-column` 保持 `justify-start`,`cursor=auto`,控制台 `0 error / 0 warning`。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(7 tests),覆盖任务中心切到非路由会话时不被 `initialSessionId` 抢回。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"` 通过(3 files / 326 tests)。 +- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" --max-warnings 0` 通过。 +- `npm run typecheck` 通过。 +- `npm run verify:gui-smoke` 通过。 +- `git diff --check -- src/components/agent/chat/AgentChatWorkspace.tsx src/components/agent/chat/index.test.tsx src/components/agent/chat/components/MessageList.tsx src/components/agent/chat/components/MessageList.test.tsx src/components/agent/chat/hooks/useAgentSession.ts src/components/agent/chat/hooks/useAsterAgentChat.test.tsx docs/exec-plans/old-session-open-performance-plan.md` 通过。 +- `npm run verify:local` 通过;覆盖 `verify:app-version`、`lint`、`typecheck`、全量前端测试、`test:contracts`、全量 Rust 测试与内嵌 `verify:gui-smoke`。 +- Playwright E2E 复测(`http://127.0.0.1:1420/`):刷新后控制台 `0 error / 1 warning`(仅浏览器模式 i18n 默认语言提示);点击侧栏 `E2E layout anchor` 约 `133ms` 可见旧会话且 `message-list-column` 为 `justify-start`;随后点击侧栏 `新建任务` 约 `118ms` 回到 `青柠一下,灵感即来` 空态,旧消息不可见,`body/html cursor=auto`。 +- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" -t "短对话发送首帧|任务中心发送首帧|旧会话短消息"` 通过,覆盖发送中短消息与任务中心发送首帧均吸顶。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(7 tests),并将顶部加号测试改为等待按钮就绪后点击,降低慢机环境下的偶发失败。 +- Playwright E2E 复测(`http://127.0.0.1:1420/`):发送 `吸顶测试,请只回复 OK。` 后立即采样、`250ms`、`1250ms` 均为 `message-list-column` `justify-start`,首条消息距滚动视口顶部约 `22px`,`scrollTop=0`,`body/html cursor=auto`,确认发送中首帧不再贴底;旧会话 `E2E layout anchor` 打开后 `message-list-column` 同样为 `justify-start`;侧栏 `新建任务` 约 `97ms` 回到 `青柠一下,灵感即来` 空态且旧消息不可见。刷新后控制台保持 `0 error / 1 warning`(仅浏览器模式 i18n 默认语言提示)。 +- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" "src/lib/api/memory.test.ts" -t "短对话发送首帧|任务中心发送首帧|旧会话短消息|project_memory_get|项目记忆|短时间重复|并发读取"` 通过(2 files / 6 tests)。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(7 tests)。 +- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/lib/api/memory.ts" "src/lib/api/memory.test.ts" --max-warnings 0` 通过。 +- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace / browser-runtime / site-adapters / service-skill-entry / runtime tool-surface page smoke 均通过。 +- `2026-04-29` 追加浏览器式 Tab 收口:任务中心顶部 `+` 改为本地 `task-draft-*` 草稿 tab,点击阶段不再创建真实 session;`CustomEvent` 新建请求改为可取消事件,只有任务中心工作区实际接管后侧栏才停止路由跳转,避免侧栏 `新建任务` 在任务中心内误跳回 `new-task-home`。 +- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/taskCenterDraftTaskEvents.ts" "src/components/AppSidebar.tsx" "src/components/agent/chat/components/EmptyState.tsx" --max-warnings 0` 通过。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(8 tests),覆盖顶部加号和侧栏事件新建草稿 tab,不触发 `createFreshSession` / 路由跳出。 +- `npx tsc --noEmit --pretty false` 通过;同时修复本轮 E2E 中暴露的 `EmptyState` 首页组件 HMR 崩溃,刷新后控制台恢复到 `0 error / 1 warning`(浏览器模式 i18n 默认语言提示)。 +- Playwright E2E(`http://127.0.0.1:1420/`):从旧会话点击顶部 `+`,`task-center-tab-task-draft-*` 约 `77ms` active,`青柠一下,灵感即来` 约 `95ms` 可见;旧 `message-list-column` 不再渲染,旧消息不可见;invoke trace `10` 条,`createSessionCalls=0`,无 invoke error。 +- 本轮 GUI smoke 未完成:`npm run bridge:health -- --timeout-ms 120000` 因当前工作区并行 Rust 改动导致 DevBridge 未就绪而等待,`tauri:dev:headless` 日志显示 `src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs` 存在非本轮编译错误(`ServiceSceneLaunchContext` 缺字段、mutable borrow 冲突),需先收口该 Rust 改动后再重跑 `npm run verify:gui-smoke`。 +- `2026-04-29` 追加多旧会话切换复现:新增回归覆盖 `旧会话 A -> 顶部 + 新增草稿 -> 路由切到旧会话 B -> 再次顶部 +`。根因是路由 `initialSessionId` 切到真实旧会话时只替换真实 tab,没有清掉仍 active 的本地 draft id,导致旧会话 B tab 不是 active,后续新增状态被旧 draft 干扰;现在切到真实路由会话时仅取消 active draft,保留草稿 tab 列表。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "切到另一条旧会话后仍应继续新增本地草稿标签"` 通过。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(9 tests),覆盖顶部 +、侧栏事件、旧会话 A/B 切换后再次新增、路由抢回防护。 +- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/taskCenterDraftTaskEvents.ts" "src/components/AppSidebar.tsx" --max-warnings 0` 通过。 +- `npx tsc --noEmit --pretty false` 通过。 +- Playwright 真实旧会话 A/B 复测当前仍被 DevBridge 阻塞:页面控制台为 bridge 连接错误,`npm run bridge:health -- --timeout-ms 120000` 超时;阻塞仍来自非本轮 Rust 编译错误,需先恢复 `src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs` 后重测。 +- `npx vitest run "src/components/AppSidebar.test.tsx" -t "任务中心内点击一级新建任务"` 通过,覆盖任务中心内一级主导航 `新建任务` 被草稿事件接管且不触发路由跳转。 +- `npx vitest run "src/components/AppSidebar.test.tsx"` 通过(34 tests)。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(9 tests);首次与 AppSidebar 全量测试并行时出现一次顶部 `+` 慢机 transition 偶发断言,单独重跑与整组重跑均通过。 +- `npx eslint "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx" --max-warnings 0` 通过。 +- `npx tsc --noEmit --pretty false` 通过。 +- `git diff --check -- src/components/AppSidebar.tsx src/components/AppSidebar.test.tsx src/components/agent/chat/AgentChatWorkspace.tsx src/components/agent/chat/index.test.tsx src/components/agent/chat/taskCenterDraftTaskEvents.ts` 通过。 +- `npm run bridge:health -- --timeout-ms 120000` 重新通过;本轮先清理了当前仓库残留的并发 Tauri/Cargo dev 进程,随后 `DevBridge` 在 `http://127.0.0.1:3030/health` 返回 `status=ok`。 +- Playwright E2E(`http://127.0.0.1:1420/`):刷新首页后执行 `旧会话 A(E2E layout anchor) -> 侧栏一级新建任务 -> 旧会话 B(Lime 助手) -> 侧栏一级新建任务`;打开 A 约 `669ms`,A 后新建草稿约 `63ms`,打开 B 约 `213ms`,B 后新建草稿约 `50ms`。两次新建均激活 `task-center-tab-task-draft-*`,不会出现 active `task-center-tab-new-task-home`;第二次后保留 `2` 个 draft tab,旧会话 tab inactive,首页空态可见,`body/html cursor=auto`。 +- 同轮 Playwright invoke trace:总 `/invoke` `16` 次,`agent_runtime_create_session=0`;两次 `agent_runtime_get_session` 均携带 `historyLimit: 40`;侧栏列表仅一次 `agent_runtime_list_sessions`,`limit=37`;无 invoke error。控制台 `0 error / 2 warning`,warning 为浏览器模式事件流中止与 i18n 默认语言提示,均非主链阻塞。 +- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、service-skill-entry、runtime tool-surface 与 runtime tool-surface page smoke 均通过。 +- `npm run verify:local` 已执行到前端全量测试第 `26/43` 批后失败,失败集中在 `src/components/agent/chat/components/EmptyState.test.tsx` 的 `18` 条旧首页断言;实际渲染已切到新的 `HomeStartSurface` / `HomeSkillGallery` 首页文案与结构,属于本轮侧栏 tab 修复之外的首页改版测试漂移,需后续单独收口 EmptyState 测试或恢复对应首页能力。 +- `2026-04-30` 追加修复“无法打开两个历史对话”:根因是外层 `AppSidebar` 点击历史会话只做路由跳转,任务中心没有本地打开意图;随后 `AgentChatWorkspace` 的 `initialSessionId` 同步 effect 将 open tab map 替换为单个目标会话。现在侧栏点击历史会话会先发送 `lime:task-center:open-task` 事件,任务中心先乐观 upsert 目标 tab 并记录本地路由追平意图;路由追平时仅激活/前置目标 tab,不再替换掉已有历史 tab。 +- 同轮收口:任务中心内部 `handleOpenSidebarTaskTopic` / `handleResumeSidebarTask` / `handleResumeRecentSession` / fallback restore 默认走追加/激活语义,只有外部直达会话仍保留 replace 语义,避免旧缓存污染首开深链。 +- `2026-04-30` 追加旧会话卡顿收口:MessageList 对恢复中或已分页旧会话使用更小的首屏批次(20 条)和 idle 小批量补齐;已完成的旧会话最后一个 turn 不再按 current turn 处理,避免历史 timeline 在首帧被当作活跃执行过程同步展开。 +- `2026-04-30` 追加 invoke 降噪:`workspace_get` 增加 1s 短 TTL 与同 id in-flight 合并,避免旧会话切换期间工作区详情重复抢占 bridge;打开旧会话时自动回填的 `recent_preferences` / `recent_team_selection` 改为同 session 后台合并队列,12s 后 idle 再写,若期间已切走则直接丢弃,手动偏好修改仍保持即时低延迟同步。 +- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(11 tests),新增覆盖任务中心内部连续打开旧会话、外层侧栏事件 + 路由追平后保留两个历史 tab。 +- `npx vitest run "src/components/AppSidebar.test.tsx"` 通过(35 tests),新增覆盖任务中心内点击已有会话会先通知本地标签栏,再继续导航。 +- `npx eslint "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx" "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/taskCenterDraftTaskEvents.ts" --max-warnings 0` 通过。 +- `npx tsc --noEmit --pretty false` 通过。 +- `npm run bridge:health -- --timeout-ms 120000` 通过,DevBridge `/health` 约 `22ms` 就绪。 +- Playwright E2E(`http://127.0.0.1:1420/`):刷新后点击侧栏 `E2E layout anchor`,顶部仅显示并激活该旧会话;随后点击侧栏 `你好!👋 我是 Lime 助手,很高...`,顶部同时保留 `你好!👋 ...` 与 `E2E layout anchor` 两个历史 tab,目标会话 active,旧会话 inactive;页面停留 `http://127.0.0.1:1420/`,标题 `Lime`。 +- 同轮 Playwright invoke trace:两次 `agent_runtime_get_session` 均携带 `historyLimit: 40`;侧栏 list 请求为 `agent_runtime_list_sessions` `limit=37/60`;全部 `/invoke` 为 `200 OK`,控制台 `0 error / 0 warning`。 +- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、service-skill-entry、runtime tool-surface 与 runtime tool-surface page smoke 均通过。 +- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx"` 通过(77 tests);新增覆盖已分页旧会话首帧只挂载更小尾部批次、已完成旧 turn 不再作为 active current turn 展开历史 timeline。 +- `npx eslint "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" --max-warnings 0` 通过。 +- `npx tsc --noEmit --pretty false` 通过。 +- Playwright 性能复测(`http://127.0.0.1:1420/`):打开 `AI Trends Task`(188 总消息 / 最近 40)约 `567ms`,`longTasks=[]`,DOM 节点约 `637`;打开 `Slow typing E2E`(170 总消息 / 最近 40)约 `706ms`,`longTasks=[]`,DOM 节点约 `644`,控制台 `0 error / 0 warning`。 +- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、service-skill-entry、runtime tool-surface 与 runtime tool-surface page smoke 均通过。 +- `npm run verify:local` 本轮执行到 `lint` 阶段失败,失败点为非本轮文件 `src/components/agent/chat/home/HomeSceneSkillManagerDialog.tsx:388` 的 `react-hooks/exhaustive-deps` warning(`open` 为不必要依赖),本轮 touched 文件的定向 eslint 已通过。 +- Playwright 预优化采样(`http://127.0.0.1:1420/`):打开 `Slow typing E2E` 约 `393ms` 可见,早期 invoke 为 `agent_runtime_update_session` ×2 + `agent_runtime_get_session` ×1,且两次 metadata 回填排在 `get_session` 前;本轮据此将自动 metadata 回填降为后台合并。 +- `npx vitest run "src/lib/api/project.test.ts"` 通过(59 tests),覆盖 `workspace_get` 短缓存与并发去重。 +- `npx vitest run "src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx"` 通过(13 tests),覆盖自动 fallback Team 回填走后台同步、手动切换仍即时回写。 +- `npx eslint "src/lib/api/project.ts" "src/lib/api/project.test.ts" "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/hooks/useSelectedTeamPreference.ts" "src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx" --max-warnings 0` 通过。 +- GUI smoke / Playwright 后置复测暂被本地并发 Cargo/Tauri 构建阻塞:`tauri:dev:headless` 首次暴露非本轮 `creation_tools.rs` 大型 `serde_json::json!` 递归限制,已在 `src-tauri/src/lib.rs` 补 `recursion_limit = "256"` 解除;随后仍有多个非本轮 `cargo test` / `cargo check` 并发进程占用 target/package lock,需环境空闲后重跑 `npm run bridge:health -- --timeout-ms 120000`、`npm run verify:gui-smoke` 与 Playwright 旧会话性能采样。 diff --git a/docs/roadmap/warp/contract-schema.md b/docs/roadmap/warp/contract-schema.md index cb439e112..166a533f9 100644 --- a/docs/roadmap/warp/contract-schema.md +++ b/docs/roadmap/warp/contract-schema.md @@ -19,7 +19,7 @@ 1. `contract_key` 代表底层多模态运行能力,例如 `image_generation`、`browser_control`、`pdf_extract`。 2. `contract_key` 不代表上层入口,不能写成 `@配图`、`@浏览器` 或 `/scene-key`。 -3. `bound_entries` 在 Phase 0/1 可以为空;Phase 7 才把 `@`、按钮和 Scene 绑定进来。 +3. `bound_entries` 在 Phase 0/1 可以为空;进入 Phase 7 后按 contract 逐条把 `@`、按钮和 Scene 绑定进来。 4. 每个 contract 必须能解释 identity、capability、profile、executor、truth source、artifact、viewer、evidence。 5. `required_capabilities` 必须全部出现在 [capability-matrix.md](./capability-matrix.md)。 6. `routing_slot` 必须出现在 capability matrix 的 `model_roles`。 @@ -92,6 +92,7 @@ Phase 7 才允许大量补 entry binding。 "entry_kind": "command", "display_name": "@配图", "launch_metadata_path": "harness.image_skill_launch", + "entry_source": "at_image_command", "default_input_mapping": ["user_text", "selected_assets"], "entry_visibility_policy": ["skill_catalog_visible", "profile_allows_image_generation"] } @@ -101,11 +102,12 @@ Phase 7 才允许大量补 entry binding。 1. Entry binding 必须引用已存在的 `contract_key`。 2. Entry binding 不得拥有 `truth_source`、`artifact_kinds`、`viewer_surface`。 -3. Entry binding 只补 launch metadata 和 input mapping。 +3. Entry binding 只补 launch metadata、`entry_source` 和 input mapping。 +4. `launch_metadata_path` 必须指向 `harness.*`,避免入口直接绕到 task / artifact 层。 ## 7. 首批 contract -首批 registry 只覆盖底层能力,不抢做入口绑定: +首批 registry 先覆盖底层能力;`image_generation` 已作为第一条 vertical slice 进入 Phase 7 entry binding: 1. `image_generation` 2. `browser_control` @@ -113,7 +115,7 @@ Phase 7 才允许大量补 entry binding。 4. `voice_generation` 5. `web_research` -这些 contract 用于验证 schema 和治理守卫,后续 vertical slice 可优先选择 `image_generation` 或 `browser_control`。 +这些 contract 用于验证 schema 和治理守卫;后续 vertical slice 继续按“先底层、后 entry binding”的顺序推进。 ## 8. 校验入口 @@ -129,3 +131,4 @@ npm run governance:modality-contracts 4. 枚举字段使用已知值。 5. executor binding 声明能力与 failure mapping。 6. entry binding 不携带底层事实源字段。 +7. entry binding 必须声明 `entry_source`,且 `launch_metadata_path` 必须留在 `harness.*`。 diff --git a/lime-e2e-layout-anchor-after-fix.png b/lime-e2e-layout-anchor-after-fix.png deleted file mode 100644 index 74c46a601..000000000 Binary files a/lime-e2e-layout-anchor-after-fix.png and /dev/null differ diff --git a/lime-e2e-layout-anchor-final.png b/lime-e2e-layout-anchor-final.png deleted file mode 100644 index 67a9d9402..000000000 Binary files a/lime-e2e-layout-anchor-final.png and /dev/null differ diff --git a/lime-hello-layout-after-fix.png b/lime-hello-layout-after-fix.png deleted file mode 100644 index c603c64c6..000000000 Binary files a/lime-hello-layout-after-fix.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 4b2d61f52..cd39f47f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.23.0", + "version": "1.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.23.0", + "version": "1.24.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 25e505c25..f51c96079 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.23.0", + "version": "1.24.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index f87731ea6..f95903b48 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.23.0" \ + --version "1.24.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 12e885ebd..14e2eda9d 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.23.0", + "version": "1.24.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/public/home-covers/home-cover-account-performance-tracking.jpg b/public/home-covers/home-cover-account-performance-tracking.jpg new file mode 100644 index 000000000..c923fb0f3 Binary files /dev/null and b/public/home-covers/home-cover-account-performance-tracking.jpg differ diff --git a/public/home-covers/home-cover-article-to-slide-video-outline.jpg b/public/home-covers/home-cover-article-to-slide-video-outline.jpg new file mode 100644 index 000000000..c0a85bc65 Binary files /dev/null and b/public/home-covers/home-cover-article-to-slide-video-outline.jpg differ diff --git a/public/home-covers/home-cover-carousel-post-replication.jpg b/public/home-covers/home-cover-carousel-post-replication.jpg new file mode 100644 index 000000000..5dc34f49c Binary files /dev/null and b/public/home-covers/home-cover-carousel-post-replication.jpg differ diff --git a/public/home-covers/home-cover-cloud-video-dubbing.jpg b/public/home-covers/home-cover-cloud-video-dubbing.jpg new file mode 100644 index 000000000..c54981192 Binary files /dev/null and b/public/home-covers/home-cover-cloud-video-dubbing.jpg differ diff --git a/public/home-covers/home-cover-draft.jpg b/public/home-covers/home-cover-draft.jpg new file mode 100644 index 000000000..22983b27a Binary files /dev/null and b/public/home-covers/home-cover-draft.jpg differ diff --git a/public/home-covers/home-cover-review.jpg b/public/home-covers/home-cover-review.jpg new file mode 100644 index 000000000..3f54208a5 Binary files /dev/null and b/public/home-covers/home-cover-review.jpg differ diff --git a/public/home-covers/home-cover-rewrite.jpg b/public/home-covers/home-cover-rewrite.jpg new file mode 100644 index 000000000..ce58c860e Binary files /dev/null and b/public/home-covers/home-cover-rewrite.jpg differ diff --git a/public/home-covers/home-cover-short-video-script-replication.jpg b/public/home-covers/home-cover-short-video-script-replication.jpg new file mode 100644 index 000000000..e7c1eb041 Binary files /dev/null and b/public/home-covers/home-cover-short-video-script-replication.jpg differ diff --git a/public/home-covers/home-cover-trend.jpg b/public/home-covers/home-cover-trend.jpg new file mode 100644 index 000000000..688db406d Binary files /dev/null and b/public/home-covers/home-cover-trend.jpg differ diff --git a/public/home-covers/home-cover-video-dubbing-language.jpg b/public/home-covers/home-cover-video-dubbing-language.jpg new file mode 100644 index 000000000..910cb0ffd Binary files /dev/null and b/public/home-covers/home-cover-video-dubbing-language.jpg differ diff --git a/public/home-covers/home-cover-viral.jpg b/public/home-covers/home-cover-viral.jpg new file mode 100644 index 000000000..8910b610e Binary files /dev/null and b/public/home-covers/home-cover-viral.jpg differ diff --git a/public/home-covers/home-cover-voice.jpg b/public/home-covers/home-cover-voice.jpg new file mode 100644 index 000000000..c2bdafbb1 Binary files /dev/null and b/public/home-covers/home-cover-voice.jpg differ diff --git a/scripts/check-modality-runtime-contracts.mjs b/scripts/check-modality-runtime-contracts.mjs index 896932d1d..4303881b9 100755 --- a/scripts/check-modality-runtime-contracts.mjs +++ b/scripts/check-modality-runtime-contracts.mjs @@ -288,6 +288,7 @@ function validateEntryBindings(errors, contract) { "entry_key", "display_name", "launch_metadata_path", + "entry_source", ]) { pushIf( errors, @@ -300,6 +301,12 @@ function validateEntryBindings(errors, contract) { !ENTRY_KINDS.has(entry.entry_kind), `${prefix}.entry_kind is unknown: ${String(entry.entry_kind)}`, ); + pushIf( + errors, + isNonEmptyString(entry.launch_metadata_path) && + !entry.launch_metadata_path.startsWith("harness."), + `${prefix}.launch_metadata_path must point to harness metadata`, + ); validateStringArray( errors, contractKey, diff --git a/scripts/release-updater-manifest.test.mjs b/scripts/release-updater-manifest.test.mjs index 40101f498..7d6b6259f 100644 --- a/scripts/release-updater-manifest.test.mjs +++ b/scripts/release-updater-manifest.test.mjs @@ -303,7 +303,7 @@ describe("GitHub release asset staging", () => { "arm-sig", ); writeFile( - path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.23.0_aarch64.dmg"), + path.join(assetsDir, "aarch64-apple-darwin", "Lime_1.24.0_aarch64.dmg"), ); writeFile(path.join(assetsDir, "x86_64-apple-darwin", "Lime.app.tar.gz")); writeFile( @@ -311,7 +311,7 @@ describe("GitHub release asset staging", () => { "x64-sig", ); writeFile( - path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.23.0_x64.dmg"), + path.join(assetsDir, "x86_64-apple-darwin", "Lime_1.24.0_x64.dmg"), ); writeFile(latestPath, "{}"); @@ -319,29 +319,29 @@ describe("GitHub release asset staging", () => { assetsDir, extraAssets: [latestPath], outDir, - version: "v1.23.0", + version: "v1.24.0", }); expect(copied.map((item) => item.name).sort()).toEqual( [ - "Lime_1.23.0_aarch64.app.tar.gz", - "Lime_1.23.0_aarch64.app.tar.gz.sig", - "Lime_1.23.0_aarch64.dmg", - "Lime_1.23.0_x64.app.tar.gz", - "Lime_1.23.0_x64.app.tar.gz.sig", - "Lime_1.23.0_x64.dmg", + "Lime_1.24.0_aarch64.app.tar.gz", + "Lime_1.24.0_aarch64.app.tar.gz.sig", + "Lime_1.24.0_aarch64.dmg", + "Lime_1.24.0_x64.app.tar.gz", + "Lime_1.24.0_x64.app.tar.gz.sig", + "Lime_1.24.0_x64.dmg", "latest.json", ].sort(), ); expect( fs.readFileSync( - path.join(outDir, "Lime_1.23.0_aarch64.app.tar.gz.sig"), + path.join(outDir, "Lime_1.24.0_aarch64.app.tar.gz.sig"), "utf8", ), ).toBe("arm-sig"); expect( fs.readFileSync( - path.join(outDir, "Lime_1.23.0_x64.app.tar.gz.sig"), + path.join(outDir, "Lime_1.24.0_x64.app.tar.gz.sig"), "utf8", ), ).toBe("x64-sig"); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fd46b5f13..dc8352660 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.23.0" +version = "1.24.0" dependencies = [ "anyhow", "arboard", @@ -5168,7 +5168,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.23.0" +version = "1.24.0" dependencies = [ "anyhow", "aster-core", @@ -5197,7 +5197,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.23.0" +version = "1.24.0" dependencies = [ "chrono", "futures", @@ -5214,7 +5214,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.23.0" +version = "1.24.0" dependencies = [ "clap", "lime-core", @@ -5226,7 +5226,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.23.0" +version = "1.24.0" dependencies = [ "async-trait", "lime-core", @@ -5242,7 +5242,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.23.0" +version = "1.24.0" dependencies = [ "aster-models", "async-trait", @@ -5295,7 +5295,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.23.0" +version = "1.24.0" dependencies = [ "aes", "axum 0.7.9", @@ -5325,7 +5325,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.23.0" +version = "1.24.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5345,7 +5345,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.23.0" +version = "1.24.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5361,7 +5361,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.23.0" +version = "1.24.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.23.0" +version = "1.24.0" dependencies = [ "async-trait", "lime-core", @@ -5411,7 +5411,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.23.0" +version = "1.24.0" dependencies = [ "anyhow", "async-stream", @@ -5466,7 +5466,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.23.0" +version = "1.24.0" dependencies = [ "aster-core", "async-stream", @@ -5510,7 +5510,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.23.0" +version = "1.24.0" dependencies = [ "axum 0.7.9", "futures", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.23.0" +version = "1.24.0" dependencies = [ "anyhow", "aster-core", @@ -5568,7 +5568,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.23.0" +version = "1.24.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5586,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.23.0" +version = "1.24.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fb54defc6..adc65dac6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.23.0" +version = "1.24.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -193,7 +193,7 @@ version = "2.4" [package] name = "lime" -version = "1.23.0" +version = "1.24.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/aster-rust/Cargo.lock b/src-tauri/crates/aster-rust/Cargo.lock index 8c3db4cd2..af24679c7 100644 --- a/src-tauri/crates/aster-rust/Cargo.lock +++ b/src-tauri/crates/aster-rust/Cargo.lock @@ -6784,7 +6784,7 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ diff --git a/src-tauri/crates/media-runtime/src/lib.rs b/src-tauri/crates/media-runtime/src/lib.rs index dc3e2f82c..1f673488c 100644 --- a/src-tauri/crates/media-runtime/src/lib.rs +++ b/src-tauri/crates/media-runtime/src/lib.rs @@ -68,6 +68,7 @@ pub fn build_image_generation_endpoint(host: &str, port: u16) -> String { #[serde(rename_all = "snake_case")] pub enum TaskType { ImageGenerate, + AudioGenerate, CoverGenerate, VideoGenerate, TranscriptionGenerate, @@ -81,6 +82,7 @@ impl TaskType { pub fn as_str(self) -> &'static str { match self { Self::ImageGenerate => "image_generate", + Self::AudioGenerate => "audio_generate", Self::CoverGenerate => "cover_generate", Self::VideoGenerate => "video_generate", Self::TranscriptionGenerate => "transcription_generate", @@ -94,6 +96,7 @@ impl TaskType { pub fn command_name(self) -> &'static str { match self { Self::ImageGenerate => "image", + Self::AudioGenerate => "audio", Self::CoverGenerate => "cover", Self::VideoGenerate => "video", Self::TranscriptionGenerate => "transcription", @@ -108,6 +111,7 @@ impl TaskType { match self { Self::VideoGenerate => "queued", Self::ImageGenerate + | Self::AudioGenerate | Self::CoverGenerate | Self::TranscriptionGenerate | Self::BroadcastGenerate @@ -120,6 +124,7 @@ impl TaskType { pub fn family(self) -> &'static str { match self { Self::ImageGenerate | Self::CoverGenerate => "image", + Self::AudioGenerate => "audio", Self::VideoGenerate => "video", Self::TranscriptionGenerate | Self::BroadcastGenerate @@ -132,6 +137,7 @@ impl TaskType { pub fn all() -> &'static [Self] { &[ Self::ImageGenerate, + Self::AudioGenerate, Self::CoverGenerate, Self::VideoGenerate, Self::TranscriptionGenerate, @@ -149,6 +155,7 @@ impl FromStr for TaskType { fn from_str(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "image" | "image_generate" => Ok(Self::ImageGenerate), + "audio" | "audio_generate" | "voice" | "voice_generate" => Ok(Self::AudioGenerate), "cover" | "cover_generate" => Ok(Self::CoverGenerate), "video" | "video_generate" => Ok(Self::VideoGenerate), "transcription" | "transcribe" | "transcription_generate" => { @@ -671,6 +678,7 @@ fn task_family_for_type(task_type: &str) -> String { .map(|value| value.family().to_string()) .unwrap_or_else(|| match task_type.trim().to_ascii_lowercase().as_str() { value if value.contains("image") || value.contains("cover") => "image".to_string(), + value if value.contains("audio") || value.contains("voice") => "audio".to_string(), value if value.contains("video") => "video".to_string(), value if value.contains("resource") => "resource".to_string(), "transcription_generate" | "broadcast_generate" | "url_parse" | "typesetting" => { @@ -711,6 +719,9 @@ fn derive_task_summary(task_type: &str, title: Option<&str>, payload: &Value) -> "image_generate" | "cover_generate" | "video_generate" => { payload_string(payload, &["prompt", "usage"]) } + "audio_generate" => { + payload_string(payload, &["source_text", "prompt", "voice", "voice_style"]) + } "transcription_generate" => { payload_string(payload, &["prompt", "source_path", "source_url"]) } @@ -739,6 +750,12 @@ fn derive_task_ui_hints(task_family: &str, summary: Option<&str>) -> TaskUiHints preferred_surface: Some("claw_chat".to_string()), open_action: Some("open_video_workbench".to_string()), }, + "audio" => TaskUiHints { + render_mode: Some("media_placeholder_card".to_string()), + placeholder_text: Some(format!("[audio:{}]", summary.unwrap_or("音频任务"))), + preferred_surface: Some("claw_chat".to_string()), + open_action: Some("open_audio_player".to_string()), + }, _ => TaskUiHints { render_mode: Some("task_status_card".to_string()), placeholder_text: None, @@ -2873,6 +2890,40 @@ mod tests { ); } + #[test] + fn write_task_artifact_supports_audio_generate() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let output = write_task_artifact( + temp_dir.path(), + TaskType::AudioGenerate, + Some("配音".to_string()), + serde_json::json!({ + "source_text": "这是需要配音的文案", + "voice": "warm_narrator", + "audio_output": { + "status": "pending", + "mime_type": "audio/mpeg" + } + }), + TaskWriteOptions::default(), + ) + .expect("write audio task"); + + assert!(output.path.starts_with(".lime/tasks/audio_generate/")); + assert_eq!(output.task_type, "audio_generate"); + assert_eq!(output.task_family, "audio"); + assert_eq!(output.status, "pending_submit"); + assert_eq!(output.normalized_status, "pending"); + assert_eq!( + output.ui_hints.open_action.as_deref(), + Some("open_audio_player") + ); + assert_eq!( + output.record.payload.pointer("/audio_output/status"), + Some(&serde_json::json!("pending")) + ); + } + #[test] fn write_task_artifact_reuses_idempotent_record() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index 39ca4ae7b..e9de0c364 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -20,6 +20,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::RwLock; +use url::form_urlencoded; /// 内嵌的模型资源目录名(相对于 resource_dir) /// 对应 tauri.conf.json 中的 "resources/models/**/*" @@ -27,6 +28,8 @@ const MODELS_RESOURCE_DIR: &str = "resources/models"; const MODELS_HOST_ALIASES_FILE: &str = "host_aliases.json"; const MODELS_HOST_ALIASES_USER_FILE: &str = "host_aliases.user.json"; const DEFAULT_USER_HOST_ALIASES_TEMPLATE: &str = "{\n \"rules\": []\n}\n"; +const LIME_TENANT_HEADER: &str = "X-Lime-Tenant-ID"; +const LIME_TENANT_PARAM: &str = "lime_tenant_id"; /// 仓库索引文件结构 #[derive(Debug, Deserialize)] @@ -2592,6 +2595,77 @@ impl ModelRegistryService { } } + fn parse_api_host_url(api_host: &str) -> Option { + let trimmed = api_host.trim(); + if trimmed.is_empty() { + return None; + } + + reqwest::Url::parse(trimmed) + .or_else(|_| reqwest::Url::parse(&format!("https://{trimmed}"))) + .ok() + } + + fn api_host_without_query_fragment(api_host: &str) -> String { + let trimmed = api_host.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return String::new(); + } + + let had_scheme = trimmed.starts_with("http://") || trimmed.starts_with("https://"); + if let Some(mut url) = Self::parse_api_host_url(trimmed) { + url.set_query(None); + url.set_fragment(None); + let normalized = url.to_string().trim_end_matches('/').to_string(); + return if had_scheme { + normalized + } else { + normalized + .trim_start_matches("https://") + .trim_end_matches('/') + .to_string() + }; + } + + trimmed + .split(['?', '#']) + .next() + .unwrap_or(trimmed) + .trim_end_matches('/') + .to_string() + } + + fn normalize_lime_tenant_id(value: &str) -> Option { + let tenant_id = value.trim(); + if tenant_id.is_empty() { + return None; + } + + tenant_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + .then(|| tenant_id.to_string()) + } + + fn parse_lime_tenant_id_from_pairs(value: &str) -> Option { + form_urlencoded::parse(value.as_bytes()).find_map(|(key, value)| { + (key == LIME_TENANT_PARAM) + .then(|| Self::normalize_lime_tenant_id(&value)) + .flatten() + }) + } + + fn lime_tenant_id_from_api_host(api_host: &str) -> Option { + let url = Self::parse_api_host_url(api_host)?; + + url.query() + .and_then(Self::parse_lime_tenant_id_from_pairs) + .or_else(|| { + url.fragment() + .and_then(Self::parse_lime_tenant_id_from_pairs) + }) + } + fn build_gemini_models_api_url(api_host: &str) -> String { let host = api_host.trim_end_matches('/'); @@ -2707,8 +2781,10 @@ impl ModelRegistryService { fn build_models_api_hint(provider_id: &str, api_host: &str, api_url: &str) -> Option { let normalized_host = normalize_openai_model_discovery_host(api_host); - let original_host = api_host.trim().trim_end_matches('/'); - if !original_host.is_empty() && normalized_host.trim_end_matches('/') != original_host { + let original_host = Self::api_host_without_query_fragment(api_host); + if !original_host.is_empty() + && normalized_host.trim_end_matches('/') != original_host.trim_end_matches('/') + { return Some(format!( "当前 API Host 看起来是具体接口地址而不是基础地址。Lime 已自动回退到 `{api_url}` 尝试模型枚举;如果上游本身不提供 `/models`,请改填基础 API Host,或直接在 Provider 中填写自定义模型。" )); @@ -2816,6 +2892,9 @@ impl ModelRegistryService { for (name, value) in runtime_spec.extra_headers { headers.push(((*name).to_string(), (*value).to_string())); } + if let Some(tenant_id) = Self::lime_tenant_id_from_api_host(normalized_host) { + headers.push((LIME_TENANT_HEADER.to_string(), tenant_id)); + } Ok(PreparedModelFetchRequest { protocol, @@ -3445,7 +3524,7 @@ pub struct FetchModelsResult { #[cfg(test)] mod tests { - use super::{HostAliasRule, ModelFetchProtocol, ModelRegistryService}; + use super::{HostAliasRule, ModelFetchProtocol, ModelRegistryService, LIME_TENANT_HEADER}; use lime_core::database::dao::api_key_provider::ApiProviderType; use lime_core::database::DbConnection; use rusqlite::Connection; @@ -3486,6 +3565,38 @@ mod tests { ); } + #[test] + fn test_prepare_model_fetch_request_adds_lime_tenant_header_from_fragment() { + let request = ModelRegistryService::prepare_model_fetch_request( + "lime-hub", + "https://llm.limeai.run#lime_tenant_id=tenant-0001", + "sk-lime-test", + Some(ApiProviderType::Openai), + ) + .expect("prepare Lime model fetch request"); + + assert_eq!(request.url, "https://llm.limeai.run/v1/models"); + assert!(request + .headers + .iter() + .any(|(name, value)| { name == "Authorization" && value == "Bearer sk-lime-test" })); + assert!(request + .headers + .iter() + .any(|(name, value)| { name == LIME_TENANT_HEADER && value == "tenant-0001" })); + } + + #[test] + fn test_build_models_api_hint_ignores_lime_tenant_fragment() { + let hint = ModelRegistryService::build_models_api_hint( + "lime-hub", + "https://llm.limeai.run#lime_tenant_id=tenant-0001", + "https://llm.limeai.run/v1/models", + ); + + assert_eq!(hint, None); + } + #[test] fn test_build_gemini_models_api_url() { assert_eq!( diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index 95d88634a..4f10694ae 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -6,6 +6,7 @@ pub const LIST_MCP_RESOURCES_TOOL_NAME: &str = "ListMcpResourcesTool"; pub const READ_MCP_RESOURCE_TOOL_NAME: &str = "ReadMcpResourceTool"; pub const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image"; pub const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task"; +pub const LIME_CREATE_AUDIO_TASK_TOOL_NAME: &str = "lime_create_audio_generation_task"; pub const LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME: &str = "lime_create_transcription_task"; pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task"; pub const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task"; @@ -521,6 +522,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, + ToolCatalogEntry { + name: LIME_CREATE_AUDIO_TASK_TOOL_NAME, + profiles: WORKBENCH_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, ToolCatalogEntry { name: LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME, profiles: WORKBENCH_PROFILES, @@ -1038,6 +1048,7 @@ mod tests { let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::workbench()); assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(&LIME_CREATE_VIDEO_TASK_TOOL_NAME)); + assert!(names.contains(&LIME_CREATE_AUDIO_TASK_TOOL_NAME)); assert!(names.contains(&LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME)); } @@ -1098,9 +1109,10 @@ mod tests { #[test] fn test_workbench_tool_names_only_returns_workbench_increment() { let names = workbench_tool_names().into_iter().collect::>(); - assert_eq!(names.len(), 11); + assert_eq!(names.len(), 12); assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME)); + assert!(names.contains(LIME_CREATE_AUDIO_TASK_TOOL_NAME)); assert!(names.contains(LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME)); assert!(names.contains(LIME_RUN_SERVICE_SKILL_TOOL_NAME)); assert!(names.contains(LIME_SEARCH_WEB_IMAGES_TOOL_NAME)); @@ -1130,6 +1142,7 @@ mod tests { assert!(names.contains(&"TeamCreate")); assert!(names.contains(&"TeamDelete")); assert!(names.contains(&LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME)); + assert!(names.contains(&LIME_CREATE_AUDIO_TASK_TOOL_NAME)); assert!(names.contains(&LIME_SEARCH_WEB_IMAGES_TOOL_NAME)); assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME)); assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME)); diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 212c8582b..9610a6ab2 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1506,6 +1506,7 @@ pub fn run() { commands::video_generation_cmd::list_video_generation_tasks, commands::video_generation_cmd::cancel_video_generation_task, commands::media_task_cmd::create_image_generation_task_artifact, + commands::media_task_cmd::create_audio_generation_task_artifact, commands::media_task_cmd::get_media_task_artifact, commands::media_task_cmd::list_media_task_artifacts, commands::media_task_cmd::cancel_media_task_artifact, diff --git a/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs index 084b6bb45..302a605de 100644 --- a/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_text_transform_contract_fields, text_transform_required_capabilities, + text_transform_runtime_contract, TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_MODALITY, + TEXT_TRANSFORM_ROUTING_SLOT, +}; const ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const ANALYSIS_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -47,6 +52,33 @@ fn extract_object_string_array( .unwrap_or_default() } +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -79,6 +111,18 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_text_transform_contract_metadata( + launch: &mut serde_json::Map, +) { + insert_text_transform_contract_fields(launch); + if let Some(analysis_request) = launch + .get_mut("analysis_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_text_transform_contract_fields(analysis_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -97,6 +141,12 @@ pub(crate) fn prepare_analysis_skill_launch_request_metadata( &mut metadata, &["analysis_skill_launch", "analysisSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["analysis_skill_launch", "analysisSkillLaunch"], + ) { + ensure_text_transform_contract_metadata(launch); + } Some(metadata) } @@ -229,6 +279,8 @@ fn build_analysis_skill_launch_system_prompt( let content_id = extract_object_string(analysis_request, &["content_id", "contentId"]); let entry_source = extract_object_string(analysis_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_analysis_command".to_string()); + let required_capabilities = text_transform_required_capabilities(); + let runtime_contract = text_transform_runtime_contract(); let args_payload = serde_json::json!({ "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), "analysis_request": serde_json::Value::Object(analysis_request.clone()), @@ -250,6 +302,22 @@ fn build_analysis_skill_launch_system_prompt( let mut lines = vec![ ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自分析技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={}, modality={}, routing_slot={};`@分析` / `@发布合规` 只是 text_transform 的上层入口,首刀仍走 Skill(analysis)。", + TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_MODALITY, TEXT_TRANSFORM_ROUTING_SLOT + ), + format!( + "- 当前合同所需能力:{};不得退回 frontend_direct_text_transform、tool_search_before_text_transform_skill 或 web_search_before_text_transform_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速判断要分析什么,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs index a7a165b83..098d7d88b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs +++ b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs @@ -1,4 +1,8 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + browser_control_required_capabilities, browser_control_runtime_contract, + BROWSER_CONTROL_CONTRACT_KEY, BROWSER_CONTROL_MODALITY, BROWSER_CONTROL_ROUTING_SLOT, +}; pub(crate) const BROWSER_PROFILE_KEY_ENV_KEYS: &[&str] = &["LIME_BROWSER_PROFILE_KEY", "PROXYCAST_BROWSER_PROFILE_KEY"]; @@ -14,12 +18,36 @@ fn shared_browser_assist_runtime_hints( BROWSER_ASSIST_RUNTIME_HINTS.get_or_init(|| tokio::sync::RwLock::new(HashMap::new())) } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct BrowserAssistModalityRuntimeContract { + pub(crate) contract_key: String, + pub(crate) modality: String, + pub(crate) required_capabilities: Vec, + pub(crate) routing_slot: String, + pub(crate) runtime_contract: serde_json::Value, + pub(crate) entry_source: Option, +} + +impl BrowserAssistModalityRuntimeContract { + pub(crate) fn metadata_value(&self) -> serde_json::Value { + serde_json::json!({ + "contractKey": self.contract_key, + "modality": self.modality, + "requiredCapabilities": self.required_capabilities, + "routingSlot": self.routing_slot, + "runtimeContract": self.runtime_contract, + "entrySource": self.entry_source, + }) + } +} + +#[derive(Debug, Clone, PartialEq)] pub(crate) struct BrowserAssistRuntimeHint { pub(crate) profile_key: String, pub(crate) preferred_backend: Option, pub(crate) auto_launch: bool, pub(crate) launch_url: Option, + pub(crate) modality_runtime_contract: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,6 +73,94 @@ pub(crate) fn parse_browser_backend_hint(value: &str) -> Option, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn extract_browser_assist_string_array( + browser_assist: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn extract_browser_assist_value( + browser_assist: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| browser_assist.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +pub(crate) fn extract_browser_assist_modality_runtime_contract( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let browser_assist = + extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])?; + let contract_key = extract_browser_assist_string( + browser_assist, + &["modality_contract_key", "modalityContractKey"], + )?; + if contract_key != BROWSER_CONTROL_CONTRACT_KEY { + return None; + } + + let required_capabilities = { + let values = extract_browser_assist_string_array( + browser_assist, + &["required_capabilities", "requiredCapabilities"], + ); + if values.is_empty() { + browser_control_required_capabilities() + } else { + values + } + }; + + Some(BrowserAssistModalityRuntimeContract { + contract_key, + modality: extract_browser_assist_string(browser_assist, &["modality"]) + .unwrap_or_else(|| BROWSER_CONTROL_MODALITY.to_string()), + required_capabilities, + routing_slot: extract_browser_assist_string( + browser_assist, + &["routing_slot", "routingSlot"], + ) + .unwrap_or_else(|| BROWSER_CONTROL_ROUTING_SLOT.to_string()), + runtime_contract: extract_browser_assist_value( + browser_assist, + &["runtime_contract", "runtimeContract"], + ) + .unwrap_or_else(browser_control_runtime_contract), + entry_source: extract_browser_assist_string( + browser_assist, + &["entry_source", "entrySource"], + ), + }) +} + pub(crate) fn extract_browser_assist_runtime_hint( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -80,6 +196,9 @@ pub(crate) fn extract_browser_assist_runtime_hint( preferred_backend, auto_launch, launch_url, + modality_runtime_contract: extract_browser_assist_modality_runtime_contract( + request_metadata, + ), }) } diff --git a/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs index 04a23ced7..8d4bd5f8d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_web_research_contract_fields, web_research_required_capabilities, + web_research_runtime_contract, WEB_RESEARCH_CONTRACT_KEY, WEB_RESEARCH_MODALITY, + WEB_RESEARCH_ROUTING_SLOT, +}; const DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const DEEP_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -23,6 +28,62 @@ fn extract_object_string( .map(str::to_string) } +fn extract_object_string_array( + object: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn extract_object_value( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -55,6 +116,16 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_web_research_contract_metadata(launch: &mut serde_json::Map) { + insert_web_research_contract_fields(launch); + if let Some(deep_search_request) = launch + .get_mut("deep_search_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_web_research_contract_fields(deep_search_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -73,6 +144,12 @@ pub(crate) fn prepare_deep_search_skill_launch_request_metadata( &mut metadata, &["deep_search_skill_launch", "deepSearchSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["deep_search_skill_launch", "deepSearchSkillLaunch"], + ) { + ensure_web_research_contract_metadata(launch); + } Some(metadata) } @@ -198,6 +275,31 @@ fn build_deep_search_skill_launch_system_prompt( let content_id = extract_object_string(deep_search_request, &["content_id", "contentId"]); let entry_source = extract_object_string(deep_search_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_deep_search_command".to_string()); + let modality_contract_key = extract_object_string( + deep_search_request, + &["modality_contract_key", "modalityContractKey"], + ) + .unwrap_or_else(|| WEB_RESEARCH_CONTRACT_KEY.to_string()); + let modality = extract_object_string(deep_search_request, &["modality"]) + .unwrap_or_else(|| WEB_RESEARCH_MODALITY.to_string()); + let routing_slot = extract_object_string(deep_search_request, &["routing_slot", "routingSlot"]) + .unwrap_or_else(|| WEB_RESEARCH_ROUTING_SLOT.to_string()); + let required_capabilities = { + let values = extract_object_string_array( + deep_search_request, + &["required_capabilities", "requiredCapabilities"], + ); + if values.is_empty() { + web_research_required_capabilities() + } else { + values + } + }; + let runtime_contract = extract_object_value( + deep_search_request, + &["runtime_contract", "runtimeContract"], + ) + .unwrap_or_else(web_research_runtime_contract); let args_payload = serde_json::json!({ "user_input": raw_text .clone() @@ -223,6 +325,21 @@ fn build_deep_search_skill_launch_system_prompt( let mut lines = vec![ DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自深搜技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};后续多轮检索、引用与产物必须原样保留 contract 字段。" + ), + format!( + "- 当前合同所需能力:{};不得退回 model_memory_only_answer 或 local_file_search_before_research_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速归纳用户的深搜目标,然后立刻把任务交给 Skill 工具;不要先直接给出记忆性结论。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index 559237e73..945b29e3d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -333,7 +333,8 @@ pub(crate) use browser_assist::{ }; #[cfg(test)] pub(crate) use browser_assist::{ - extract_browser_assist_runtime_hint, BROWSER_ASSIST_ALLOW_PATTERN, + extract_browser_assist_runtime_hint, BrowserAssistModalityRuntimeContract, + BROWSER_ASSIST_ALLOW_PATTERN, }; #[allow(unused_imports)] pub(crate) use command_api::{ diff --git a/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs index b84ad6cd4..84b46a329 100644 --- a/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_pdf_extract_contract_fields, pdf_extract_required_capabilities, + pdf_extract_runtime_contract, PDF_EXTRACT_CONTRACT_KEY, PDF_EXTRACT_MODALITY, + PDF_EXTRACT_ROUTING_SLOT, +}; const PDF_READ_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const PDF_READ_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -21,6 +26,62 @@ fn extract_object_string( .map(str::to_string) } +fn extract_object_string_array( + object: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn extract_object_value( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -53,6 +114,16 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_pdf_extract_contract_metadata(launch: &mut serde_json::Map) { + insert_pdf_extract_contract_fields(launch); + if let Some(pdf_read_request) = launch + .get_mut("pdf_read_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_pdf_extract_contract_fields(pdf_read_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -71,6 +142,12 @@ pub(crate) fn prepare_pdf_read_skill_launch_request_metadata( &mut metadata, &["pdf_read_skill_launch", "pdfReadSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["pdf_read_skill_launch", "pdfReadSkillLaunch"], + ) { + ensure_pdf_extract_contract_metadata(launch); + } Some(metadata) } @@ -194,6 +271,29 @@ fn build_pdf_read_skill_launch_system_prompt( let content_id = extract_object_string(pdf_read_request, &["content_id", "contentId"]); let entry_source = extract_object_string(pdf_read_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_pdf_read_command".to_string()); + let modality_contract_key = extract_object_string( + pdf_read_request, + &["modality_contract_key", "modalityContractKey"], + ) + .unwrap_or_else(|| PDF_EXTRACT_CONTRACT_KEY.to_string()); + let modality = extract_object_string(pdf_read_request, &["modality"]) + .unwrap_or_else(|| PDF_EXTRACT_MODALITY.to_string()); + let routing_slot = extract_object_string(pdf_read_request, &["routing_slot", "routingSlot"]) + .unwrap_or_else(|| PDF_EXTRACT_ROUTING_SLOT.to_string()); + let required_capabilities = { + let values = extract_object_string_array( + pdf_read_request, + &["required_capabilities", "requiredCapabilities"], + ); + if values.is_empty() { + pdf_extract_required_capabilities() + } else { + values + } + }; + let runtime_contract = + extract_object_value(pdf_read_request, &["runtime_contract", "runtimeContract"]) + .unwrap_or_else(pdf_extract_runtime_contract); let args_payload = serde_json::json!({ "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), "pdf_read_request": serde_json::Value::Object(pdf_read_request.clone()), @@ -220,6 +320,21 @@ fn build_pdf_read_skill_launch_system_prompt( let mut lines = vec![ PDF_READ_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自读 PDF 技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};后续读取与产物必须原样保留 contract 字段。" + ), + format!( + "- 当前合同所需能力:{};不得退回 frontend_direct_pdf_parse 或 generic_chat_summary_only。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先确认 PDF 来源,再立刻把任务交给 Skill 工具;不要在未实际读取文件前直接总结 PDF 内容。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index eee220fc6..2614a82f7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -473,6 +473,30 @@ fn build_service_scene_launch_system_prompt( } )); } + if let Some(contract_key) = context.modality_contract_key.as_deref() { + lines.push(format!( + "- 当前底层运行合同:modality_contract_key={contract_key}, modality={}, routing_slot={};后续执行与产物必须原样保留 contract 字段。", + context.modality.as_deref().unwrap_or("未提供"), + context.routing_slot.as_deref().unwrap_or("未提供") + )); + } + if !context.required_capabilities.is_empty() { + lines.push(format!( + "- 当前合同所需能力:{}。", + context.required_capabilities.join(", ") + )); + } + if let Some(runtime_contract) = render_json_inline(context.runtime_contract.as_ref()) { + lines.push(format!( + "- 当前 runtime_contract(JSON):{runtime_contract}。" + )); + } + if context.modality_contract_key.as_deref() == Some("voice_generation") { + lines.push( + "- 当前配音任务必须继续走 voice_generation / voice_runtime 主链;需要沉淀产物时使用 lime_create_audio_generation_task 写入 audio_task/audio_output,不要退回 legacy_tts_test_command,也不要伪造云端已提交。" + .to_string(), + ); + } if let Some(value) = render_json_inline(context.slot_values.as_ref()) { lines.push(format!("- 当前 slot_values(JSON):{value}。")); } diff --git a/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs index d677e6e81..1544c3d9f 100644 --- a/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_web_research_contract_fields, web_research_required_capabilities, + web_research_runtime_contract, WEB_RESEARCH_CONTRACT_KEY, WEB_RESEARCH_MODALITY, + WEB_RESEARCH_ROUTING_SLOT, +}; const REPORT_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const REPORT_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -23,6 +28,62 @@ fn extract_object_string( .map(str::to_string) } +fn extract_object_string_array( + object: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn extract_object_value( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -55,6 +116,16 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_web_research_contract_metadata(launch: &mut serde_json::Map) { + insert_web_research_contract_fields(launch); + if let Some(report_request) = launch + .get_mut("report_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_web_research_contract_fields(report_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -73,6 +144,12 @@ pub(crate) fn prepare_report_skill_launch_request_metadata( &mut metadata, &["report_skill_launch", "reportSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["report_skill_launch", "reportSkillLaunch"], + ) { + ensure_web_research_contract_metadata(launch); + } Some(metadata) } @@ -197,6 +274,29 @@ fn build_report_skill_launch_system_prompt( let content_id = extract_object_string(report_request, &["content_id", "contentId"]); let entry_source = extract_object_string(report_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_report_command".to_string()); + let modality_contract_key = extract_object_string( + report_request, + &["modality_contract_key", "modalityContractKey"], + ) + .unwrap_or_else(|| WEB_RESEARCH_CONTRACT_KEY.to_string()); + let modality = extract_object_string(report_request, &["modality"]) + .unwrap_or_else(|| WEB_RESEARCH_MODALITY.to_string()); + let routing_slot = extract_object_string(report_request, &["routing_slot", "routingSlot"]) + .unwrap_or_else(|| WEB_RESEARCH_ROUTING_SLOT.to_string()); + let required_capabilities = { + let values = extract_object_string_array( + report_request, + &["required_capabilities", "requiredCapabilities"], + ); + if values.is_empty() { + web_research_required_capabilities() + } else { + values + } + }; + let runtime_contract = + extract_object_value(report_request, &["runtime_contract", "runtimeContract"]) + .unwrap_or_else(web_research_runtime_contract); let args_payload = serde_json::json!({ "user_input": raw_text .clone() @@ -222,6 +322,21 @@ fn build_report_skill_launch_system_prompt( let mut lines = vec![ REPORT_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自研报技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};`@研报` / `@竞品` 只是 web_research 的上层入口,首刀仍走 Skill(report_generate)。" + ), + format!( + "- 当前合同所需能力:{};不得退回 model_memory_only_answer 或 local_file_search_before_research_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速归纳用户的研报目标,然后立刻把任务交给 Skill 工具;不要先写空泛长文。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), @@ -237,6 +352,7 @@ fn build_report_skill_launch_system_prompt( "- 如果某个工具目录/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(report_generate)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要退回普通聊天写长文。".to_string(), "- report_generate skill 内部必须先执行真实联网检索,再产出研究报告。".to_string(), + "- `report_generate` 只是 `web_research` 下的报告型子入口;证据链必须保留 Skill(report_generate) 与 search_query / WebSearch 时间线。".to_string(), "- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在结果中标注时间口径。".to_string(), "- 最终输出必须清楚区分核心结论、关键证据、风险/待确认项与建议动作,不要把推断写成已确认事实。".to_string(), format!("- 当前研报请求上下文(JSON):{request_json}"), diff --git a/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs index 98b4c808e..765874e13 100644 --- a/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_web_research_contract_fields, web_research_required_capabilities, + web_research_runtime_contract, WEB_RESEARCH_CONTRACT_KEY, WEB_RESEARCH_MODALITY, + WEB_RESEARCH_ROUTING_SLOT, +}; const RESEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const RESEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -23,6 +28,62 @@ fn extract_object_string( .map(str::to_string) } +fn extract_object_string_array( + object: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn extract_object_value( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -55,6 +116,16 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_web_research_contract_metadata(launch: &mut serde_json::Map) { + insert_web_research_contract_fields(launch); + if let Some(research_request) = launch + .get_mut("research_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_web_research_contract_fields(research_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -73,6 +144,12 @@ pub(crate) fn prepare_research_skill_launch_request_metadata( &mut metadata, &["research_skill_launch", "researchSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["research_skill_launch", "researchSkillLaunch"], + ) { + ensure_web_research_contract_metadata(launch); + } Some(metadata) } @@ -197,6 +274,29 @@ fn build_research_skill_launch_system_prompt( let content_id = extract_object_string(research_request, &["content_id", "contentId"]); let entry_source = extract_object_string(research_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_search_command".to_string()); + let modality_contract_key = extract_object_string( + research_request, + &["modality_contract_key", "modalityContractKey"], + ) + .unwrap_or_else(|| WEB_RESEARCH_CONTRACT_KEY.to_string()); + let modality = extract_object_string(research_request, &["modality"]) + .unwrap_or_else(|| WEB_RESEARCH_MODALITY.to_string()); + let routing_slot = extract_object_string(research_request, &["routing_slot", "routingSlot"]) + .unwrap_or_else(|| WEB_RESEARCH_ROUTING_SLOT.to_string()); + let required_capabilities = { + let values = extract_object_string_array( + research_request, + &["required_capabilities", "requiredCapabilities"], + ); + if values.is_empty() { + web_research_required_capabilities() + } else { + values + } + }; + let runtime_contract = + extract_object_value(research_request, &["runtime_contract", "runtimeContract"]) + .unwrap_or_else(web_research_runtime_contract); let args_payload = serde_json::json!({ "user_input": raw_text .clone() @@ -222,6 +322,21 @@ fn build_research_skill_launch_system_prompt( let mut lines = vec![ RESEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自搜索技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};后续检索、引用与产物必须原样保留 contract 字段。" + ), + format!( + "- 当前合同所需能力:{};不得退回 model_memory_only_answer 或 local_file_search_before_research_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速归纳用户的搜索目标,然后立刻把任务交给 Skill 工具;不要先直接给出记忆性结论。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs index c836ba39e..31f832a33 100644 --- a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs @@ -2,6 +2,10 @@ use super::*; use crate::agent_tools::catalog::{ LIME_SITE_INFO_TOOL_NAME, LIME_SITE_RUN_TOOL_NAME, LIME_SITE_SEARCH_TOOL_NAME, }; +use crate::commands::modality_runtime_contracts::{ + insert_voice_generation_contract_fields, VOICE_GENERATION_CONTRACT_KEY, + VOICE_GENERATION_ROUTING_SLOT, +}; use crate::services::site_capability_service::{ get_site_adapter, run_site_adapter_with_optional_save, RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult, @@ -77,6 +81,11 @@ pub(crate) struct ServiceSceneLaunchContext { pub(crate) project_id: Option, pub(crate) content_id: Option, pub(crate) entry_source: Option, + pub(crate) modality_contract_key: Option, + pub(crate) modality: Option, + pub(crate) required_capabilities: Vec, + pub(crate) routing_slot: Option, + pub(crate) runtime_contract: Option, pub(crate) oem_runtime: ServiceSceneLaunchOemRuntimeContext, } @@ -102,6 +111,35 @@ fn extract_object_string( .map(str::to_string) } +fn extract_object_string_array( + object: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(|value| match value { + serde_json::Value::Array(items) => Some( + items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>(), + ), + serde_json::Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + Some(Vec::new()) + } else { + Some(vec![trimmed.to_string()]) + } + } + _ => None, + }) + .unwrap_or_default() +} + fn normalized_optional_object( value: Option<&serde_json::Value>, ) -> Option<&serde_json::Map> { @@ -399,6 +437,21 @@ pub(crate) fn extract_service_scene_launch_context( project_id: extract_object_string(service_scene_run, &["project_id", "projectId"]), content_id: extract_object_string(service_scene_run, &["content_id", "contentId"]), entry_source: extract_object_string(service_scene_run, &["entry_source", "entrySource"]), + modality_contract_key: extract_object_string( + service_scene_run, + &["modality_contract_key", "modalityContractKey"], + ), + modality: extract_object_string(service_scene_run, &["modality"]), + required_capabilities: extract_object_string_array( + service_scene_run, + &["required_capabilities", "requiredCapabilities"], + ), + routing_slot: extract_object_string(service_scene_run, &["routing_slot", "routingSlot"]), + runtime_contract: service_scene_run + .get("runtime_contract") + .or_else(|| service_scene_run.get("runtimeContract")) + .cloned() + .filter(serde_json::Value::is_object), oem_runtime: ServiceSceneLaunchOemRuntimeContext { scene_base_url: oem_runtime.and_then(|value| { extract_object_string(value, &["scene_base_url", "sceneBaseUrl"]) @@ -411,6 +464,50 @@ pub(crate) fn extract_service_scene_launch_context( }) } +fn ensure_voice_generation_contract_metadata(metadata: &mut serde_json::Value) { + let Some(harness) = metadata + .get_mut("harness") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + + for launch_key in ["service_scene_launch", "serviceSceneLaunch"] { + let Some(service_scene_run) = harness + .get_mut(launch_key) + .and_then(serde_json::Value::as_object_mut) + .and_then(|launch| { + if launch.contains_key("service_scene_run") { + launch.get_mut("service_scene_run") + } else { + launch.get_mut("serviceSceneRun") + } + }) + .and_then(serde_json::Value::as_object_mut) + else { + continue; + }; + + let scene_key = extract_object_string(service_scene_run, &["scene_key", "sceneKey"]); + let entry_source = + extract_object_string(service_scene_run, &["entry_source", "entrySource"]); + let contract_key = extract_object_string( + service_scene_run, + &["modality_contract_key", "modalityContractKey"], + ); + let routing_slot = + extract_object_string(service_scene_run, &["routing_slot", "routingSlot"]); + let is_voice_runtime = scene_key.as_deref() == Some("voice_runtime") + || entry_source.as_deref() == Some("at_voice_command") + || contract_key.as_deref() == Some(VOICE_GENERATION_CONTRACT_KEY) + || routing_slot.as_deref() == Some(VOICE_GENERATION_ROUTING_SLOT); + + if is_voice_runtime { + insert_voice_generation_contract_fields(service_scene_run); + } + } +} + pub(crate) fn prepare_service_scene_launch_request_metadata( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -419,6 +516,7 @@ pub(crate) fn prepare_service_scene_launch_request_metadata( &mut metadata, &["service_scene_launch", "serviceSceneLaunch"], ); + ensure_voice_generation_contract_metadata(&mut metadata); Some(metadata) } diff --git a/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs index 08d738775..5eca67954 100644 --- a/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_web_research_contract_fields, web_research_required_capabilities, + web_research_runtime_contract, WEB_RESEARCH_CONTRACT_KEY, WEB_RESEARCH_MODALITY, + WEB_RESEARCH_ROUTING_SLOT, +}; const SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const SITE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -25,6 +30,62 @@ fn extract_object_string( .map(str::to_string) } +fn extract_object_string_array( + object: &serde_json::Map, + keys: &[&str], +) -> Vec { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn extract_object_value( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find(|value| value.is_object()) + .cloned() +} + +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -57,6 +118,16 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_web_research_contract_metadata(launch: &mut serde_json::Map) { + insert_web_research_contract_fields(launch); + if let Some(site_search_request) = launch + .get_mut("site_search_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_web_research_contract_fields(site_search_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -75,6 +146,12 @@ pub(crate) fn prepare_site_search_skill_launch_request_metadata( &mut metadata, &["site_search_skill_launch", "siteSearchSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["site_search_skill_launch", "siteSearchSkillLaunch"], + ) { + ensure_web_research_contract_metadata(launch); + } Some(metadata) } @@ -227,6 +304,31 @@ fn build_site_search_skill_launch_system_prompt( .and_then(serde_json::Value::as_u64); let entry_source = extract_object_string(site_search_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_site_search_command".to_string()); + let modality_contract_key = extract_object_string( + site_search_request, + &["modality_contract_key", "modalityContractKey"], + ) + .unwrap_or_else(|| WEB_RESEARCH_CONTRACT_KEY.to_string()); + let modality = extract_object_string(site_search_request, &["modality"]) + .unwrap_or_else(|| WEB_RESEARCH_MODALITY.to_string()); + let routing_slot = extract_object_string(site_search_request, &["routing_slot", "routingSlot"]) + .unwrap_or_else(|| WEB_RESEARCH_ROUTING_SLOT.to_string()); + let required_capabilities = { + let values = extract_object_string_array( + site_search_request, + &["required_capabilities", "requiredCapabilities"], + ); + if values.is_empty() { + web_research_required_capabilities() + } else { + values + } + }; + let runtime_contract = extract_object_value( + site_search_request, + &["runtime_contract", "runtimeContract"], + ) + .unwrap_or_else(web_research_runtime_contract); let args_payload = serde_json::json!({ "user_input": raw_text .clone() @@ -257,6 +359,21 @@ fn build_site_search_skill_launch_system_prompt( let mut lines = vec![ SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自站点搜索技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={modality_contract_key}, modality={modality}, routing_slot={routing_slot};站点搜索是 web_research 合同下的子入口,后续站点结果与产物必须原样保留 contract 字段。" + ), + format!( + "- 当前合同所需能力:{};不得退回 model_memory_only_answer、通用 WebSearch 或 local_file_search_before_research_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速归纳用户要查哪个站点、查什么,然后立刻把任务交给 Skill 工具。".to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs index 31884a209..ee189b2b6 100644 --- a/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_text_transform_contract_fields, text_transform_required_capabilities, + text_transform_runtime_contract, TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_MODALITY, + TEXT_TRANSFORM_ROUTING_SLOT, +}; const SUMMARY_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const SUMMARY_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -21,6 +26,33 @@ fn extract_object_string( .map(str::to_string) } +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -53,6 +85,18 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_text_transform_contract_metadata( + launch: &mut serde_json::Map, +) { + insert_text_transform_contract_fields(launch); + if let Some(summary_request) = launch + .get_mut("summary_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_text_transform_contract_fields(summary_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -71,6 +115,12 @@ pub(crate) fn prepare_summary_skill_launch_request_metadata( &mut metadata, &["summary_skill_launch", "summarySkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["summary_skill_launch", "summarySkillLaunch"], + ) { + ensure_text_transform_contract_metadata(launch); + } Some(metadata) } @@ -196,6 +246,8 @@ fn build_summary_skill_launch_system_prompt( let content_id = extract_object_string(summary_request, &["content_id", "contentId"]); let entry_source = extract_object_string(summary_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_summary_command".to_string()); + let required_capabilities = text_transform_required_capabilities(); + let runtime_contract = text_transform_runtime_contract(); let args_payload = serde_json::json!({ "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), "summary_request": serde_json::Value::Object(summary_request.clone()), @@ -227,6 +279,22 @@ fn build_summary_skill_launch_system_prompt( let mut lines = vec![ SUMMARY_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自总结技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={}, modality={}, routing_slot={};`@总结` / `@读文件` 只是 text_transform 的上层入口,首刀仍走 Skill(summary)。", + TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_MODALITY, TEXT_TRANSFORM_ROUTING_SLOT + ), + format!( + "- 当前合同所需能力:{};不得退回 frontend_direct_text_transform、tool_search_before_text_transform_skill 或 web_search_before_text_transform_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速判断要总结什么,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 4279716a3..9729f9854 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -452,7 +452,19 @@ mod tests { "profile_key": "general_browser_assist", "preferred_backend": "cdp_direct", "auto_launch": true, - "launch_url": "https://www.google.com" + "launch_url": "https://www.google.com", + "modality_contract_key": "browser_control", + "modality": "browser", + "required_capabilities": [ + "text_generation", + "browser_reasoning", + "browser_control_planning" + ], + "routing_slot": "browser_reasoning_model", + "runtime_contract": { + "contract_key": "browser_control" + }, + "entry_source": "at_browser_command" } } }); @@ -464,10 +476,77 @@ mod tests { preferred_backend: Some(BrowserBackendType::CdpDirect), auto_launch: true, launch_url: Some("https://www.google.com".to_string()), + modality_runtime_contract: Some(BrowserAssistModalityRuntimeContract { + contract_key: "browser_control".to_string(), + modality: "browser".to_string(), + required_capabilities: vec![ + "text_generation".to_string(), + "browser_reasoning".to_string(), + "browser_control_planning".to_string(), + ], + routing_slot: "browser_reasoning_model".to_string(), + runtime_contract: serde_json::json!({ + "contract_key": "browser_control" + }), + entry_source: Some("at_browser_command".to_string()), + }), }) ); } + #[test] + fn test_lime_browser_tool_attaches_modality_contract_metadata() { + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: Some("https://www.google.com".to_string()), + modality_runtime_contract: Some(BrowserAssistModalityRuntimeContract { + contract_key: "browser_control".to_string(), + modality: "browser".to_string(), + required_capabilities: vec![ + "text_generation".to_string(), + "browser_reasoning".to_string(), + "browser_control_planning".to_string(), + ], + routing_slot: "browser_reasoning_model".to_string(), + runtime_contract: serde_json::json!({ + "contract_key": "browser_control", + "routing_slot": "browser_reasoning_model" + }), + entry_source: Some("at_browser_command".to_string()), + }), + }; + + let result = LimeBrowserMcpTool::attach_modality_runtime_contract_metadata( + ToolResult::success("ok"), + Some(&session_hint), + ); + + assert_eq!( + result.metadata.get("modality_contract_key"), + Some(&serde_json::json!("browser_control")) + ); + assert_eq!( + result.metadata.get("routing_slot"), + Some(&serde_json::json!("browser_reasoning_model")) + ); + assert_eq!( + result + .metadata + .get("modality_runtime_contract") + .and_then(|value| value.pointer("/contractKey")), + Some(&serde_json::json!("browser_control")) + ); + assert_eq!( + result + .metadata + .get("runtime_contract") + .and_then(|value| value.pointer("/contract_key")), + Some(&serde_json::json!("browser_control")) + ); + } + #[test] fn test_resolve_browser_backend_keeps_explicit_backend() { let params = serde_json::json!({ @@ -478,6 +557,7 @@ mod tests { preferred_backend: Some(BrowserBackendType::AsterCompat), auto_launch: true, launch_url: None, + modality_runtime_contract: None, }; assert_eq!( @@ -494,6 +574,7 @@ mod tests { preferred_backend: Some(BrowserBackendType::CdpDirect), auto_launch: true, launch_url: None, + modality_runtime_contract: None, }; assert_eq!( @@ -510,6 +591,7 @@ mod tests { preferred_backend: Some(BrowserBackendType::CdpDirect), auto_launch: true, launch_url: None, + modality_runtime_contract: None, }; assert_eq!( @@ -541,6 +623,7 @@ mod tests { preferred_backend: Some(BrowserBackendType::LimeExtensionBridge), auto_launch: true, launch_url: None, + modality_runtime_contract: None, }; assert!(!LimeBrowserMcpTool::should_auto_launch_managed_browser( @@ -558,6 +641,7 @@ mod tests { preferred_backend: Some(BrowserBackendType::CdpDirect), auto_launch: true, launch_url: Some("https://www.google.com".to_string()), + modality_runtime_contract: None, }; assert!(LimeBrowserMcpTool::should_auto_launch_managed_browser( @@ -577,6 +661,7 @@ mod tests { preferred_backend: Some(BrowserBackendType::CdpDirect), auto_launch: true, launch_url: Some("https://github.com/search?q=ai+agent".to_string()), + modality_runtime_contract: None, }; assert!(!LimeBrowserMcpTool::should_auto_launch_managed_browser( @@ -1103,9 +1188,9 @@ mod tests { serde_json::json!("session-image-skill-1") ); - assert!(!permissions - .iter() - .any(|permission| permission.tool == LIME_CREATE_IMAGE_TASK_TOOL_NAME && !permission.allowed)); + assert!(!permissions.iter().any(|permission| permission.tool + == LIME_CREATE_IMAGE_TASK_TOOL_NAME + && !permission.allowed)); assert!(permissions .iter() .any(|permission| permission.tool == "Bash" && !permission.allowed)); @@ -3326,24 +3411,22 @@ mod tests { #[test] fn test_agent_runtime_list_sessions_request_deserializes_include_archived_alias() { - let request: AgentRuntimeListSessionsRequest = - serde_json::from_value(serde_json::json!({ - "includeArchived": true - })) - .expect("request should deserialize"); + let request: AgentRuntimeListSessionsRequest = serde_json::from_value(serde_json::json!({ + "includeArchived": true + })) + .expect("request should deserialize"); assert_eq!(request.include_archived, Some(true)); } #[test] fn test_agent_runtime_list_sessions_request_deserializes_archive_scope_and_limit() { - let request: AgentRuntimeListSessionsRequest = - serde_json::from_value(serde_json::json!({ - "archivedOnly": true, - "workspaceId": "workspace-1", - "limit": 24 - })) - .expect("request should deserialize"); + let request: AgentRuntimeListSessionsRequest = serde_json::from_value(serde_json::json!({ + "archivedOnly": true, + "workspaceId": "workspace-1", + "limit": 24 + })) + .expect("request should deserialize"); assert_eq!(request.archived_only, Some(true)); assert_eq!(request.workspace_id.as_deref(), Some("workspace-1")); @@ -4515,6 +4598,53 @@ mod tests { assert!(merged.contains("旧 OEM 运行时字段")); } + #[test] + fn test_merge_system_prompt_with_voice_service_scene_launch_includes_modality_contract() { + let metadata = serde_json::json!({ + "harness": { + "service_scene_launch": { + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "voice-skill-1", + "skill_title": "视频配音", + "skill_summary": "围绕视频文案与素材整理一版可继续加工的配音稿。", + "scene_key": "voice_runtime", + "command_prefix": "@配音", + "user_input": "给这个新品视频做一版发布配音稿", + "execution_kind": "agent_turn", + "execution_location": "client_default", + "entry_source": "at_voice_command", + "modality_contract_key": "voice_generation", + "modality": "audio", + "required_capabilities": ["text_generation", "voice_generation"], + "routing_slot": "voice_generation_model", + "runtime_contract": { + "contract_key": "voice_generation", + "routing_slot": "voice_generation_model", + "executor_binding": { + "executor_kind": "service_skill", + "binding_key": "voice_runtime" + } + } + } + } + } + }); + + let merged = merge_system_prompt_with_service_skill_launch( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains("modality_contract_key=voice_generation")); + assert!(merged.contains("modality=audio")); + assert!(merged.contains("routing_slot=voice_generation_model")); + assert!(merged.contains("当前合同所需能力:text_generation, voice_generation")); + assert!(merged.contains("\"binding_key\":\"voice_runtime\"")); + assert!(merged.contains("不要退回 legacy_tts_test_command")); + } + #[test] fn test_merge_system_prompt_with_image_skill_launch_appends_prompt() { let metadata = serde_json::json!({ @@ -4806,6 +4936,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=web_research, modality=mixed, routing_slot=report_generation_model" + )); + assert!(merged.contains("structured_document_generation")); + assert!(merged.contains("model_memory_only_answer")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"web_research")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"research\"")); assert!(merged.contains("Skill.args 的 JSON")); @@ -4847,6 +4984,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=web_research, modality=mixed, routing_slot=report_generation_model" + )); + assert!(merged.contains("structured_document_generation")); + assert!(merged.contains("model_memory_only_answer")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"web_research")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"research\"")); assert!(merged.contains("Skill.args 的 JSON")); @@ -4889,6 +5033,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=web_research, modality=mixed, routing_slot=report_generation_model" + )); + assert!(merged.contains("structured_document_generation")); + assert!(merged.contains("model_memory_only_answer")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"web_research")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"report_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); @@ -4898,6 +5049,7 @@ mod tests { assert!(merged.contains("不要先走 ToolSearch / Read / Glob / Grep")); assert!(merged.contains("应立即改为直调 Skill(report_generate)")); assert!(merged.contains("report_generate skill 内部必须先执行真实联网检索")); + assert!(merged.contains("`web_research` 下的报告型子入口")); assert!(merged.contains("核心结论、关键证据、风险/待确认项与建议动作")); assert!(merged.contains("当前任务已经显式进入研报技能主链")); } @@ -4928,6 +5080,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=web_research, modality=mixed, routing_slot=report_generation_model" + )); + assert!(merged.contains("structured_document_generation")); + assert!(merged.contains("web_research 合同下的子入口")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"web_research")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"site_search\"")); assert!(merged.contains("Skill.args 的 JSON")); @@ -4966,6 +5125,11 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=pdf_extract, modality=document, routing_slot=base_model" + )); + assert!(merged.contains("local_file_read")); + assert!(merged.contains("generic_chat_summary_only")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"pdf_read\"")); assert!(merged.contains("Skill.args 的 JSON")); @@ -5006,6 +5170,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=text_transform, modality=document, routing_slot=base_model" + )); + assert!(merged.contains("local_file_read")); + assert!(merged.contains("frontend_direct_text_transform")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"text_transform")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("Skill.args 的 JSON")); assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); @@ -5078,6 +5249,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=text_transform, modality=document, routing_slot=base_model" + )); + assert!(merged.contains("local_file_read")); + assert!(merged.contains("frontend_direct_text_transform")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"text_transform")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("Skill.args 的 JSON")); assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); @@ -5118,6 +5296,13 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains( + "modality_contract_key=text_transform, modality=document, routing_slot=base_model" + )); + assert!(merged.contains("local_file_read")); + assert!(merged.contains("frontend_direct_text_transform")); + assert!(merged.contains("runtime_contract(JSON)")); + assert!(merged.contains("contract_key\":\"text_transform")); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("Skill.args 的 JSON")); assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); @@ -5156,6 +5341,7 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + assert!(harness.get("broadcast_skill_launch").is_some()); } #[test] @@ -5184,6 +5370,7 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + assert!(harness.get("resource_search_skill_launch").is_some()); } #[test] @@ -5212,6 +5399,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("site_search_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("site_search_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + let site_search_request = launch + .get("site_search_request") + .and_then(serde_json::Value::as_object) + .expect("site_search_request"); + assert_eq!( + site_search_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + assert_eq!( + site_search_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("report_generation_model") + ); + assert_eq!( + site_search_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("research") + ); } #[test] @@ -5239,6 +5460,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("pdf_read_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("pdf_read_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("pdf_extract") + ); + let pdf_read_request = launch + .get("pdf_read_request") + .and_then(serde_json::Value::as_object) + .expect("pdf_read_request"); + assert_eq!( + pdf_read_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("pdf_extract") + ); + assert_eq!( + pdf_read_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("base_model") + ); + assert_eq!( + pdf_read_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("pdf_read") + ); } #[test] @@ -5266,6 +5521,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("research_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("research_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + let research_request = launch + .get("research_request") + .and_then(serde_json::Value::as_object) + .expect("research_request"); + assert_eq!( + research_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + assert_eq!( + research_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("report_generation_model") + ); + assert_eq!( + research_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("research") + ); } #[test] @@ -5293,6 +5582,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("deep_search_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("deep_search_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + let deep_search_request = launch + .get("deep_search_request") + .and_then(serde_json::Value::as_object) + .expect("deep_search_request"); + assert_eq!( + deep_search_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + assert_eq!( + deep_search_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("report_generation_model") + ); + assert_eq!( + deep_search_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("research") + ); } #[test] @@ -5320,6 +5643,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("report_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("report_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + let report_request = launch + .get("report_request") + .and_then(serde_json::Value::as_object) + .expect("report_request"); + assert_eq!( + report_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("web_research") + ); + assert_eq!( + report_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("report_generation_model") + ); + assert_eq!( + report_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("research") + ); } #[test] @@ -5347,6 +5704,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("summary_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("summary_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); + let summary_request = launch + .get("summary_request") + .and_then(serde_json::Value::as_object) + .expect("summary_request"); + assert_eq!( + summary_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); + assert_eq!( + summary_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("base_model") + ); + assert_eq!( + summary_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); } #[test] @@ -5374,6 +5765,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("translation_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("translation_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); + let translation_request = launch + .get("translation_request") + .and_then(serde_json::Value::as_object) + .expect("translation_request"); + assert_eq!( + translation_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); + assert_eq!( + translation_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("base_model") + ); + assert_eq!( + translation_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); } #[test] @@ -5401,6 +5826,40 @@ mod tests { harness.get("chat_mode").and_then(serde_json::Value::as_str), Some("workbench") ); + let launch = harness + .get("analysis_skill_launch") + .and_then(serde_json::Value::as_object) + .expect("analysis_skill_launch"); + assert_eq!( + launch + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); + let analysis_request = launch + .get("analysis_request") + .and_then(serde_json::Value::as_object) + .expect("analysis_request"); + assert_eq!( + analysis_request + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); + assert_eq!( + analysis_request + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("base_model") + ); + assert_eq!( + analysis_request + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("text_transform") + ); } #[test] @@ -5879,6 +6338,50 @@ mod tests { ); } + #[test] + fn test_prepare_service_scene_launch_request_metadata_adds_voice_generation_contract() { + let metadata = serde_json::json!({ + "harness": { + "service_scene_launch": { + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "voice-skill-1", + "scene_key": "voice_runtime", + "entry_source": "at_voice_command" + } + } + } + }); + + let prepared = prepare_service_scene_launch_request_metadata(Some(&metadata)) + .expect("prepared metadata"); + let service_scene_run = prepared + .pointer("/harness/service_scene_launch/service_scene_run") + .and_then(serde_json::Value::as_object) + .expect("service_scene_run"); + + assert_eq!( + service_scene_run + .get("modality_contract_key") + .and_then(serde_json::Value::as_str), + Some("voice_generation") + ); + assert_eq!( + service_scene_run + .get("routing_slot") + .and_then(serde_json::Value::as_str), + Some("voice_generation_model") + ); + assert_eq!( + service_scene_run + .get("runtime_contract") + .and_then(|value| value.get("executor_binding")) + .and_then(|value| value.get("binding_key")) + .and_then(serde_json::Value::as_str), + Some("voice_runtime") + ); + } + #[test] fn test_merge_system_prompt_with_service_skill_launch_skips_duplicate_marker() { let metadata = serde_json::json!({ diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs index 5c3707bee..8d9dbf62f 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs @@ -354,6 +354,38 @@ impl LimeBrowserMcpTool { true } + + pub(crate) fn attach_modality_runtime_contract_metadata( + mut tool_result: ToolResult, + session_hint: Option<&BrowserAssistRuntimeHint>, + ) -> ToolResult { + let Some(contract) = session_hint.and_then(|hint| hint.modality_runtime_contract.as_ref()) + else { + return tool_result; + }; + + tool_result = tool_result + .with_metadata( + "modality_contract_key", + serde_json::json!(contract.contract_key.clone()), + ) + .with_metadata("modality", serde_json::json!(contract.modality.clone())) + .with_metadata( + "required_capabilities", + serde_json::json!(contract.required_capabilities.clone()), + ) + .with_metadata( + "routing_slot", + serde_json::json!(contract.routing_slot.clone()), + ) + .with_metadata("runtime_contract", contract.runtime_contract.clone()) + .with_metadata("modality_runtime_contract", contract.metadata_value()); + if let Some(entry_source) = contract.entry_source.as_ref() { + tool_result = + tool_result.with_metadata("entry_source", serde_json::json!(entry_source)); + } + tool_result + } } #[async_trait] @@ -486,6 +518,8 @@ impl Tool for LimeBrowserMcpTool { if let Some(browser_session) = browser_session_metadata { tool_result = tool_result.with_metadata("browser_session", browser_session); } + tool_result = + Self::attach_modality_runtime_contract_metadata(tool_result, session_hint.as_ref()); Ok(tool_result) } else { let mut tool_result = ToolResult::error( @@ -505,6 +539,8 @@ impl Tool for LimeBrowserMcpTool { if let Some(browser_session) = browser_session_metadata { tool_result = tool_result.with_metadata("browser_session", browser_session); } + tool_result = + Self::attach_modality_runtime_contract_metadata(tool_result, session_hint.as_ref()); Ok(tool_result) } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index c7212cd6f..d46cc97cf 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -1,11 +1,15 @@ use super::*; +use crate::agent_tools::catalog::LIME_CREATE_AUDIO_TASK_TOOL_NAME; use crate::commands::media_task_cmd::{ - create_image_generation_task_artifact_inner, finalize_image_generation_task_creation, - CreateImageGenerationTaskArtifactRequest, ImageStoryboardSlotInput, + create_audio_generation_task_artifact_inner, create_image_generation_task_artifact_inner, + finalize_audio_generation_task_creation, finalize_image_generation_task_creation, + CreateAudioGenerationTaskArtifactRequest, CreateImageGenerationTaskArtifactRequest, + ImageStoryboardSlotInput, }; use crate::commands::modality_runtime_contracts::{ image_generation_required_capabilities, IMAGE_GENERATION_CONTRACT_KEY, - IMAGE_GENERATION_MODALITY, IMAGE_GENERATION_ROUTING_SLOT, + IMAGE_GENERATION_MODALITY, IMAGE_GENERATION_ROUTING_SLOT, VOICE_GENERATION_CONTRACT_KEY, + VOICE_GENERATION_MODALITY, VOICE_GENERATION_ROUTING_SLOT, }; use lime_media_runtime::{ write_task_artifact, MediaTaskType, TaskRelationships, TaskType, TaskWriteOptions, @@ -15,6 +19,7 @@ use serde::{de, Deserialize, Deserializer}; const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"]; const CONTENT_ID_ENV_KEYS: &[&str] = &["LIME_CONTENT_ID", "PROXYCAST_CONTENT_ID"]; const IMAGE_TASK_DEFAULT_ENTRY_SOURCE: &str = "at_image_command"; +const AUDIO_TASK_DEFAULT_ENTRY_SOURCE: &str = "at_voice_command"; fn submit_creation_task_record( app_handle: &AppHandle, @@ -596,6 +601,55 @@ struct ImageTaskInput { output_path: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AudioTaskInput { + #[serde(default, alias = "source_text", alias = "prompt", alias = "text")] + source_text: String, + #[serde(default)] + title: Option, + #[serde(default, alias = "raw_text")] + raw_text: Option, + #[serde(default)] + voice: Option, + #[serde(default, alias = "voice_style")] + voice_style: Option, + #[serde(default, alias = "target_language")] + target_language: Option, + #[serde(default, alias = "mime_type")] + mime_type: Option, + #[serde(default, alias = "audio_path")] + audio_path: Option, + #[serde(default, alias = "duration_ms")] + duration_ms: Option, + #[serde(default, alias = "provider_id")] + provider_id: Option, + #[serde(default)] + model: Option, + #[serde(default, alias = "session_id")] + session_id: Option, + #[serde(default, alias = "project_id")] + project_id: Option, + #[serde(default, alias = "content_id")] + content_id: Option, + #[serde(default, alias = "entry_source")] + entry_source: Option, + #[serde(default, alias = "modality_contract_key")] + modality_contract_key: Option, + #[serde(default)] + modality: Option, + #[serde(default, alias = "required_capabilities")] + required_capabilities: Vec, + #[serde(default, alias = "routing_slot")] + routing_slot: Option, + #[serde(default, alias = "runtime_contract")] + runtime_contract: Option, + #[serde(default, alias = "requested_target")] + requested_target: Option, + #[serde(default, alias = "output_path")] + output_path: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct TranscriptionTaskInput { @@ -1131,6 +1185,196 @@ impl Tool for LimeCreateImageTaskTool { } } +#[derive(Clone)] +struct LimeCreateAudioTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateAudioTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +fn audio_task_input_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "sourceText": { "type": "string", "description": "需要生成配音的正文。" }, + "source_text": { "type": "string", "description": "需要生成配音的正文(snake_case 兼容)。" }, + "prompt": { "type": "string", "description": "兼容字段,等价于 sourceText。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "rawText": { "type": "string", "description": "原始用户输入(可选)。" }, + "raw_text": { "type": "string", "description": "原始用户输入(snake_case 兼容,可选)。" }, + "voice": { "type": "string", "description": "音色或 voice preset(可选)。" }, + "voiceStyle": { "type": "string", "description": "配音风格(可选)。" }, + "voice_style": { "type": "string", "description": "配音风格(snake_case 兼容,可选)。" }, + "targetLanguage": { "type": "string", "description": "目标语言(可选)。" }, + "target_language": { "type": "string", "description": "目标语言(snake_case 兼容,可选)。" }, + "mimeType": { "type": "string", "description": "音频 MIME 类型(可选,默认 audio/mpeg)。" }, + "mime_type": { "type": "string", "description": "音频 MIME 类型(snake_case 兼容,可选)。" }, + "audioPath": { "type": "string", "description": "已生成音频路径(可选;未生成时留空)。" }, + "audio_path": { "type": "string", "description": "已生成音频路径(snake_case 兼容,可选)。" }, + "durationMs": { "type": "integer", "minimum": 0, "description": "音频时长毫秒(可选)。" }, + "duration_ms": { "type": "integer", "minimum": 0, "description": "音频时长毫秒(snake_case 兼容,可选)。" }, + "providerId": { "type": "string", "description": "Provider 标识(可选)。" }, + "provider_id": { "type": "string", "description": "Provider 标识(snake_case 兼容,可选)。" }, + "model": { "type": "string", "description": "模型名(可选)。" }, + "sessionId": { "type": "string", "description": "会话 ID(可选)。" }, + "session_id": { "type": "string", "description": "会话 ID(snake_case 兼容,可选)。" }, + "projectId": { "type": "string", "description": "项目 ID(可选)。" }, + "project_id": { "type": "string", "description": "项目 ID(snake_case 兼容,可选)。" }, + "contentId": { "type": "string", "description": "内容 ID(可选)。" }, + "content_id": { "type": "string", "description": "内容 ID(snake_case 兼容,可选)。" }, + "entrySource": { "type": "string", "description": "入口来源(可选,默认 at_voice_command)。" }, + "entry_source": { "type": "string", "description": "入口来源(snake_case 兼容,可选)。" }, + "modalityContractKey": { "type": "string", "description": "兼容字段,默认 voice_generation。" }, + "modality_contract_key": { "type": "string", "description": "兼容字段,默认 voice_generation。" }, + "modality": { "type": "string", "description": "兼容字段,默认 audio。" }, + "requiredCapabilities": { "type": "array", "items": { "type": "string" }, "description": "兼容字段,运行时会规范化为 voice_generation 所需能力。" }, + "required_capabilities": { "type": "array", "items": { "type": "string" }, "description": "兼容字段,运行时会规范化为 voice_generation 所需能力。" }, + "routingSlot": { "type": "string", "description": "兼容字段,默认 voice_generation_model。" }, + "routing_slot": { "type": "string", "description": "兼容字段,默认 voice_generation_model。" }, + "runtimeContract": { "type": "object", "description": "运行合同快照(可选)。", "additionalProperties": true }, + "runtime_contract": { "type": "object", "description": "运行合同快照(可选)。", "additionalProperties": true }, + "requestedTarget": { "type": "string", "description": "请求目标(可选,默认 voice)。" }, + "requested_target": { "type": "string", "description": "请求目标(snake_case 兼容,可选)。" }, + "outputPath": { "type": "string", "description": "可选 task JSON 输出路径。" }, + "output_path": { "type": "string", "description": "可选 task JSON 输出路径(snake_case 兼容)。" } + }, + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["audio", "voice", "task", "generation"], + "allowed_callers": ["assistant", "skill"] + } + }) +} + +fn resolve_context_session_id(context: &ToolContext, requested: Option) -> Option { + requested.or_else(|| { + let value = context.session_id.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + }) +} + +fn resolve_context_environment_id( + context: &ToolContext, + requested: Option, + env_keys: &[&str], +) -> Option { + requested.or_else(|| { + env_keys.iter().find_map(|key| { + context + .environment + .get(*key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + }) +} + +fn build_audio_generation_task_request( + context: &ToolContext, + input: AudioTaskInput, +) -> CreateAudioGenerationTaskArtifactRequest { + CreateAudioGenerationTaskArtifactRequest { + project_root_path: context.working_directory.to_string_lossy().to_string(), + source_text: input.source_text, + title: input.title, + raw_text: input.raw_text, + voice: input.voice, + voice_style: input.voice_style, + target_language: input.target_language, + mime_type: input.mime_type, + audio_path: input.audio_path, + duration_ms: input.duration_ms, + provider_id: input.provider_id, + model: input.model, + session_id: resolve_context_session_id(context, input.session_id), + project_id: resolve_context_environment_id(context, input.project_id, PROJECT_ID_ENV_KEYS), + content_id: resolve_context_environment_id(context, input.content_id, CONTENT_ID_ENV_KEYS), + entry_source: input + .entry_source + .or_else(|| Some(AUDIO_TASK_DEFAULT_ENTRY_SOURCE.to_string())), + modality_contract_key: input + .modality_contract_key + .or_else(|| Some(VOICE_GENERATION_CONTRACT_KEY.to_string())), + modality: input + .modality + .or_else(|| Some(VOICE_GENERATION_MODALITY.to_string())), + required_capabilities: if input.required_capabilities.is_empty() { + vec![ + "text_generation".to_string(), + "voice_generation".to_string(), + ] + } else { + input.required_capabilities + }, + routing_slot: input + .routing_slot + .or_else(|| Some(VOICE_GENERATION_ROUTING_SLOT.to_string())), + runtime_contract: input.runtime_contract, + requested_target: input.requested_target.or_else(|| Some("voice".to_string())), + output_path: input.output_path, + } +} + +fn submit_audio_generation_task_record( + app_handle: &AppHandle, + context: &ToolContext, + input: AudioTaskInput, +) -> Result { + let request = build_audio_generation_task_request(context, input); + let output = create_audio_generation_task_artifact_inner(request) + .map_err(|error| ToolError::execution_failed(format!("创建音频任务失败: {error}")))?; + + finalize_audio_generation_task_creation(Some(app_handle), &output); + + let serialized = serde_json::to_string_pretty(&output) + .unwrap_or_else(|_| serde_json::json!(&output).to_string()); + Ok(media_cli_bridge::attach_media_task_metadata( + ToolResult::success(serialized), + &output, + )) +} + +#[async_trait] +impl Tool for LimeCreateAudioTaskTool { + fn name(&self) -> &str { + LIME_CREATE_AUDIO_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建配音生成任务(audio_generate),只写入标准 audio_task/audio_output artifact。" + } + + fn input_schema(&self) -> serde_json::Value { + audio_task_input_schema() + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: AudioTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.source_text.trim().is_empty() { + return Err(ToolError::invalid_params( + "sourceText 不能为空字符串".to_string(), + )); + } + submit_audio_generation_task_record(&self.app_handle, context, input) + } +} + #[derive(Clone)] struct LimeCreateTranscriptionTaskTool { app_handle: AppHandle, @@ -1591,6 +1835,9 @@ pub(super) fn register_creation_task_tools_to_registry( app_handle.clone(), ))); } + if !registry.contains(LIME_CREATE_AUDIO_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateAudioTaskTool::new(app_handle.clone()))); + } if !registry.contains(LIME_CREATE_TRANSCRIPTION_TASK_TOOL_NAME) { registry.register(Box::new(LimeCreateTranscriptionTaskTool::new( app_handle.clone(), @@ -1837,6 +2084,90 @@ mod tests { ); } + #[test] + fn build_audio_generation_task_request_should_keep_voice_contract_artifact() { + let temp_dir = tempdir().expect("create temp dir"); + let context = ToolContext::new(temp_dir.path().to_path_buf()) + .with_session_id("session-audio-1") + .with_environment(std::collections::HashMap::from([ + ("LIME_PROJECT_ID".to_string(), "project-audio-1".to_string()), + ("LIME_CONTENT_ID".to_string(), "content-audio-1".to_string()), + ])); + let request = build_audio_generation_task_request( + &context, + AudioTaskInput { + source_text: "请为这段新品发布文案生成温暖旁白".to_string(), + title: Some("新品发布旁白".to_string()), + raw_text: Some("@配音 请为这段新品发布文案生成温暖旁白".to_string()), + voice: Some("warm_narrator".to_string()), + voice_style: Some("温暖".to_string()), + target_language: Some("zh-CN".to_string()), + mime_type: None, + audio_path: None, + duration_ms: None, + provider_id: Some("limecore".to_string()), + model: Some("voice-pro".to_string()), + session_id: None, + project_id: None, + content_id: None, + entry_source: None, + modality_contract_key: None, + modality: None, + required_capabilities: Vec::new(), + routing_slot: None, + runtime_contract: None, + requested_target: None, + output_path: None, + }, + ); + + assert_eq!( + request.project_root_path, + temp_dir.path().to_string_lossy().to_string() + ); + assert_eq!(request.session_id.as_deref(), Some("session-audio-1")); + assert_eq!(request.project_id.as_deref(), Some("project-audio-1")); + assert_eq!(request.content_id.as_deref(), Some("content-audio-1")); + assert_eq!(request.entry_source.as_deref(), Some("at_voice_command")); + assert_eq!( + request.modality_contract_key.as_deref(), + Some("voice_generation") + ); + assert_eq!(request.modality.as_deref(), Some("audio")); + assert_eq!( + request.required_capabilities, + vec![ + "text_generation".to_string(), + "voice_generation".to_string() + ] + ); + assert_eq!( + request.routing_slot.as_deref(), + Some("voice_generation_model") + ); + + let output = + create_audio_generation_task_artifact_inner(request).expect("create audio artifact"); + + assert!(output.path.contains("audio_generate")); + assert_eq!( + output + .record + .payload + .get("modality_contract_key") + .and_then(Value::as_str), + Some("voice_generation") + ); + assert_eq!( + output + .record + .payload + .pointer("/audio_output/kind") + .and_then(Value::as_str), + Some("audio_output") + ); + } + #[test] fn image_task_input_should_accept_snake_case_fields_from_skill_context() { let input: ImageTaskInput = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs index 51e649ff5..a2f618efd 100644 --- a/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs @@ -1,4 +1,9 @@ use super::*; +use crate::commands::modality_runtime_contracts::{ + insert_text_transform_contract_fields, text_transform_required_capabilities, + text_transform_runtime_contract, TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_MODALITY, + TEXT_TRANSFORM_ROUTING_SLOT, +}; const TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; const TRANSLATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ @@ -21,6 +26,33 @@ fn extract_object_string( .map(str::to_string) } +fn extract_harness_nested_object_mut<'a>( + value: &'a mut serde_json::Value, + keys: &[&str], +) -> Option<&'a mut serde_json::Map> { + let root = value.as_object_mut()?; + let harness = if root.contains_key("harness") { + root.get_mut("harness") + .and_then(serde_json::Value::as_object_mut)? + } else { + root + }; + + for key in keys.iter().copied() { + let exists = harness + .get(key) + .and_then(serde_json::Value::as_object) + .is_some(); + if exists { + return harness + .get_mut(key) + .and_then(serde_json::Value::as_object_mut); + } + } + + None +} + fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) { let Some(root) = value.as_object_mut() else { return; @@ -53,6 +85,18 @@ fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys ); } +fn ensure_text_transform_contract_metadata( + launch: &mut serde_json::Map, +) { + insert_text_transform_contract_fields(launch); + if let Some(translation_request) = launch + .get_mut("translation_request") + .and_then(serde_json::Value::as_object_mut) + { + insert_text_transform_contract_fields(translation_request); + } +} + fn truncate_prompt_text(value: String, max_chars: usize) -> String { let total_chars = value.chars().count(); if total_chars <= max_chars { @@ -71,6 +115,12 @@ pub(crate) fn prepare_translation_skill_launch_request_metadata( &mut metadata, &["translation_skill_launch", "translationSkillLaunch"], ); + if let Some(launch) = extract_harness_nested_object_mut( + &mut metadata, + &["translation_skill_launch", "translationSkillLaunch"], + ) { + ensure_text_transform_contract_metadata(launch); + } Some(metadata) } @@ -198,6 +248,8 @@ fn build_translation_skill_launch_system_prompt( let content_id = extract_object_string(translation_request, &["content_id", "contentId"]); let entry_source = extract_object_string(translation_request, &["entry_source", "entrySource"]) .unwrap_or_else(|| "at_translation_command".to_string()); + let required_capabilities = text_transform_required_capabilities(); + let runtime_contract = text_transform_runtime_contract(); let args_payload = serde_json::json!({ "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), "translation_request": serde_json::Value::Object(translation_request.clone()), @@ -219,6 +271,22 @@ fn build_translation_skill_launch_system_prompt( let mut lines = vec![ TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER.to_string(), "- 当前回合来自翻译技能启动,不要把它当成普通聊天回答。".to_string(), + format!( + "- 当前底层运行合同:modality_contract_key={}, modality={}, routing_slot={};`@翻译` 只是 text_transform 的上层入口,首刀仍走 Skill(translation)。", + TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_MODALITY, TEXT_TRANSFORM_ROUTING_SLOT + ), + format!( + "- 当前合同所需能力:{};不得退回 frontend_direct_text_transform、tool_search_before_text_transform_skill 或 web_search_before_text_transform_skill。", + required_capabilities.join(", ") + ), + format!( + "- 当前 runtime_contract(JSON):{}", + truncate_prompt_text( + serde_json::to_string(&runtime_contract) + .unwrap_or_else(|_| "{}".to_string()), + 1_200, + ) + ), "- 先快速判断要翻译什么、要翻译成什么语言,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。" .to_string(), format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index a3b99cad9..44a927d15 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -21,13 +21,17 @@ use crate::commands::modality_runtime_contracts::{ assess_image_generation_model_capability_from_registry, image_generation_runtime_contract, looks_like_text_model_for_image_generation, normalize_image_generation_contract_key, normalize_image_generation_modality, normalize_image_generation_required_capabilities, - normalize_image_generation_routing_slot, ImageGenerationModelCapabilityAssessment, - IMAGE_GENERATION_CONTRACT_KEY, IMAGE_GENERATION_ROUTING_SLOT, + normalize_image_generation_routing_slot, normalize_voice_generation_contract_key, + normalize_voice_generation_modality, normalize_voice_generation_required_capabilities, + normalize_voice_generation_routing_slot, voice_generation_runtime_contract, + ImageGenerationModelCapabilityAssessment, IMAGE_GENERATION_CONTRACT_KEY, + IMAGE_GENERATION_ROUTING_SLOT, VOICE_GENERATION_CONTRACT_KEY, }; use crate::commands::model_registry_cmd::ModelRegistryState; use crate::config::GlobalConfigManagerState; const IMAGE_TASK_RUNNER_WORKER_ID: &str = "lime-image-api-worker"; +const AUDIO_TASK_DEFAULT_MIME_TYPE: &str = "audio/mpeg"; static ACTIVE_IMAGE_TASK_EXECUTIONS: Lazy>> = Lazy::new(|| Mutex::new(HashSet::new())); @@ -56,7 +60,7 @@ pub struct CreateImageGenerationTaskArtifactRequest { pub title_generation_result: Option, #[serde(default)] pub mode: Option, - #[serde(default)] + #[serde(default, alias = "raw_text")] pub raw_text: Option, #[serde(default)] pub layout_hint: Option, @@ -112,6 +116,56 @@ pub struct CreateImageGenerationTaskArtifactRequest { pub storyboard_slots: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAudioGenerationTaskArtifactRequest { + pub project_root_path: String, + #[serde(alias = "source_text", alias = "prompt", alias = "text")] + pub source_text: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub raw_text: Option, + #[serde(default)] + pub voice: Option, + #[serde(default, alias = "voice_style")] + pub voice_style: Option, + #[serde(default, alias = "target_language")] + pub target_language: Option, + #[serde(default, alias = "mime_type")] + pub mime_type: Option, + #[serde(default, alias = "audio_path")] + pub audio_path: Option, + #[serde(default, alias = "duration_ms")] + pub duration_ms: Option, + #[serde(default, alias = "provider_id")] + pub provider_id: Option, + #[serde(default)] + pub model: Option, + #[serde(default, alias = "session_id")] + pub session_id: Option, + #[serde(default, alias = "project_id")] + pub project_id: Option, + #[serde(default, alias = "content_id")] + pub content_id: Option, + #[serde(default, alias = "entry_source")] + pub entry_source: Option, + #[serde(default, alias = "modality_contract_key")] + pub modality_contract_key: Option, + #[serde(default)] + pub modality: Option, + #[serde(default, alias = "required_capabilities")] + pub required_capabilities: Vec, + #[serde(default, alias = "routing_slot")] + pub routing_slot: Option, + #[serde(default, alias = "runtime_contract")] + pub runtime_contract: Option, + #[serde(default, alias = "requested_target")] + pub requested_target: Option, + #[serde(default, alias = "output_path")] + pub output_path: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaTaskLookupRequest { @@ -130,6 +184,12 @@ pub struct ListMediaTaskArtifactsRequest { #[serde(default)] pub task_type: Option, #[serde(default)] + #[serde(alias = "modality_contract_key")] + pub modality_contract_key: Option, + #[serde(default)] + #[serde(alias = "routing_outcome")] + pub routing_outcome: Option, + #[serde(default)] pub limit: Option, } @@ -138,9 +198,43 @@ pub struct MediaTaskListFilters { pub status: Option, pub task_family: Option, pub task_type: Option, + pub modality_contract_key: Option, + pub routing_outcome: Option, pub limit: Option, } +#[derive(Debug, Serialize)] +pub struct MediaTaskModalityRuntimeContractIndexEntry { + pub task_id: String, + pub task_type: String, + pub normalized_status: String, + pub contract_key: Option, + pub routing_slot: Option, + pub provider_id: Option, + pub model: Option, + pub routing_event: String, + pub routing_outcome: String, + pub failure_code: Option, + pub model_capability_assessment_source: Option, + pub model_supports_image_generation: Option, +} + +#[derive(Debug, Serialize)] +pub struct MediaTaskRoutingOutcomeCount { + pub outcome: String, + pub count: usize, +} + +#[derive(Debug, Serialize)] +pub struct MediaTaskModalityRuntimeContractIndex { + pub snapshot_count: usize, + pub contract_keys: Vec, + pub blocked_count: usize, + pub routing_outcomes: Vec, + pub model_registry_assessment_count: usize, + pub snapshots: Vec, +} + #[derive(Debug, Serialize)] pub struct ListMediaTaskArtifactsResponse { pub success: bool, @@ -148,6 +242,7 @@ pub struct ListMediaTaskArtifactsResponse { pub artifact_root: String, pub filters: MediaTaskListFilters, pub total: usize, + pub modality_runtime_contracts: MediaTaskModalityRuntimeContractIndex, pub tasks: Vec, } @@ -358,6 +453,35 @@ fn build_image_task_idempotency_key( Ok(format!("image-task-{}", hex::encode(&digest[..16]))) } +fn build_audio_task_idempotency_key( + request: &CreateAudioGenerationTaskArtifactRequest, + source_text: &str, + voice: Option<&str>, + voice_style: Option<&str>, + target_language: Option<&str>, + provider_id: Option<&str>, + model: Option<&str>, +) -> Result { + let fingerprint = json!({ + "session_id": normalize_optional_string(request.session_id.clone()), + "project_id": normalize_optional_string(request.project_id.clone()), + "content_id": normalize_optional_string(request.content_id.clone()), + "entry_source": normalize_optional_string(request.entry_source.clone()), + "source_text": source_text, + "voice": voice, + "voice_style": voice_style, + "target_language": target_language, + "provider_id": provider_id, + "model": model, + }); + let serialized = serde_json::to_vec(&fingerprint) + .map_err(|error| format!("序列化音频任务幂等指纹失败: {error}"))?; + let mut hasher = Sha256::new(); + hasher.update(&serialized); + let digest = hasher.finalize(); + Ok(format!("audio-task-{}", hex::encode(&digest[..16]))) +} + #[derive(Debug, Clone)] struct ImageGenerationRunnerConfig { endpoint: String, @@ -470,6 +594,163 @@ fn read_image_task_payload_string_array( }) } +fn read_image_task_payload_bool(payload: &serde_json::Value, keys: &[&str]) -> Option { + keys.iter() + .filter_map(|key| payload.get(*key)) + .find_map(serde_json::Value::as_bool) +} + +fn is_image_generation_contract_routing_failure_code(code: &str) -> bool { + matches!( + code.trim(), + "image_generation_contract_mismatch" + | "image_generation_capability_gap" + | "image_generation_routing_slot_mismatch" + | "image_generation_model_capability_gap" + ) +} + +fn media_task_contract_key(output: &MediaTaskOutput) -> Option { + read_image_task_payload_string(&output.record.payload, &["modality_contract_key"]) + .or_else(|| { + output + .record + .payload + .get("runtime_contract") + .and_then(|value| value.get("contract_key")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) + .map(ToString::to_string) +} + +fn media_task_routing_outcome(output: &MediaTaskOutput) -> (&'static str, &'static str) { + let is_contract_routing_failure = output + .last_error + .as_ref() + .map(|error| is_image_generation_contract_routing_failure_code(&error.code)) + .unwrap_or(false); + + if is_contract_routing_failure { + ("routing_not_possible", "blocked") + } else if media_task_contract_key(output).as_deref() == Some(VOICE_GENERATION_CONTRACT_KEY) { + if output.normalized_status == "failed" { + ("executor_invoked", "failed") + } else { + ("executor_invoked", "accepted") + } + } else if output.normalized_status == "failed" { + ("model_routing_decision", "failed") + } else { + ("model_routing_decision", "accepted") + } +} + +fn image_task_matches_modality_contract_filters( + output: &MediaTaskOutput, + modality_contract_key: Option<&str>, + routing_outcome: Option<&str>, +) -> bool { + let contract_matches = modality_contract_key + .map(|expected| { + media_task_contract_key(output) + .map(|actual| actual == expected) + .unwrap_or(false) + }) + .unwrap_or(true); + let (_, actual_routing_outcome) = media_task_routing_outcome(output); + let routing_matches = routing_outcome + .map(|expected| actual_routing_outcome == expected) + .unwrap_or(true); + contract_matches && routing_matches +} + +fn push_unique_string(values: &mut Vec, value: Option) { + let Some(value) = value else { + return; + }; + if values.iter().any(|existing| existing == &value) { + return; + } + values.push(value); +} + +fn increment_routing_outcome_count(counts: &mut Vec, outcome: &str) { + if let Some(item) = counts.iter_mut().find(|item| item.outcome == outcome) { + item.count += 1; + return; + } + counts.push(MediaTaskRoutingOutcomeCount { + outcome: outcome.to_string(), + count: 1, + }); +} + +fn build_modality_runtime_contract_index( + tasks: &[MediaTaskOutput], +) -> MediaTaskModalityRuntimeContractIndex { + let mut contract_keys = Vec::new(); + let mut blocked_count = 0; + let mut routing_outcomes = Vec::new(); + let mut model_registry_assessment_count = 0; + let mut snapshots = Vec::new(); + + for output in tasks { + let contract_key = media_task_contract_key(output); + if contract_key.is_none() { + continue; + } + + push_unique_string(&mut contract_keys, contract_key.clone()); + let (routing_event, routing_outcome) = media_task_routing_outcome(output); + if routing_outcome == "blocked" { + blocked_count += 1; + } + increment_routing_outcome_count(&mut routing_outcomes, routing_outcome); + + let payload = &output.record.payload; + let model_capability_assessment = payload.get("model_capability_assessment"); + let model_capability_assessment_source = model_capability_assessment + .and_then(|value| value.get("source")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + if model_capability_assessment_source.as_deref() == Some("model_registry") { + model_registry_assessment_count += 1; + } + + snapshots.push(MediaTaskModalityRuntimeContractIndexEntry { + task_id: output.task_id.clone(), + task_type: output.task_type.clone(), + normalized_status: output.normalized_status.clone(), + contract_key, + routing_slot: read_image_task_payload_string(payload, &["routing_slot"]) + .map(ToString::to_string), + provider_id: read_image_task_payload_string(payload, &["provider_id", "providerId"]) + .map(ToString::to_string), + model: read_image_task_payload_string(payload, &["model"]).map(ToString::to_string), + routing_event: routing_event.to_string(), + routing_outcome: routing_outcome.to_string(), + failure_code: output.last_error.as_ref().map(|error| error.code.clone()), + model_capability_assessment_source, + model_supports_image_generation: model_capability_assessment.and_then(|value| { + read_image_task_payload_bool(value, &["supports_image_generation"]) + }), + }); + } + + MediaTaskModalityRuntimeContractIndex { + snapshot_count: snapshots.len(), + contract_keys, + blocked_count, + routing_outcomes, + model_registry_assessment_count, + snapshots, + } +} + fn validate_image_generation_task_execution_contract( output: &MediaTaskOutput, model_capability: Option<&ImageGenerationModelCapabilityAssessment>, @@ -758,6 +1039,13 @@ pub(crate) fn finalize_image_generation_task_creation( } } +pub(crate) fn finalize_audio_generation_task_creation( + app: Option<&AppHandle>, + output: &MediaTaskOutput, +) { + emit_creation_task_event_if_needed(app, output); +} + pub(crate) fn create_image_generation_task_artifact_inner( request: CreateImageGenerationTaskArtifactRequest, ) -> Result { @@ -880,6 +1168,101 @@ pub(crate) fn create_image_generation_task_artifact_inner( .map_err(|error| format!("创建图片任务 artifact 失败: {error}")) } +pub(crate) fn create_audio_generation_task_artifact_inner( + request: CreateAudioGenerationTaskArtifactRequest, +) -> Result { + let project_root_path = + normalize_required_string(&request.project_root_path, "projectRootPath")?; + let source_text = normalize_required_string(&request.source_text, "sourceText")?; + let raw_text = normalize_optional_string(request.raw_text.clone()); + let voice = normalize_optional_string(request.voice.clone()); + let voice_style = normalize_optional_string(request.voice_style.clone()); + let target_language = normalize_optional_string(request.target_language.clone()); + let mime_type = normalize_optional_string(request.mime_type.clone()) + .unwrap_or_else(|| AUDIO_TASK_DEFAULT_MIME_TYPE.to_string()); + let audio_path = normalize_optional_string(request.audio_path.clone()); + let provider_id = normalize_optional_string(request.provider_id.clone()); + let model = normalize_optional_string(request.model.clone()); + let session_id = normalize_optional_string(request.session_id.clone()); + let project_id = normalize_optional_string(request.project_id.clone()); + let content_id = normalize_optional_string(request.content_id.clone()); + let entry_source = normalize_optional_string(request.entry_source.clone()) + .or_else(|| Some("at_voice_command".to_string())); + let runtime_contract_key = request + .runtime_contract + .as_ref() + .and_then(|value| value.get("contract_key")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + if let Some(runtime_contract_key) = runtime_contract_key { + normalize_voice_generation_contract_key(Some(runtime_contract_key))?; + } + let modality_contract_key = + normalize_voice_generation_contract_key(request.modality_contract_key.clone())?; + let modality = normalize_voice_generation_modality(request.modality.clone())?; + let required_capabilities = + normalize_voice_generation_required_capabilities(request.required_capabilities.clone())?; + let routing_slot = normalize_voice_generation_routing_slot(request.routing_slot.clone())?; + let requested_target = normalize_optional_string(request.requested_target.clone()) + .or_else(|| Some("voice".to_string())); + let output_path = normalize_optional_string(request.output_path.clone()); + let idempotency_key = build_audio_task_idempotency_key( + &request, + &source_text, + voice.as_deref(), + voice_style.as_deref(), + target_language.as_deref(), + provider_id.as_deref(), + model.as_deref(), + )?; + + write_task_artifact( + std::path::Path::new(project_root_path.as_str()), + MediaTaskType::AudioGenerate, + normalize_optional_string(request.title), + json!({ + "prompt": source_text.as_str(), + "source_text": source_text.as_str(), + "raw_text": raw_text, + "voice": voice.clone(), + "voice_style": voice_style, + "target_language": target_language, + "mime_type": mime_type.clone(), + "audio_path": audio_path.clone(), + "duration_ms": request.duration_ms, + "provider_id": provider_id, + "model": model, + "session_id": session_id, + "project_id": project_id, + "content_id": content_id, + "entry_source": entry_source, + "modality_contract_key": modality_contract_key, + "modality": modality, + "required_capabilities": required_capabilities, + "routing_slot": routing_slot, + "runtime_contract": voice_generation_runtime_contract(), + "requested_target": requested_target, + "audio_output": { + "kind": "audio_output", + "status": "pending", + "audio_path": audio_path, + "mime_type": mime_type, + "duration_ms": request.duration_ms, + "source_text": source_text.clone(), + "voice": voice, + } + }), + TaskWriteOptions { + status: Some("pending_submit".to_string()), + output_path: output_path.as_deref(), + artifact_dir: None, + idempotency_key: Some(idempotency_key.as_str()), + relationships: TaskRelationships::default(), + }, + ) + .map_err(|error| format!("创建音频任务 artifact 失败: {error}")) +} + pub(crate) fn get_media_task_artifact_inner( request: MediaTaskLookupRequest, ) -> Result { @@ -902,6 +1285,8 @@ pub(crate) fn list_media_task_artifacts_inner( let status_filter = normalize_optional_string(request.status); let task_family_filter = normalize_optional_string(request.task_family); let task_type_filter = normalize_optional_string(request.task_type); + let modality_contract_key_filter = normalize_optional_string(request.modality_contract_key); + let routing_outcome_filter = normalize_optional_string(request.routing_outcome); let parsed_task_type = task_type_filter .as_deref() .map(|value| { @@ -910,15 +1295,26 @@ pub(crate) fn list_media_task_artifacts_inner( .map_err(|_| format!("不支持的 taskType: {value}")) }) .transpose()?; - let tasks = list_task_outputs( + let mut tasks = list_task_outputs( std::path::Path::new(project_root_path.as_str()), None, status_filter.as_deref(), task_family_filter.as_deref(), parsed_task_type, - request.limit, + None, ) .map_err(|error| format!("列出媒体任务 artifact 失败: {error}"))?; + tasks.retain(|output| { + image_task_matches_modality_contract_filters( + output, + modality_contract_key_filter.as_deref(), + routing_outcome_filter.as_deref(), + ) + }); + if let Some(limit) = request.limit { + tasks.truncate(limit); + } + let modality_runtime_contracts = build_modality_runtime_contract_index(&tasks); Ok(ListMediaTaskArtifactsResponse { success: true, @@ -931,9 +1327,12 @@ pub(crate) fn list_media_task_artifacts_inner( status: status_filter, task_family: task_family_filter, task_type: parsed_task_type.map(|value| value.as_str().to_string()), + modality_contract_key: modality_contract_key_filter, + routing_outcome: routing_outcome_filter, limit: request.limit, }, total: tasks.len(), + modality_runtime_contracts, tasks, }) } @@ -964,6 +1363,16 @@ pub fn create_image_generation_task_artifact( Ok(output) } +#[tauri::command] +pub fn create_audio_generation_task_artifact( + app: AppHandle, + request: CreateAudioGenerationTaskArtifactRequest, +) -> Result { + let output = create_audio_generation_task_artifact_inner(request)?; + finalize_audio_generation_task_creation(Some(&app), &output); + Ok(output) +} + #[tauri::command] pub fn get_media_task_artifact(request: MediaTaskLookupRequest) -> Result { get_media_task_artifact_inner(request) @@ -1256,6 +1665,156 @@ mod tests { ); } + #[test] + fn create_audio_generation_task_artifact_inner_should_write_voice_contract_payload() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let request = CreateAudioGenerationTaskArtifactRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + source_text: "这是一段需要生成温暖旁白的发布文案。".to_string(), + title: Some("发布配音".to_string()), + raw_text: Some("@配音 风格: 温暖 这是一段发布文案".to_string()), + voice: Some("warm_narrator".to_string()), + voice_style: Some("温暖".to_string()), + target_language: Some("zh-CN".to_string()), + mime_type: None, + audio_path: None, + duration_ms: None, + provider_id: Some("limecore".to_string()), + model: Some("voice-pro".to_string()), + session_id: Some("session-voice-1".to_string()), + project_id: Some("project-voice-1".to_string()), + content_id: Some("content-voice-1".to_string()), + entry_source: None, + modality_contract_key: None, + modality: None, + required_capabilities: Vec::new(), + routing_slot: None, + runtime_contract: None, + requested_target: None, + output_path: None, + }; + + let first = create_audio_generation_task_artifact_inner(request) + .expect("create audio generation task"); + let second = + create_audio_generation_task_artifact_inner(CreateAudioGenerationTaskArtifactRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + source_text: "这是一段需要生成温暖旁白的发布文案。".to_string(), + title: Some("发布配音".to_string()), + raw_text: Some("@配音 风格: 温暖 这是一段发布文案".to_string()), + voice: Some("warm_narrator".to_string()), + voice_style: Some("温暖".to_string()), + target_language: Some("zh-CN".to_string()), + mime_type: None, + audio_path: None, + duration_ms: None, + provider_id: Some("limecore".to_string()), + model: Some("voice-pro".to_string()), + session_id: Some("session-voice-1".to_string()), + project_id: Some("project-voice-1".to_string()), + content_id: Some("content-voice-1".to_string()), + entry_source: None, + modality_contract_key: None, + modality: None, + required_capabilities: Vec::new(), + routing_slot: None, + runtime_contract: None, + requested_target: None, + output_path: None, + }) + .expect("reuse audio generation task"); + + assert_eq!(first.task_id, second.task_id); + assert!(second.reused_existing); + assert!(first.path.starts_with(".lime/tasks/audio_generate/")); + assert_eq!(first.task_type, "audio_generate"); + assert_eq!(first.task_family, "audio"); + assert_eq!( + first + .record + .payload + .get("entry_source") + .and_then(Value::as_str), + Some("at_voice_command") + ); + assert_eq!( + first + .record + .payload + .get("modality_contract_key") + .and_then(Value::as_str), + Some(VOICE_GENERATION_CONTRACT_KEY) + ); + assert_eq!( + first.record.payload.get("modality").and_then(Value::as_str), + Some("audio") + ); + assert_eq!( + first.record.payload.get("required_capabilities"), + Some(&json!(["text_generation", "voice_generation"])) + ); + assert_eq!( + first + .record + .payload + .get("routing_slot") + .and_then(Value::as_str), + Some(crate::commands::modality_runtime_contracts::VOICE_GENERATION_ROUTING_SLOT) + ); + assert_eq!( + first + .record + .payload + .pointer("/runtime_contract/executor_binding/binding_key") + .and_then(Value::as_str), + Some("voice_runtime") + ); + assert_eq!( + first + .record + .payload + .pointer("/audio_output/kind") + .and_then(Value::as_str), + Some("audio_output") + ); + assert_eq!( + first + .record + .payload + .pointer("/audio_output/status") + .and_then(Value::as_str), + Some("pending") + ); + assert_eq!( + first + .record + .payload + .pointer("/audio_output/mime_type") + .and_then(Value::as_str), + Some(AUDIO_TASK_DEFAULT_MIME_TYPE) + ); + + let listed = list_media_task_artifacts_inner(ListMediaTaskArtifactsRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + status: Some("pending".to_string()), + task_family: Some("audio".to_string()), + task_type: Some("audio_generate".to_string()), + modality_contract_key: Some(VOICE_GENERATION_CONTRACT_KEY.to_string()), + routing_outcome: Some("accepted".to_string()), + limit: Some(10), + }) + .expect("list audio generation tasks"); + + assert_eq!(listed.total, 1); + assert_eq!(listed.modality_runtime_contracts.snapshot_count, 1); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .routing_event + .as_str(), + "executor_invoked" + ); + } + #[test] fn validate_image_generation_task_execution_contract_should_reject_text_model_candidate() { let temp_dir = tempfile::tempdir().expect("create temp dir"); @@ -1472,11 +2031,24 @@ mod tests { status: Some("pending".to_string()), task_family: Some("image".to_string()), task_type: Some("image_generate".to_string()), + modality_contract_key: None, + routing_outcome: None, limit: Some(10), }) .expect("list tasks"); assert_eq!(listed.total, 1); assert_eq!(listed.tasks[0].task_id, created.task_id); + assert_eq!(listed.modality_runtime_contracts.snapshot_count, 1); + assert_eq!( + listed.modality_runtime_contracts.contract_keys, + vec![IMAGE_GENERATION_CONTRACT_KEY.to_string()] + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .routing_outcome + .as_str(), + "accepted" + ); let cancelled = cancel_media_task_artifact_inner(MediaTaskLookupRequest { project_root_path: temp_dir.path().to_string_lossy().to_string(), @@ -1487,6 +2059,85 @@ mod tests { assert!(cancelled.record.cancelled_at.is_some()); } + #[test] + fn list_media_task_artifacts_inner_should_index_modality_contract_routing_blocks() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let created = + create_image_generation_task_artifact_inner(minimal_image_generation_request( + temp_dir.path().to_string_lossy().to_string(), + Some("gpt-5.2"), + )) + .expect("create task"); + let assessment = ImageGenerationModelCapabilityAssessment { + model_id: "gpt-5.2".to_string(), + provider_id: Some("openai".to_string()), + source: "model_registry", + supports_image_generation: false, + reason: "registry_missing_image_generation_capability", + }; + patch_image_task_model_capability_assessment( + temp_dir.path(), + &created.task_id, + &assessment, + ) + .expect("patch model capability assessment"); + patch_image_task( + temp_dir.path(), + &created.task_id, + TaskArtifactPatch { + status: Some("failed".to_string()), + last_error: Some(Some(build_task_error( + "image_generation_model_capability_gap", + "model registry 显示当前模型不具备图片生成能力。", + false, + "routing", + ))), + ..TaskArtifactPatch::default() + }, + ) + .expect("mark task failed"); + + let listed = list_media_task_artifacts_inner(ListMediaTaskArtifactsRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + status: Some("failed".to_string()), + task_family: Some("image".to_string()), + task_type: Some("image_generate".to_string()), + modality_contract_key: Some(IMAGE_GENERATION_CONTRACT_KEY.to_string()), + routing_outcome: Some("blocked".to_string()), + limit: Some(10), + }) + .expect("list contract routing blocks"); + + assert_eq!(listed.total, 1); + assert_eq!( + listed.filters.modality_contract_key.as_deref(), + Some(IMAGE_GENERATION_CONTRACT_KEY) + ); + assert_eq!(listed.filters.routing_outcome.as_deref(), Some("blocked")); + assert_eq!(listed.modality_runtime_contracts.snapshot_count, 1); + assert_eq!(listed.modality_runtime_contracts.blocked_count, 1); + assert_eq!( + listed + .modality_runtime_contracts + .model_registry_assessment_count, + 1 + ); + assert_eq!( + listed.modality_runtime_contracts.routing_outcomes[0].outcome, + "blocked" + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0] + .model_capability_assessment_source + .as_deref(), + Some("model_registry") + ); + assert_eq!( + listed.modality_runtime_contracts.snapshots[0].model_supports_image_generation, + Some(false) + ); + } + #[test] fn create_image_generation_task_artifact_inner_should_create_new_task_after_cancelled_one() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/src-tauri/src/commands/modality_runtime_contracts.rs b/src-tauri/src/commands/modality_runtime_contracts.rs index e7e63a785..dd0ca13f4 100644 --- a/src-tauri/src/commands/modality_runtime_contracts.rs +++ b/src-tauri/src/commands/modality_runtime_contracts.rs @@ -7,6 +7,43 @@ pub(crate) const IMAGE_GENERATION_ROUTING_SLOT: &str = "image_generation_model"; pub(crate) const IMAGE_GENERATION_EXECUTOR_BINDING_KEY: &str = "image_generate"; pub(crate) const IMAGE_GENERATION_REQUIRED_CAPABILITIES: &[&str] = &["text_generation", "image_generation", "vision_input"]; +pub(crate) const BROWSER_CONTROL_CONTRACT_KEY: &str = "browser_control"; +pub(crate) const BROWSER_CONTROL_MODALITY: &str = "browser"; +pub(crate) const BROWSER_CONTROL_ROUTING_SLOT: &str = "browser_reasoning_model"; +pub(crate) const BROWSER_CONTROL_EXECUTOR_BINDING_KEY: &str = "browser_assist"; +pub(crate) const BROWSER_CONTROL_REQUIRED_CAPABILITIES: &[&str] = &[ + "text_generation", + "browser_reasoning", + "browser_control_planning", +]; +pub(crate) const PDF_EXTRACT_CONTRACT_KEY: &str = "pdf_extract"; +pub(crate) const PDF_EXTRACT_MODALITY: &str = "document"; +pub(crate) const PDF_EXTRACT_ROUTING_SLOT: &str = "base_model"; +pub(crate) const PDF_EXTRACT_EXECUTOR_BINDING_KEY: &str = "pdf_read"; +pub(crate) const PDF_EXTRACT_REQUIRED_CAPABILITIES: &[&str] = + &["text_generation", "local_file_read", "long_context"]; +pub(crate) const VOICE_GENERATION_CONTRACT_KEY: &str = "voice_generation"; +pub(crate) const VOICE_GENERATION_MODALITY: &str = "audio"; +pub(crate) const VOICE_GENERATION_ROUTING_SLOT: &str = "voice_generation_model"; +pub(crate) const VOICE_GENERATION_EXECUTOR_BINDING_KEY: &str = "voice_runtime"; +pub(crate) const VOICE_GENERATION_REQUIRED_CAPABILITIES: &[&str] = + &["text_generation", "voice_generation"]; +pub(crate) const WEB_RESEARCH_CONTRACT_KEY: &str = "web_research"; +pub(crate) const WEB_RESEARCH_MODALITY: &str = "mixed"; +pub(crate) const WEB_RESEARCH_ROUTING_SLOT: &str = "report_generation_model"; +pub(crate) const WEB_RESEARCH_EXECUTOR_BINDING_KEY: &str = "research"; +pub(crate) const WEB_RESEARCH_REQUIRED_CAPABILITIES: &[&str] = &[ + "text_generation", + "web_search", + "structured_document_generation", + "long_context", +]; +pub(crate) const TEXT_TRANSFORM_CONTRACT_KEY: &str = "text_transform"; +pub(crate) const TEXT_TRANSFORM_MODALITY: &str = "document"; +pub(crate) const TEXT_TRANSFORM_ROUTING_SLOT: &str = "base_model"; +pub(crate) const TEXT_TRANSFORM_EXECUTOR_BINDING_KEY: &str = "text_transform"; +pub(crate) const TEXT_TRANSFORM_REQUIRED_CAPABILITIES: &[&str] = + &["text_generation", "local_file_read", "long_context"]; const IMAGE_GENERATION_MODEL_KEYWORDS: &[&str] = &[ "gpt-image", "gpt-images", @@ -86,6 +123,41 @@ pub(crate) fn image_generation_required_capabilities() -> Vec { .collect() } +pub(crate) fn browser_control_required_capabilities() -> Vec { + BROWSER_CONTROL_REQUIRED_CAPABILITIES + .iter() + .map(|value| (*value).to_string()) + .collect() +} + +pub(crate) fn pdf_extract_required_capabilities() -> Vec { + PDF_EXTRACT_REQUIRED_CAPABILITIES + .iter() + .map(|value| (*value).to_string()) + .collect() +} + +pub(crate) fn voice_generation_required_capabilities() -> Vec { + VOICE_GENERATION_REQUIRED_CAPABILITIES + .iter() + .map(|value| (*value).to_string()) + .collect() +} + +pub(crate) fn web_research_required_capabilities() -> Vec { + WEB_RESEARCH_REQUIRED_CAPABILITIES + .iter() + .map(|value| (*value).to_string()) + .collect() +} + +pub(crate) fn text_transform_required_capabilities() -> Vec { + TEXT_TRANSFORM_REQUIRED_CAPABILITIES + .iter() + .map(|value| (*value).to_string()) + .collect() +} + pub(crate) fn normalize_image_generation_contract_key( value: Option, ) -> Result { @@ -124,6 +196,66 @@ pub(crate) fn normalize_image_generation_required_capabilities( Ok(expected) } +fn normalize_expected_voice_generation_contract_string( + value: Option, + expected: &str, + field_name: &str, +) -> Result { + match normalize_optional_contract_string(value) { + Some(value) if value == expected => Ok(expected.to_string()), + Some(value) => Err(format!( + "配音任务 {field_name} 必须是 {expected},收到 {value}" + )), + None => Ok(expected.to_string()), + } +} + +pub(crate) fn normalize_voice_generation_contract_key( + value: Option, +) -> Result { + normalize_expected_voice_generation_contract_string( + value, + VOICE_GENERATION_CONTRACT_KEY, + "modality_contract_key", + ) +} + +pub(crate) fn normalize_voice_generation_modality(value: Option) -> Result { + normalize_expected_voice_generation_contract_string( + value, + VOICE_GENERATION_MODALITY, + "modality", + ) +} + +pub(crate) fn normalize_voice_generation_routing_slot( + value: Option, +) -> Result { + normalize_expected_voice_generation_contract_string( + value, + VOICE_GENERATION_ROUTING_SLOT, + "routing_slot", + ) +} + +pub(crate) fn normalize_voice_generation_required_capabilities( + values: Vec, +) -> Result, String> { + let expected = voice_generation_required_capabilities(); + for value in values { + let normalized = value.trim(); + if normalized.is_empty() { + continue; + } + if !expected.iter().any(|item| item == normalized) { + return Err(format!( + "配音任务 required_capabilities 包含不属于 voice_generation contract 的能力: {normalized}" + )); + } + } + Ok(expected) +} + pub(crate) fn image_generation_runtime_contract() -> Value { json!({ "contract_key": IMAGE_GENERATION_CONTRACT_KEY, @@ -141,6 +273,91 @@ pub(crate) fn image_generation_runtime_contract() -> Value { }) } +pub(crate) fn browser_control_runtime_contract() -> Value { + json!({ + "contract_key": BROWSER_CONTROL_CONTRACT_KEY, + "modality": BROWSER_CONTROL_MODALITY, + "required_capabilities": BROWSER_CONTROL_REQUIRED_CAPABILITIES, + "routing_slot": BROWSER_CONTROL_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "browser", + "binding_key": BROWSER_CONTROL_EXECUTOR_BINDING_KEY + }, + "truth_source": ["browser_action_trace", "runtime_timeline_event"], + "artifact_kinds": ["browser_session", "browser_snapshot"], + "viewer_surface": ["browser_replay_viewer"], + "owner_surface": "browser_runtime" + }) +} + +pub(crate) fn pdf_extract_runtime_contract() -> Value { + json!({ + "contract_key": PDF_EXTRACT_CONTRACT_KEY, + "modality": PDF_EXTRACT_MODALITY, + "required_capabilities": PDF_EXTRACT_REQUIRED_CAPABILITIES, + "routing_slot": PDF_EXTRACT_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": PDF_EXTRACT_EXECUTOR_BINDING_KEY + }, + "truth_source": ["pdf_extract_artifact", "runtime_timeline_event"], + "artifact_kinds": ["pdf_extract", "report_document"], + "viewer_surface": ["document_viewer"], + "owner_surface": "agent_runtime" + }) +} + +pub(crate) fn voice_generation_runtime_contract() -> Value { + json!({ + "contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": VOICE_GENERATION_MODALITY, + "required_capabilities": VOICE_GENERATION_REQUIRED_CAPABILITIES, + "routing_slot": VOICE_GENERATION_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "service_skill", + "binding_key": VOICE_GENERATION_EXECUTOR_BINDING_KEY + }, + "truth_source": ["audio_task_artifact", "runtime_timeline_event"], + "artifact_kinds": ["audio_task", "audio_output"], + "viewer_surface": ["audio_player"], + "owner_surface": "service_skill_runtime" + }) +} + +pub(crate) fn web_research_runtime_contract() -> Value { + json!({ + "contract_key": WEB_RESEARCH_CONTRACT_KEY, + "modality": WEB_RESEARCH_MODALITY, + "required_capabilities": WEB_RESEARCH_REQUIRED_CAPABILITIES, + "routing_slot": WEB_RESEARCH_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": WEB_RESEARCH_EXECUTOR_BINDING_KEY + }, + "truth_source": ["research_timeline_event", "report_document_artifact"], + "artifact_kinds": ["report_document", "webpage_artifact"], + "viewer_surface": ["report_viewer", "webpage_viewer"], + "owner_surface": "agent_runtime" + }) +} + +pub(crate) fn text_transform_runtime_contract() -> Value { + json!({ + "contract_key": TEXT_TRANSFORM_CONTRACT_KEY, + "modality": TEXT_TRANSFORM_MODALITY, + "required_capabilities": TEXT_TRANSFORM_REQUIRED_CAPABILITIES, + "routing_slot": TEXT_TRANSFORM_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": TEXT_TRANSFORM_EXECUTOR_BINDING_KEY + }, + "truth_source": ["runtime_timeline_event", "report_document_artifact"], + "artifact_kinds": ["report_document", "generic_file"], + "viewer_surface": ["document_viewer", "generic_file_viewer"], + "owner_surface": "agent_runtime" + }) +} + pub(crate) fn looks_like_image_generation_model(model: &str) -> bool { let normalized = model.trim().to_ascii_lowercase(); !normalized.is_empty() @@ -243,6 +460,98 @@ pub(crate) fn insert_image_generation_contract_fields(record: &mut Map) { + record.insert( + "modality_contract_key".to_string(), + Value::String(PDF_EXTRACT_CONTRACT_KEY.to_string()), + ); + record.insert( + "modality".to_string(), + Value::String(PDF_EXTRACT_MODALITY.to_string()), + ); + record.insert( + "required_capabilities".to_string(), + json!(PDF_EXTRACT_REQUIRED_CAPABILITIES), + ); + record.insert( + "routing_slot".to_string(), + Value::String(PDF_EXTRACT_ROUTING_SLOT.to_string()), + ); + record.insert( + "runtime_contract".to_string(), + pdf_extract_runtime_contract(), + ); +} + +pub(crate) fn insert_voice_generation_contract_fields(record: &mut Map) { + record.insert( + "modality_contract_key".to_string(), + Value::String(VOICE_GENERATION_CONTRACT_KEY.to_string()), + ); + record.insert( + "modality".to_string(), + Value::String(VOICE_GENERATION_MODALITY.to_string()), + ); + record.insert( + "required_capabilities".to_string(), + json!(VOICE_GENERATION_REQUIRED_CAPABILITIES), + ); + record.insert( + "routing_slot".to_string(), + Value::String(VOICE_GENERATION_ROUTING_SLOT.to_string()), + ); + record.insert( + "runtime_contract".to_string(), + voice_generation_runtime_contract(), + ); +} + +pub(crate) fn insert_web_research_contract_fields(record: &mut Map) { + record.insert( + "modality_contract_key".to_string(), + Value::String(WEB_RESEARCH_CONTRACT_KEY.to_string()), + ); + record.insert( + "modality".to_string(), + Value::String(WEB_RESEARCH_MODALITY.to_string()), + ); + record.insert( + "required_capabilities".to_string(), + json!(WEB_RESEARCH_REQUIRED_CAPABILITIES), + ); + record.insert( + "routing_slot".to_string(), + Value::String(WEB_RESEARCH_ROUTING_SLOT.to_string()), + ); + record.insert( + "runtime_contract".to_string(), + web_research_runtime_contract(), + ); +} + +pub(crate) fn insert_text_transform_contract_fields(record: &mut Map) { + record.insert( + "modality_contract_key".to_string(), + Value::String(TEXT_TRANSFORM_CONTRACT_KEY.to_string()), + ); + record.insert( + "modality".to_string(), + Value::String(TEXT_TRANSFORM_MODALITY.to_string()), + ); + record.insert( + "required_capabilities".to_string(), + json!(TEXT_TRANSFORM_REQUIRED_CAPABILITIES), + ); + record.insert( + "routing_slot".to_string(), + Value::String(TEXT_TRANSFORM_ROUTING_SLOT.to_string()), + ); + record.insert( + "runtime_contract".to_string(), + text_transform_runtime_contract(), + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88e4fa8e4..11a7e6902 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ //! - ✅ lime-providers crate(providers, converter, streaming, translator, stream, session 部分) #![allow(clippy::all)] +#![recursion_limit = "256"] //! - 主 crate 保留 Tauri 相关业务逻辑 // 抑制 objc crate 宏内部的 unexpected_cfgs 警告 diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index b1c78f67f..d68084174 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -6,7 +6,10 @@ use crate::agent::SessionDetail; use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; use crate::commands::modality_runtime_contracts::{ - IMAGE_GENERATION_CONTRACT_KEY, IMAGE_GENERATION_ROUTING_SLOT, + BROWSER_CONTROL_CONTRACT_KEY, BROWSER_CONTROL_ROUTING_SLOT, IMAGE_GENERATION_CONTRACT_KEY, + IMAGE_GENERATION_ROUTING_SLOT, PDF_EXTRACT_CONTRACT_KEY, PDF_EXTRACT_ROUTING_SLOT, + TEXT_TRANSFORM_CONTRACT_KEY, TEXT_TRANSFORM_ROUTING_SLOT, VOICE_GENERATION_CONTRACT_KEY, + VOICE_GENERATION_ROUTING_SLOT, WEB_RESEARCH_CONTRACT_KEY, WEB_RESEARCH_ROUTING_SLOT, }; use crate::database::DbConnection; use crate::services::artifact_document_validator::ARTIFACT_DOCUMENT_SCHEMA_VERSION; @@ -200,6 +203,7 @@ pub fn export_runtime_evidence_pack( let auxiliary_runtime = collect_auxiliary_runtime_snapshots(Some(workspace_root.as_path()), &recent_artifacts); let modality_runtime_contracts = collect_modality_runtime_contract_snapshots( + detail, Some(workspace_root.as_path()), &recent_artifacts, ); @@ -357,7 +361,7 @@ pub(crate) fn build_runtime_evidence_sceneapp_snapshot( .unwrap_or_default(); let auxiliary_runtime = collect_auxiliary_runtime_snapshots(workspace_root, &recent_artifacts); let modality_runtime_contracts = - collect_modality_runtime_contract_snapshots(workspace_root, &recent_artifacts); + 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, @@ -746,10 +750,94 @@ fn build_modality_runtime_contracts_json( json!({ "applicableArtifactCount": summary.applicable_count, "snapshotCount": summary.snapshots.len(), + "snapshotIndex": build_modality_runtime_contract_snapshot_index(&summary.snapshots), "snapshots": summary.snapshots.clone() }) } +fn build_modality_runtime_contract_snapshot_index(snapshots: &[Value]) -> Value { + let mut contract_keys = BTreeSet::new(); + let mut sources: BTreeMap = BTreeMap::new(); + let mut routing_outcomes: BTreeMap = BTreeMap::new(); + let mut expected_routing_slots = BTreeSet::new(); + let mut trace_items = Vec::new(); + + for snapshot in snapshots { + let contract_key = snapshot_string(snapshot, "contractKey"); + let source = snapshot_string(snapshot, "source"); + let routing_outcome = snapshot_string(snapshot, "routingOutcome"); + let expected_routing_slot = snapshot_string(snapshot, "expectedRoutingSlot"); + + if let Some(contract_key) = contract_key.as_deref() { + contract_keys.insert(contract_key.to_string()); + } + if let Some(source) = source.as_deref() { + *sources.entry(source.to_string()).or_insert(0) += 1; + } + if let Some(routing_outcome) = routing_outcome.as_deref() { + *routing_outcomes + .entry(routing_outcome.to_string()) + .or_insert(0) += 1; + } + if let Some(expected_routing_slot) = expected_routing_slot.as_deref() { + expected_routing_slots.insert(expected_routing_slot.to_string()); + } + + if source + .as_deref() + .map(is_runtime_contract_tool_trace_source) + .unwrap_or(false) + { + trace_items.push(json!({ + "artifactPath": snapshot.get("artifactPath").cloned().unwrap_or(Value::Null), + "source": source, + "contractKey": contract_key, + "routingEvent": snapshot.get("routingEvent").cloned().unwrap_or(Value::Null), + "routingOutcome": snapshot.get("routingOutcome").cloned().unwrap_or(Value::Null), + "expectedRoutingSlot": snapshot.get("expectedRoutingSlot").cloned().unwrap_or(Value::Null), + "entrySource": snapshot.get("entrySource").cloned().unwrap_or(Value::Null), + "executorBindingKey": snapshot + .pointer("/runtimeContract/executor_binding/binding_key") + .cloned() + .unwrap_or(Value::Null), + })); + } + } + + json!({ + "contractKeys": contract_keys.into_iter().collect::>(), + "sourceCounts": sources + .into_iter() + .map(|(source, count)| json!({ "source": source, "count": count })) + .collect::>(), + "routingOutcomeCounts": routing_outcomes + .into_iter() + .map(|(outcome, count)| json!({ "outcome": outcome, "count": count })) + .collect::>(), + "expectedRoutingSlots": expected_routing_slots.into_iter().collect::>(), + "toolTraceIndex": { + "traceCount": trace_items.len(), + "items": trace_items, + } + }) +} + +fn snapshot_string(snapshot: &Value, field_name: &str) -> Option { + snapshot + .get(field_name) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn is_runtime_contract_tool_trace_source(source: &str) -> bool { + source.contains("skill_trace") + || source.contains("browser_action_trace") + || source.contains("service_scene_trace") + || source.contains("audio_task") +} + fn build_known_gaps( recent_artifacts: &[RuntimeRecentArtifact], signal_coverage: &[RuntimeEvidenceSignalCoverageEntry], @@ -1233,6 +1321,7 @@ fn collect_auxiliary_runtime_snapshots( } fn collect_modality_runtime_contract_snapshots( + detail: &SessionDetail, workspace_root: Option<&Path>, recent_artifacts: &[RuntimeRecentArtifact], ) -> RuntimeModalityContractSnapshotSummary { @@ -1273,6 +1362,66 @@ fn collect_modality_runtime_contract_snapshots( } } + for item in detail.items.iter().rev() { + let AgentThreadItemPayload::ToolCall { + tool_name, + arguments, + success, + metadata, + .. + } = &item.payload + else { + continue; + }; + let artifact_path = format!("runtime_timeline/{}/{}", item.id, tool_name); + let snapshot = if is_browser_tool_name(tool_name.as_str()) { + metadata.as_ref().and_then(|metadata| { + extract_modality_runtime_contract_snapshot(metadata, artifact_path.as_str()) + }) + } else { + extract_pdf_read_skill_contract_snapshot( + tool_name.as_str(), + arguments.as_ref(), + metadata.as_ref(), + *success, + artifact_path.as_str(), + ) + .or_else(|| { + extract_voice_generation_service_contract_snapshot( + tool_name.as_str(), + arguments.as_ref(), + metadata.as_ref(), + *success, + artifact_path.as_str(), + ) + }) + .or_else(|| { + extract_web_research_skill_contract_snapshot( + tool_name.as_str(), + arguments.as_ref(), + metadata.as_ref(), + *success, + artifact_path.as_str(), + ) + }) + .or_else(|| { + extract_text_transform_skill_contract_snapshot( + tool_name.as_str(), + arguments.as_ref(), + metadata.as_ref(), + *success, + artifact_path.as_str(), + ) + }) + }; + let Some(snapshot) = snapshot else { continue }; + summary.applicable_count += 1; + summary.snapshots.push(snapshot); + if summary.snapshots.len() >= MAX_RECENT_ARTIFACTS { + break; + } + } + summary } @@ -1509,15 +1658,15 @@ fn build_signal_coverage( } else { "exported" }, - source: "image_task.modality_runtime_contract", + source: "task_or_tool_trace.modality_runtime_contract", detail: if modality_runtime_contracts.snapshots.is_empty() { format!( - "当前检测到 {} 个多模态任务工件,但未从稳定 task artifact 中提取到底层 ModalityRuntimeContract 快照。", + "当前检测到 {} 个多模态任务或工具 trace,但未从稳定事实源中提取到底层 ModalityRuntimeContract 快照。", modality_runtime_contracts.applicable_count ) } else { format!( - "当前证据包已从 {} 个多模态任务工件中导出 {} 条 ModalityRuntimeContract / routing 决策快照。", + "当前证据包已从 {} 个多模态任务或工具 trace 中导出 {} 条 ModalityRuntimeContract / routing 决策快照。", modality_runtime_contracts.applicable_count, modality_runtime_contracts.snapshots.len() ) @@ -1847,7 +1996,9 @@ fn is_auxiliary_runtime_applicable(artifact: &RuntimeRecentArtifact) -> bool { fn is_modality_runtime_contract_applicable(artifact: &RuntimeRecentArtifact) -> bool { let normalized_path = artifact.path.replace('\\', "/").to_ascii_lowercase(); - if normalized_path.contains(".lime/tasks/image_generate/") { + if normalized_path.contains(".lime/tasks/image_generate/") + || normalized_path.contains(".lime/tasks/audio_generate/") + { return true; } @@ -1874,12 +2025,562 @@ fn is_modality_runtime_contract_applicable(artifact: &RuntimeRecentArtifact) -> &["artifactType"][..], ], ) - .map(|value| value.eq_ignore_ascii_case("image_generate")) + .map(|value| { + value.eq_ignore_ascii_case("image_generate") + || value.eq_ignore_ascii_case("audio_generate") + }) .unwrap_or(false) }) .unwrap_or(false) } +fn extract_pdf_read_skill_contract_snapshot( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, + success: Option, + artifact_path: &str, +) -> Option { + if !is_pdf_read_skill_tool_call(tool_name, arguments, metadata) { + return None; + } + + for mut document in collect_pdf_extract_contract_documents(arguments, metadata) { + apply_tool_call_status_to_contract_document(&mut document, success); + if let Some(snapshot) = extract_modality_runtime_contract_snapshot(&document, artifact_path) + { + return Some(snapshot); + } + } + + None +} + +fn is_pdf_read_skill_tool_call( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> bool { + let normalized_tool_name = tool_name.trim().to_ascii_lowercase(); + let tool_is_skill = normalized_tool_name == "skill" || normalized_tool_name.contains("skill"); + let tool_is_pdf_read = normalized_tool_name == "pdf_read" + || normalized_tool_name.contains("pdf_read") + || normalized_tool_name.contains("pdf-read"); + let argument_skill_is_pdf_read = arguments + .and_then(|arguments| { + read_json_string( + arguments, + &[ + &["skill"][..], + &["skill_name"][..], + &["skillName"][..], + &["name"][..], + ], + ) + }) + .map(|value| value == "pdf_read") + .unwrap_or(false); + let has_pdf_contract = !collect_pdf_extract_contract_documents(arguments, metadata).is_empty(); + + tool_is_pdf_read || (tool_is_skill && (argument_skill_is_pdf_read || has_pdf_contract)) +} + +fn collect_pdf_extract_contract_documents( + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> Vec { + let mut documents = Vec::new(); + + if let Some(metadata) = metadata { + push_pdf_extract_contract_candidates(metadata, &mut documents); + } + + if let Some(arguments) = arguments { + push_pdf_extract_contract_candidates(arguments, &mut documents); + if let Some(skill_args) = arguments.get("args") { + match skill_args { + Value::Object(_) => { + push_pdf_extract_contract_candidates(skill_args, &mut documents) + } + Value::String(text) => { + if let Ok(parsed) = serde_json::from_str::(text.trim()) { + push_pdf_extract_contract_candidates(&parsed, &mut documents); + } + } + _ => {} + } + } + } + + documents + .into_iter() + .filter(has_pdf_extract_contract) + .collect() +} + +fn push_pdf_extract_contract_candidates(source: &Value, documents: &mut Vec) { + documents.push(source.clone()); + for path in [ + &["harness", "pdf_read_skill_launch"][..], + &["harness", "pdfReadSkillLaunch"][..], + &["harness", "pdf_read_skill_launch", "pdf_read_request"][..], + &["harness", "pdfReadSkillLaunch", "pdfReadRequest"][..], + &["pdf_read_skill_launch"][..], + &["pdfReadSkillLaunch"][..], + &["pdf_read_skill_launch", "pdf_read_request"][..], + &["pdfReadSkillLaunch", "pdfReadRequest"][..], + &["pdf_read_request"][..], + &["pdfReadRequest"][..], + ] { + if let Some(candidate) = find_json_value(source, path) { + documents.push(candidate.clone()); + } + } +} + +fn has_pdf_extract_contract(document: &Value) -> bool { + read_json_string( + document, + &[ + &["modality_contract_key"][..], + &["modalityContractKey"][..], + &["runtime_contract", "contract_key"][..], + &["runtimeContract", "contractKey"][..], + ], + ) + .map(|value| value == PDF_EXTRACT_CONTRACT_KEY) + .unwrap_or(false) +} + +fn extract_voice_generation_service_contract_snapshot( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, + success: Option, + artifact_path: &str, +) -> Option { + if !is_voice_generation_service_tool_call(tool_name, arguments, metadata) { + return None; + } + + for mut document in collect_voice_generation_contract_documents(arguments, metadata) { + apply_tool_call_status_to_contract_document(&mut document, success); + if let Some(snapshot) = extract_modality_runtime_contract_snapshot(&document, artifact_path) + { + return Some(snapshot); + } + } + + None +} + +fn is_voice_generation_service_tool_call( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> bool { + let normalized_tool_name = tool_name.trim().to_ascii_lowercase(); + let tool_is_voice_generation = normalized_tool_name == "voice_runtime" + || normalized_tool_name.contains("voice_runtime") + || normalized_tool_name.contains("voice-generation") + || normalized_tool_name.contains("voice_generation"); + let tool_is_service_scene = normalized_tool_name.contains("service_scene") + || normalized_tool_name.contains("service-skill") + || normalized_tool_name.contains("service_skill") + || normalized_tool_name.contains("lime_run_service_skill"); + let has_voice_contract = + !collect_voice_generation_contract_documents(arguments, metadata).is_empty(); + + tool_is_voice_generation || (tool_is_service_scene && has_voice_contract) +} + +fn collect_voice_generation_contract_documents( + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> Vec { + let mut documents = Vec::new(); + + if let Some(metadata) = metadata { + push_voice_generation_contract_candidates(metadata, &mut documents); + } + + if let Some(arguments) = arguments { + push_voice_generation_contract_candidates(arguments, &mut documents); + if let Some(skill_args) = arguments.get("args") { + match skill_args { + Value::Object(_) => { + push_voice_generation_contract_candidates(skill_args, &mut documents) + } + Value::String(text) => { + if let Ok(parsed) = serde_json::from_str::(text.trim()) { + push_voice_generation_contract_candidates(&parsed, &mut documents); + } + } + _ => {} + } + } + } + + documents + .into_iter() + .filter(has_voice_generation_contract) + .collect() +} + +fn push_voice_generation_contract_candidates(source: &Value, documents: &mut Vec) { + documents.push(source.clone()); + for path in [ + &["harness", "service_scene_launch"][..], + &["harness", "serviceSceneLaunch"][..], + &["harness", "service_scene_launch", "service_scene_run"][..], + &["harness", "serviceSceneLaunch", "serviceSceneRun"][..], + &["service_scene_launch"][..], + &["serviceSceneLaunch"][..], + &["service_scene_launch", "service_scene_run"][..], + &["serviceSceneLaunch", "serviceSceneRun"][..], + &["service_scene_run"][..], + &["serviceSceneRun"][..], + &["result"][..], + &["result", "service_scene_launch"][..], + &["result", "serviceSceneLaunch"][..], + &["result", "service_scene_launch", "service_scene_run"][..], + &["result", "serviceSceneLaunch", "serviceSceneRun"][..], + &["result", "service_scene_run"][..], + &["result", "serviceSceneRun"][..], + ] { + if let Some(candidate) = find_json_value(source, path) { + documents.push(candidate.clone()); + } + } +} + +fn has_voice_generation_contract(document: &Value) -> bool { + read_json_string( + document, + &[ + &["modality_contract_key"][..], + &["modalityContractKey"][..], + &["runtime_contract", "contract_key"][..], + &["runtimeContract", "contractKey"][..], + ], + ) + .map(|value| value == VOICE_GENERATION_CONTRACT_KEY) + .unwrap_or(false) +} + +fn extract_web_research_skill_contract_snapshot( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, + success: Option, + artifact_path: &str, +) -> Option { + if !is_web_research_skill_tool_call(tool_name, arguments, metadata) { + return None; + } + + for mut document in collect_web_research_contract_documents(arguments, metadata) { + apply_tool_call_status_to_contract_document(&mut document, success); + if let Some(snapshot) = extract_modality_runtime_contract_snapshot(&document, artifact_path) + { + return Some(snapshot); + } + } + + None +} + +fn is_web_research_skill_tool_call( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> bool { + let normalized_tool_name = tool_name.trim().to_ascii_lowercase(); + let tool_is_skill = normalized_tool_name == "skill" || normalized_tool_name.contains("skill"); + let tool_is_research = normalized_tool_name == "research" + || normalized_tool_name.contains("research") + || normalized_tool_name == "site_search" + || normalized_tool_name.contains("site_search") + || normalized_tool_name.contains("site-search") + || normalized_tool_name == "report_generate" + || normalized_tool_name.contains("report_generate") + || normalized_tool_name.contains("report-generate"); + let argument_skill_is_web_research = arguments + .and_then(|arguments| { + read_json_string( + arguments, + &[ + &["skill"][..], + &["skill_name"][..], + &["skillName"][..], + &["name"][..], + ], + ) + }) + .map(|value| value == "research" || value == "site_search" || value == "report_generate") + .unwrap_or(false); + let has_web_research_contract = + !collect_web_research_contract_documents(arguments, metadata).is_empty(); + + tool_is_research + || (tool_is_skill && (argument_skill_is_web_research || has_web_research_contract)) +} + +fn collect_web_research_contract_documents( + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> Vec { + let mut documents = Vec::new(); + + if let Some(metadata) = metadata { + push_web_research_contract_candidates(metadata, &mut documents); + } + + if let Some(arguments) = arguments { + push_web_research_contract_candidates(arguments, &mut documents); + if let Some(skill_args) = arguments.get("args") { + match skill_args { + Value::Object(_) => { + push_web_research_contract_candidates(skill_args, &mut documents) + } + Value::String(text) => { + if let Ok(parsed) = serde_json::from_str::(text.trim()) { + push_web_research_contract_candidates(&parsed, &mut documents); + } + } + _ => {} + } + } + } + + documents + .into_iter() + .filter(has_web_research_contract) + .collect() +} + +fn push_web_research_contract_candidates(source: &Value, documents: &mut Vec) { + documents.push(source.clone()); + for path in [ + &["harness", "research_skill_launch"][..], + &["harness", "researchSkillLaunch"][..], + &["harness", "research_skill_launch", "research_request"][..], + &["harness", "researchSkillLaunch", "researchRequest"][..], + &["harness", "deep_search_skill_launch"][..], + &["harness", "deepSearchSkillLaunch"][..], + &["harness", "deep_search_skill_launch", "deep_search_request"][..], + &["harness", "deepSearchSkillLaunch", "deepSearchRequest"][..], + &["harness", "site_search_skill_launch"][..], + &["harness", "siteSearchSkillLaunch"][..], + &["harness", "site_search_skill_launch", "site_search_request"][..], + &["harness", "siteSearchSkillLaunch", "siteSearchRequest"][..], + &["harness", "report_skill_launch"][..], + &["harness", "reportSkillLaunch"][..], + &["harness", "report_skill_launch", "report_request"][..], + &["harness", "reportSkillLaunch", "reportRequest"][..], + &["research_skill_launch"][..], + &["researchSkillLaunch"][..], + &["research_skill_launch", "research_request"][..], + &["researchSkillLaunch", "researchRequest"][..], + &["deep_search_skill_launch"][..], + &["deepSearchSkillLaunch"][..], + &["deep_search_skill_launch", "deep_search_request"][..], + &["deepSearchSkillLaunch", "deepSearchRequest"][..], + &["site_search_skill_launch"][..], + &["siteSearchSkillLaunch"][..], + &["site_search_skill_launch", "site_search_request"][..], + &["siteSearchSkillLaunch", "siteSearchRequest"][..], + &["report_skill_launch"][..], + &["reportSkillLaunch"][..], + &["report_skill_launch", "report_request"][..], + &["reportSkillLaunch", "reportRequest"][..], + &["research_request"][..], + &["researchRequest"][..], + &["deep_search_request"][..], + &["deepSearchRequest"][..], + &["site_search_request"][..], + &["siteSearchRequest"][..], + &["report_request"][..], + &["reportRequest"][..], + ] { + if let Some(candidate) = find_json_value(source, path) { + documents.push(candidate.clone()); + } + } +} + +fn has_web_research_contract(document: &Value) -> bool { + read_json_string( + document, + &[ + &["modality_contract_key"][..], + &["modalityContractKey"][..], + &["runtime_contract", "contract_key"][..], + &["runtimeContract", "contractKey"][..], + ], + ) + .map(|value| value == WEB_RESEARCH_CONTRACT_KEY) + .unwrap_or(false) +} + +fn extract_text_transform_skill_contract_snapshot( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, + success: Option, + artifact_path: &str, +) -> Option { + if !is_text_transform_skill_tool_call(tool_name, arguments, metadata) { + return None; + } + + for mut document in collect_text_transform_contract_documents(arguments, metadata) { + apply_tool_call_status_to_contract_document(&mut document, success); + if let Some(snapshot) = extract_modality_runtime_contract_snapshot(&document, artifact_path) + { + return Some(snapshot); + } + } + + None +} + +fn is_text_transform_skill_tool_call( + tool_name: &str, + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> bool { + let normalized_tool_name = tool_name.trim().to_ascii_lowercase(); + let tool_is_skill = normalized_tool_name == "skill" || normalized_tool_name.contains("skill"); + let tool_is_text_transform = normalized_tool_name == "summary" + || normalized_tool_name.contains("summary") + || normalized_tool_name == "translation" + || normalized_tool_name.contains("translation") + || normalized_tool_name == "analysis" + || normalized_tool_name.contains("analysis"); + let argument_skill_is_text_transform = arguments + .and_then(|arguments| { + read_json_string( + arguments, + &[ + &["skill"][..], + &["skill_name"][..], + &["skillName"][..], + &["name"][..], + ], + ) + }) + .map(|value| value == "summary" || value == "translation" || value == "analysis") + .unwrap_or(false); + let has_text_transform_contract = + !collect_text_transform_contract_documents(arguments, metadata).is_empty(); + + tool_is_text_transform + || (tool_is_skill && (argument_skill_is_text_transform || has_text_transform_contract)) +} + +fn collect_text_transform_contract_documents( + arguments: Option<&Value>, + metadata: Option<&Value>, +) -> Vec { + let mut documents = Vec::new(); + + if let Some(metadata) = metadata { + push_text_transform_contract_candidates(metadata, &mut documents); + } + + if let Some(arguments) = arguments { + push_text_transform_contract_candidates(arguments, &mut documents); + if let Some(skill_args) = arguments.get("args") { + match skill_args { + Value::Object(_) => { + push_text_transform_contract_candidates(skill_args, &mut documents) + } + Value::String(text) => { + if let Ok(parsed) = serde_json::from_str::(text.trim()) { + push_text_transform_contract_candidates(&parsed, &mut documents); + } + } + _ => {} + } + } + } + + documents + .into_iter() + .filter(has_text_transform_contract) + .collect() +} + +fn push_text_transform_contract_candidates(source: &Value, documents: &mut Vec) { + documents.push(source.clone()); + for path in [ + &["harness", "summary_skill_launch"][..], + &["harness", "summarySkillLaunch"][..], + &["harness", "summary_skill_launch", "summary_request"][..], + &["harness", "summarySkillLaunch", "summaryRequest"][..], + &["harness", "translation_skill_launch"][..], + &["harness", "translationSkillLaunch"][..], + &["harness", "translation_skill_launch", "translation_request"][..], + &["harness", "translationSkillLaunch", "translationRequest"][..], + &["harness", "analysis_skill_launch"][..], + &["harness", "analysisSkillLaunch"][..], + &["harness", "analysis_skill_launch", "analysis_request"][..], + &["harness", "analysisSkillLaunch", "analysisRequest"][..], + &["summary_skill_launch"][..], + &["summarySkillLaunch"][..], + &["summary_skill_launch", "summary_request"][..], + &["summarySkillLaunch", "summaryRequest"][..], + &["translation_skill_launch"][..], + &["translationSkillLaunch"][..], + &["translation_skill_launch", "translation_request"][..], + &["translationSkillLaunch", "translationRequest"][..], + &["analysis_skill_launch"][..], + &["analysisSkillLaunch"][..], + &["analysis_skill_launch", "analysis_request"][..], + &["analysisSkillLaunch", "analysisRequest"][..], + &["summary_request"][..], + &["summaryRequest"][..], + &["translation_request"][..], + &["translationRequest"][..], + &["analysis_request"][..], + &["analysisRequest"][..], + ] { + if let Some(candidate) = find_json_value(source, path) { + documents.push(candidate.clone()); + } + } +} + +fn has_text_transform_contract(document: &Value) -> bool { + read_json_string( + document, + &[ + &["modality_contract_key"][..], + &["modalityContractKey"][..], + &["runtime_contract", "contract_key"][..], + &["runtimeContract", "contractKey"][..], + ], + ) + .map(|value| value == TEXT_TRANSFORM_CONTRACT_KEY) + .unwrap_or(false) +} + +fn apply_tool_call_status_to_contract_document(document: &mut Value, success: Option) { + let Some(map) = document.as_object_mut() else { + return; + }; + let status = match success { + Some(false) => "failed", + Some(true) => "completed", + None => return, + }; + map.entry("status".to_string()) + .or_insert_with(|| Value::String(status.to_string())); + map.entry("normalized_status".to_string()) + .or_insert_with(|| Value::String(status.to_string())); +} + fn extract_modality_runtime_contract_snapshot( document: &Value, artifact_path: &str, @@ -1941,8 +2642,27 @@ fn extract_modality_runtime_contract_snapshot( .map(is_modality_contract_routing_failure_code) .unwrap_or(false); let is_image_generation_contract = contract_key == IMAGE_GENERATION_CONTRACT_KEY; + let is_browser_control_contract = contract_key == BROWSER_CONTROL_CONTRACT_KEY; + let is_pdf_extract_contract = contract_key == PDF_EXTRACT_CONTRACT_KEY; + let is_voice_generation_contract = contract_key == VOICE_GENERATION_CONTRACT_KEY; + let is_web_research_contract = contract_key == WEB_RESEARCH_CONTRACT_KEY; + let is_text_transform_contract = contract_key == TEXT_TRANSFORM_CONTRACT_KEY; + let is_audio_task_artifact = is_voice_generation_contract + && (task_type.as_deref() == Some("audio_generate") + || artifact_path + .replace('\\', "/") + .to_ascii_lowercase() + .contains(".lime/tasks/audio_generate/")); let routing_event = if is_contract_routing_failure { "routing_not_possible" + } else if is_browser_control_contract { + "browser_action_requested" + } else if is_pdf_extract_contract + || is_voice_generation_contract + || is_web_research_contract + || is_text_transform_contract + { + "executor_invoked" } else { "model_routing_decision" }; @@ -1956,7 +2676,21 @@ fn extract_modality_runtime_contract_snapshot( Some(json!({ "artifactPath": artifact_path, - "source": "image_task.modality_runtime_contract", + "source": if is_browser_control_contract { + "browser_action_trace.modality_runtime_contract" + } else if is_pdf_extract_contract { + "pdf_read_skill_trace.modality_runtime_contract" + } else if is_audio_task_artifact { + "audio_task.modality_runtime_contract" + } else if is_voice_generation_contract { + "voice_generation_service_scene_trace.modality_runtime_contract" + } else if is_web_research_contract { + "web_research_skill_trace.modality_runtime_contract" + } else if is_text_transform_contract { + "text_transform_skill_trace.modality_runtime_contract" + } else { + "image_task.modality_runtime_contract" + }, "taskId": read_json_string( document, &[ @@ -1976,12 +2710,33 @@ fn extract_modality_runtime_contract_snapshot( ), "normalizedStatus": normalized_status, "contractKey": contract_key, - "contractMatchedExpected": is_image_generation_contract, + "contractMatchedExpected": is_image_generation_contract || is_browser_control_contract || is_pdf_extract_contract || is_voice_generation_contract || is_web_research_contract || is_text_transform_contract, "expectedRoutingSlot": if is_image_generation_contract { Some(IMAGE_GENERATION_ROUTING_SLOT) + } else if is_browser_control_contract { + Some(BROWSER_CONTROL_ROUTING_SLOT) + } else if is_pdf_extract_contract { + Some(PDF_EXTRACT_ROUTING_SLOT) + } else if is_voice_generation_contract { + Some(VOICE_GENERATION_ROUTING_SLOT) + } else if is_web_research_contract { + Some(WEB_RESEARCH_ROUTING_SLOT) + } else if is_text_transform_contract { + Some(TEXT_TRANSFORM_ROUTING_SLOT) } else { None }, + "entrySource": read_json_string( + document, + &[ + &["entry_source"][..], + &["entrySource"][..], + &["payload", "entry_source"][..], + &["payload", "entrySource"][..], + &["record", "payload", "entry_source"][..], + &["record", "payload", "entrySource"][..], + ], + ), "modality": read_json_string( document, &[ @@ -2017,18 +2772,30 @@ fn extract_modality_runtime_contract_snapshot( &[ &["provider_id"][..], &["providerId"][..], + &["preferred_provider_id"][..], + &["preferredProviderId"][..], &["payload", "provider_id"][..], &["payload", "providerId"][..], + &["payload", "preferred_provider_id"][..], + &["payload", "preferredProviderId"][..], &["record", "payload", "provider_id"][..], &["record", "payload", "providerId"][..], + &["record", "payload", "preferred_provider_id"][..], + &["record", "payload", "preferredProviderId"][..], ], ), "model": read_json_string( document, &[ &["model"][..], + &["preferred_model_id"][..], + &["preferredModelId"][..], &["payload", "model"][..], + &["payload", "preferred_model_id"][..], + &["payload", "preferredModelId"][..], &["record", "payload", "model"][..], + &["record", "payload", "preferred_model_id"][..], + &["record", "payload", "preferredModelId"][..], ], ), "modelCapabilityAssessment": find_json_value_at_paths( @@ -3028,6 +3795,78 @@ mod tests { .expect("write failed image task"); } + fn write_audio_task_fixture(root: &Path, relative_path: &str) { + let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::create_dir_all( + absolute_path + .parent() + .expect("audio task path should have parent"), + ) + .expect("create audio task dir"); + fs::write( + absolute_path, + serde_json::to_string_pretty(&json!({ + "task_id": "task-audio-1", + "task_type": "audio_generate", + "task_family": "audio", + "title": "发布旁白", + "summary": "发布旁白音频任务", + "payload": { + "prompt": "请为这段文案生成温暖旁白", + "source_text": "请为这段文案生成温暖旁白", + "voice": "warm_narrator", + "provider_id": "limecore", + "model": "voice-pro", + "entry_source": "at_voice_command", + "modality_contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": "audio", + "required_capabilities": ["text_generation", "voice_generation"], + "routing_slot": VOICE_GENERATION_ROUTING_SLOT, + "runtime_contract": { + "contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": "audio", + "required_capabilities": ["text_generation", "voice_generation"], + "routing_slot": VOICE_GENERATION_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "service_skill", + "binding_key": "voice_runtime" + }, + "truth_source": ["audio_task_artifact", "runtime_timeline_event"] + }, + "audio_output": { + "kind": "audio_output", + "status": "pending", + "audio_path": null, + "mime_type": "audio/mpeg", + "duration_ms": null, + "source_text": "请为这段文案生成温暖旁白", + "voice": "warm_narrator" + } + }, + "status": "pending_submit", + "normalized_status": "pending", + "created_at": "2026-04-30T10:00:00Z", + "updated_at": null, + "submitted_at": null, + "started_at": null, + "completed_at": null, + "cancelled_at": null, + "idempotency_key": null, + "retry_count": 0, + "source_task_id": null, + "result": null, + "last_error": null, + "current_attempt_id": "attempt-audio-1", + "attempts": [], + "relationships": {}, + "progress": {}, + "ui_hints": {} + })) + .expect("serialize audio task"), + ) + .expect("write audio task"); + } + #[allow(dead_code)] fn write_auxiliary_runtime_projection_fixture( root: &Path, @@ -3633,4 +4472,719 @@ mod tests { Some(IMAGE_GENERATION_CONTRACT_KEY) ); } + + #[test] + fn should_export_browser_control_contract_snapshot_from_tool_metadata() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "browser-contract-tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "mcp__lime-browser__navigate".to_string(), + arguments: Some(json!({ + "url": "https://example.com" + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: Some(json!({ + "tool_family": "browser", + "modality_contract_key": BROWSER_CONTROL_CONTRACT_KEY, + "modality": "browser", + "required_capabilities": [ + "text_generation", + "browser_reasoning", + "browser_control_planning" + ], + "routing_slot": BROWSER_CONTROL_ROUTING_SLOT, + "runtime_contract": { + "contract_key": BROWSER_CONTROL_CONTRACT_KEY, + "routing_slot": BROWSER_CONTROL_ROUTING_SLOT + }, + "entry_source": "at_browser_command" + })), + }, + }); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert!(result + .known_gaps + .iter() + .all(|gap| !gap.contains("ModalityRuntimeContract"))); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(BROWSER_CONTROL_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("browser_action_trace.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("browser_action_requested") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingOutcome") + .and_then(Value::as_str), + Some("accepted") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/expectedRoutingSlot") + .and_then(Value::as_str), + Some(BROWSER_CONTROL_ROUTING_SLOT) + ); + } + + #[test] + fn should_export_pdf_extract_contract_snapshot_from_skill_metadata() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "pdf-contract-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "pdf_read", + "args": { + "pdf_read_request": { + "source_path": "/tmp/agent-report.pdf" + } + } + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: Some(json!({ + "modality_contract_key": PDF_EXTRACT_CONTRACT_KEY, + "modality": "document", + "required_capabilities": [ + "text_generation", + "local_file_read", + "long_context" + ], + "routing_slot": PDF_EXTRACT_ROUTING_SLOT, + "runtime_contract": { + "contract_key": PDF_EXTRACT_CONTRACT_KEY, + "routing_slot": PDF_EXTRACT_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": "pdf_read" + } + }, + "entry_source": "at_pdf_read_command" + })), + }, + }); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert!(result + .known_gaps + .iter() + .all(|gap| !gap.contains("ModalityRuntimeContract"))); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(PDF_EXTRACT_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("pdf_read_skill_trace.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingOutcome") + .and_then(Value::as_str), + Some("accepted") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/expectedRoutingSlot") + .and_then(Value::as_str), + Some(PDF_EXTRACT_ROUTING_SLOT) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractMatchedExpected") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn should_export_voice_generation_contract_snapshot_from_service_scene_trace() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "voice-contract-service-scene-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "voice_runtime".to_string(), + arguments: Some(json!({ + "service_scene_launch": { + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "voice-runtime", + "scene_key": "voice_runtime", + "user_input": "请为这段文案生成温暖旁白", + "entry_source": "at_voice_command", + "preferred_provider_id": "limecore", + "preferred_model_id": "voice-pro", + "modality_contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": "audio", + "required_capabilities": [ + "text_generation", + "voice_generation" + ], + "routing_slot": VOICE_GENERATION_ROUTING_SLOT, + "runtime_contract": { + "contract_key": VOICE_GENERATION_CONTRACT_KEY, + "routing_slot": VOICE_GENERATION_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "service_skill", + "binding_key": "voice_runtime" + } + } + } + } + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(VOICE_GENERATION_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("voice_generation_service_scene_trace.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/expectedRoutingSlot") + .and_then(Value::as_str), + Some(VOICE_GENERATION_ROUTING_SLOT) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/entrySource") + .and_then(Value::as_str), + Some("at_voice_command") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/traceCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer( + "/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/executorBindingKey" + ) + .and_then(Value::as_str), + Some("voice_runtime") + ); + } + + #[test] + fn should_export_voice_generation_contract_snapshot_from_audio_task_artifact() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + let audio_task_relative_path = ".lime/tasks/audio_generate/task-audio-1.json"; + + write_audio_task_fixture(temp_dir.path(), audio_task_relative_path); + + if let AgentThreadItemPayload::FileArtifact { path, metadata, .. } = + &mut detail.items[1].payload + { + *path = audio_task_relative_path.to_string(); + *metadata = Some(json!({ + "task_type": "audio_generate" + })); + } + + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("audio_task.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(VOICE_GENERATION_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/taskType") + .and_then(Value::as_str), + Some("audio_generate") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/entrySource") + .and_then(Value::as_str), + Some("at_voice_command") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/executorBindingKey") + .and_then(Value::as_str), + Some("voice_runtime") + ); + } + + #[test] + fn should_export_web_research_contract_snapshot_from_skill_args() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "web-research-contract-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "research", + "args": serde_json::to_string(&json!({ + "research_request": { + "query": "AI Agent 融资", + "modality_contract_key": WEB_RESEARCH_CONTRACT_KEY, + "modality": "mixed", + "required_capabilities": [ + "text_generation", + "web_search", + "structured_document_generation", + "long_context" + ], + "routing_slot": WEB_RESEARCH_ROUTING_SLOT, + "runtime_contract": { + "contract_key": WEB_RESEARCH_CONTRACT_KEY, + "routing_slot": WEB_RESEARCH_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": "research" + } + }, + "entry_source": "at_search_command" + } + })).expect("serialize args") + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert!(result + .known_gaps + .iter() + .all(|gap| !gap.contains("ModalityRuntimeContract"))); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(WEB_RESEARCH_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("web_research_skill_trace.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/routingOutcome") + .and_then(Value::as_str), + Some("accepted") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/expectedRoutingSlot") + .and_then(Value::as_str), + Some(WEB_RESEARCH_ROUTING_SLOT) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/contractKeys/0") + .and_then(Value::as_str), + Some(WEB_RESEARCH_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/sourceCounts/0/source") + .and_then(Value::as_str), + Some("web_research_skill_trace.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/routingOutcomeCounts/0/outcome") + .and_then(Value::as_str), + Some("accepted") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/traceCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer( + "/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/entrySource", + ) + .and_then(Value::as_str), + Some("at_search_command") + ); + assert_eq!( + runtime + .pointer( + "/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/executorBindingKey", + ) + .and_then(Value::as_str), + Some("research") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractMatchedExpected") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] + fn should_export_web_research_contract_snapshot_from_report_skill_args() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "web-research-contract-report-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "report_generate", + "args": serde_json::to_string(&json!({ + "report_request": { + "query": "AI Agent 融资", + "modality_contract_key": WEB_RESEARCH_CONTRACT_KEY, + "modality": "mixed", + "required_capabilities": [ + "text_generation", + "web_search", + "structured_document_generation", + "long_context" + ], + "routing_slot": WEB_RESEARCH_ROUTING_SLOT, + "runtime_contract": { + "contract_key": WEB_RESEARCH_CONTRACT_KEY, + "routing_slot": WEB_RESEARCH_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": "research" + } + }, + "entry_source": "at_report_command" + } + })).expect("serialize args") + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(WEB_RESEARCH_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/entrySource") + .and_then(Value::as_str), + Some("at_report_command") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/executorBindingKey") + .and_then(Value::as_str), + Some("research") + ); + } + + #[test] + fn should_export_text_transform_contract_snapshot_from_summary_skill_args() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "text-transform-contract-summary-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "summary", + "args": serde_json::to_string(&json!({ + "summary_request": { + "content": "AI Agent 融资长文", + "modality_contract_key": TEXT_TRANSFORM_CONTRACT_KEY, + "modality": "document", + "required_capabilities": [ + "text_generation", + "local_file_read", + "long_context" + ], + "routing_slot": TEXT_TRANSFORM_ROUTING_SLOT, + "runtime_contract": { + "contract_key": TEXT_TRANSFORM_CONTRACT_KEY, + "routing_slot": TEXT_TRANSFORM_ROUTING_SLOT, + "executor_binding": { + "executor_kind": "skill", + "binding_key": "text_transform" + } + }, + "entry_source": "at_summary_command" + } + })).expect("serialize args") + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(TEXT_TRANSFORM_CONTRACT_KEY) + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("text_transform_skill_trace.modality_runtime_contract") + ); + assert_eq!( + runtime + .pointer( + "/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/entrySource" + ) + .and_then(Value::as_str), + Some("at_summary_command") + ); + assert_eq!( + runtime + .pointer("/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/executorBindingKey") + .and_then(Value::as_str), + Some("text_transform") + ); + } } diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index 870fa63eb..a40cc89b5 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -6,6 +6,10 @@ use crate::agent::SessionDetail; use crate::commands::aster_agent_cmd::AgentRuntimeThreadReadModel; +use crate::commands::modality_runtime_contracts::{ + BROWSER_CONTROL_CONTRACT_KEY, PDF_EXTRACT_CONTRACT_KEY, TEXT_TRANSFORM_CONTRACT_KEY, + VOICE_GENERATION_CONTRACT_KEY, WEB_RESEARCH_CONTRACT_KEY, +}; use crate::services::runtime_evidence_pack_service::{ export_runtime_evidence_pack, RuntimeEvidencePackExportResult, }; @@ -685,6 +689,36 @@ fn build_success_criteria( ) )); } + if modality_contract_has_browser_control(modality_runtime_contracts) { + criteria.push( + "浏览器控制任务必须继续走 Browser Assist / `mcp__lime-browser__*` 主链,不能用 WebSearch 或普通聊天替代真实浏览器动作。" + .to_string(), + ); + } + if modality_contract_has_pdf_extract(modality_runtime_contracts) { + criteria.push( + "PDF 读取任务必须继续走 `Skill(pdf_read)` 或真实 `list_directory` / `read_file` 证据链,不能用 `frontend_direct_pdf_parse`、`generic_chat_summary_only`、WebSearch 或普通聊天替代真实读 PDF。" + .to_string(), + ); + } + if modality_contract_has_voice_generation(modality_runtime_contracts) { + criteria.push( + "配音任务必须继续走 `service_scene_launch(scene_key=voice_runtime)` / 本地 ServiceSkill runtime 主链,不能用 `legacy_tts_test_command`、伪造云端已提交、普通聊天文本或通用文件卡替代真实语音生成合同。" + .to_string(), + ); + } + if modality_contract_has_web_research(modality_runtime_contracts) { + criteria.push( + "联网研究任务必须继续走 `Skill(research)` / `Skill(site_search)` / `Skill(report_generate)` 与真实 search_query / lime_site_* 工具时间线,不能用模型记忆、本地文件搜索或普通聊天替代真实联网研究。" + .to_string(), + ); + } + if modality_contract_has_text_transform(modality_runtime_contracts) { + criteria.push( + "文本/文档转换任务必须继续走 `Skill(summary)` / `Skill(translation)` / `Skill(analysis)`,显式文件路径场景可保留 `list_directory` / `read_file` 证据,不能用 `frontend_direct_text_transform`、ToolSearch、WebSearch 或普通聊天替代底层合同。" + .to_string(), + ); + } if criteria.is_empty() { criteria.push("结果应与 input.json 描述的任务目标一致。".to_string()); @@ -736,6 +770,46 @@ fn build_blocking_checks( ) )); } + if modality_contract_has_browser_control(modality_runtime_contracts) + && !modality_contract_has_browser_action_trace(modality_runtime_contracts) + { + checks.push( + "`browser_control` 合同缺少 browser action trace;除非 replay 重新产生 Browser Assist 工具调用证据,否则不能判 PASS。" + .to_string(), + ); + } + if modality_contract_has_pdf_extract(modality_runtime_contracts) + && !modality_contract_has_pdf_skill_trace(modality_runtime_contracts) + { + checks.push( + "`pdf_extract` 合同缺少 Skill(pdf_read) / 文件读取 trace;除非 replay 重新产生 Skill(pdf_read)、list_directory 或 read_file 证据,否则不能判 PASS。" + .to_string(), + ); + } + if modality_contract_has_voice_generation(modality_runtime_contracts) + && !modality_contract_has_voice_generation_service_trace(modality_runtime_contracts) + { + checks.push( + "`voice_generation` 合同缺少 voice_runtime service scene trace;除非 replay 重新产生 service_scene_launch(scene_key=voice_runtime) 或 audio_task/audio_output 证据,否则不能判 PASS。" + .to_string(), + ); + } + if modality_contract_has_web_research(modality_runtime_contracts) + && !modality_contract_has_web_research_skill_trace(modality_runtime_contracts) + { + checks.push( + "`web_research` 合同缺少 Skill(research) / Skill(site_search) / Skill(report_generate) trace;除非 replay 重新产生 Skill 调用与 search_query 或 lime_site_* 证据,否则不能判 PASS。" + .to_string(), + ); + } + if modality_contract_has_text_transform(modality_runtime_contracts) + && !modality_contract_has_text_transform_skill_trace(modality_runtime_contracts) + { + checks.push( + "`text_transform` 合同缺少 Skill(summary) / Skill(translation) / Skill(analysis) trace;除非 replay 重新产生文本转换 Skill 调用,必要时保留 list_directory / read_file 证据,否则不能判 PASS。" + .to_string(), + ); + } if checks.is_empty() { checks.push("当前没有额外阻塞检查项,按结果与证据判定即可。".to_string()); @@ -784,6 +858,56 @@ fn build_modality_contract_checks(modality_runtime_contracts: &Value) -> Vec Vec bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == BROWSER_CONTROL_CONTRACT_KEY) + .unwrap_or(false) + }) +} + +fn modality_contract_has_browser_action_trace(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("source") + .and_then(Value::as_str) + .map(|value| value.contains("browser_action_trace")) + .unwrap_or(false) + || snapshot + .get("routingEvent") + .and_then(Value::as_str) + .map(|value| value == "browser_action_requested") + .unwrap_or(false) + }) +} + +fn modality_contract_has_pdf_extract(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == PDF_EXTRACT_CONTRACT_KEY) + .unwrap_or(false) + }) +} + +fn modality_contract_has_pdf_skill_trace(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("source") + .and_then(Value::as_str) + .map(|value| { + value.contains("pdf_read_skill_trace") + || value.contains("pdf_extract_file_trace") + }) + .unwrap_or(false) + || snapshot + .get("routingEvent") + .and_then(Value::as_str) + .map(|value| value == "executor_invoked") + .unwrap_or(false) + }) +} + +fn modality_contract_has_voice_generation(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == VOICE_GENERATION_CONTRACT_KEY) + .unwrap_or(false) + }) +} + +fn modality_contract_has_voice_generation_service_trace( + modality_runtime_contracts: &Value, +) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + let is_voice_generation = snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == VOICE_GENERATION_CONTRACT_KEY) + .unwrap_or(false); + if !is_voice_generation { + return false; + } + + snapshot + .get("source") + .and_then(Value::as_str) + .map(|value| { + value.contains("voice_generation_service_scene_trace") + || value.contains("audio_task") + || value.contains("audio_output") + }) + .unwrap_or(false) + || snapshot + .get("routingEvent") + .and_then(Value::as_str) + .map(|value| value == "executor_invoked") + .unwrap_or(false) + }) +} + +fn modality_contract_has_web_research(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == WEB_RESEARCH_CONTRACT_KEY) + .unwrap_or(false) + }) +} + +fn modality_contract_has_web_research_skill_trace(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + let is_web_research = snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == WEB_RESEARCH_CONTRACT_KEY) + .unwrap_or(false); + if !is_web_research { + return false; + } + + snapshot + .get("source") + .and_then(Value::as_str) + .map(|value| value.contains("web_research_skill_trace")) + .unwrap_or(false) + || snapshot + .get("routingEvent") + .and_then(Value::as_str) + .map(|value| value == "executor_invoked") + .unwrap_or(false) + }) +} + +fn modality_contract_has_text_transform(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == TEXT_TRANSFORM_CONTRACT_KEY) + .unwrap_or(false) + }) +} + +fn modality_contract_has_text_transform_skill_trace(modality_runtime_contracts: &Value) -> bool { + modality_contract_snapshots(modality_runtime_contracts) + .iter() + .any(|snapshot| { + let is_text_transform = snapshot + .get("contractKey") + .and_then(Value::as_str) + .map(|value| value == TEXT_TRANSFORM_CONTRACT_KEY) + .unwrap_or(false); + if !is_text_transform { + return false; + } + + snapshot + .get("source") + .and_then(Value::as_str) + .map(|value| value.contains("text_transform_skill_trace")) + .unwrap_or(false) + || snapshot + .get("routingEvent") + .and_then(Value::as_str) + .map(|value| value == "executor_invoked") + .unwrap_or(false) + }) +} + fn modality_contract_model_capability_assessment_sources( modality_runtime_contracts: &Value, ) -> Vec { @@ -1607,6 +1972,77 @@ mod tests { .expect("write failed image task"); } + fn write_audio_contract_task_fixture(root: &Path, relative_path: &str) { + let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::create_dir_all( + absolute_path + .parent() + .expect("audio task path should have parent"), + ) + .expect("create audio task dir"); + fs::write( + absolute_path, + serde_json::to_string_pretty(&json!({ + "task_id": "task-audio-1", + "task_type": "audio_generate", + "task_family": "audio", + "title": "发布旁白", + "payload": { + "prompt": "请为这段文案生成温暖旁白", + "source_text": "请为这段文案生成温暖旁白", + "voice": "warm_narrator", + "provider_id": "limecore", + "model": "voice-pro", + "entry_source": "at_voice_command", + "modality_contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": "audio", + "required_capabilities": ["text_generation", "voice_generation"], + "routing_slot": "voice_generation_model", + "runtime_contract": { + "contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": "audio", + "required_capabilities": ["text_generation", "voice_generation"], + "routing_slot": "voice_generation_model", + "executor_binding": { + "executor_kind": "service_skill", + "binding_key": "voice_runtime" + }, + "truth_source": ["audio_task_artifact", "runtime_timeline_event"] + }, + "audio_output": { + "kind": "audio_output", + "status": "pending", + "audio_path": null, + "mime_type": "audio/mpeg", + "duration_ms": null, + "source_text": "请为这段文案生成温暖旁白", + "voice": "warm_narrator" + } + }, + "status": "pending_submit", + "normalized_status": "pending", + "created_at": "2026-04-30T10:00:00Z", + "updated_at": null, + "submitted_at": null, + "started_at": null, + "completed_at": null, + "cancelled_at": null, + "idempotency_key": null, + "retry_count": 0, + "source_task_id": null, + "result": null, + "last_error": null, + "current_attempt_id": "attempt-audio-1", + "attempts": [], + "relationships": {}, + "progress": {}, + "ui_hints": {} + })) + .expect("serialize audio task"), + ) + .expect("write audio task"); + } + #[test] fn should_export_runtime_replay_case_to_workspace() { let temp_dir = TempDir::new().expect("temp dir"); @@ -1774,4 +2210,688 @@ mod tests { Some("image_generation_model_capability_gap") ); } + + #[test] + fn should_carry_browser_control_contract_into_replay_grader_checks() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "browser-contract-tool-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 4, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:00:40Z".to_string(), + completed_at: Some("2026-03-27T10:00:40Z".to_string()), + updated_at: "2026-03-27T10:00:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "mcp__lime-browser__navigate".to_string(), + arguments: Some(json!({ + "url": "https://example.com" + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: Some(json!({ + "tool_family": "browser", + "modality_contract_key": BROWSER_CONTROL_CONTRACT_KEY, + "modality": "browser", + "required_capabilities": [ + "text_generation", + "browser_reasoning", + "browser_control_planning" + ], + "routing_slot": "browser_reasoning_model", + "runtime_contract": { + "contract_key": BROWSER_CONTROL_CONTRACT_KEY, + "routing_slot": "browser_reasoning_model" + }, + "entry_source": "at_browser_command" + })), + }, + }); + + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let grader_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/grader.md"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + let input = + serde_json::from_str::(fs::read_to_string(input_path).expect("input").as_str()) + .expect("parse input"); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(BROWSER_CONTROL_CONTRACT_KEY) + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("browser_action_requested") + ); + let suite_tags = input + .pointer("/classification/suiteTags") + .and_then(Value::as_array) + .expect("suite tags"); + for expected_tag in [ + "modality-browser_control", + "browser-control", + "browser-assist", + "browser-action-trace", + ] { + assert!(suite_tags + .iter() + .any(|item| item.as_str() == Some(expected_tag))); + } + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("Browser Assist")); + assert!(expected.contains("mcp__lime-browser__*")); + assert!(expected.contains("WebSearch")); + assert!(expected.contains("browser_action_trace")); + assert!(expected.contains("\"requiresHumanReview\": false")); + + let grader = fs::read_to_string(grader_path).expect("grader"); + assert!(grader.contains("多模态运行合同检查")); + assert!(grader.contains("browser_action_requested")); + assert!(grader.contains("WebSearch")); + + let links = + serde_json::from_str::(fs::read_to_string(links_path).expect("links").as_str()) + .expect("parse links"); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("browser_action_trace.modality_runtime_contract") + ); + } + + #[test] + fn should_carry_pdf_extract_contract_into_replay_grader_checks() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "pdf-contract-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:01:40Z".to_string(), + completed_at: Some("2026-03-27T10:01:40Z".to_string()), + updated_at: "2026-03-27T10:01:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "pdf_read", + "args": "{\"pdf_read_request\":{\"source_path\":\"/tmp/agent-report.pdf\"}}" + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: Some(json!({ + "modality_contract_key": PDF_EXTRACT_CONTRACT_KEY, + "modality": "document", + "required_capabilities": [ + "text_generation", + "local_file_read", + "long_context" + ], + "routing_slot": "base_model", + "runtime_contract": { + "contract_key": PDF_EXTRACT_CONTRACT_KEY, + "routing_slot": "base_model", + "executor_binding": { + "executor_kind": "skill", + "binding_key": "pdf_read" + } + }, + "entry_source": "at_pdf_read_command" + })), + }, + }); + + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let grader_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/grader.md"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + let input = + serde_json::from_str::(fs::read_to_string(input_path).expect("input").as_str()) + .expect("parse input"); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(PDF_EXTRACT_CONTRACT_KEY) + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/traceCount", + ) + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/toolTraceIndex/items/0/executorBindingKey", + ) + .and_then(Value::as_str), + Some("pdf_read") + ); + let suite_tags = input + .pointer("/classification/suiteTags") + .and_then(Value::as_array) + .expect("suite tags"); + for expected_tag in [ + "modality-pdf_extract", + "pdf-extract", + "pdf-read-skill", + "pdf-read-trace", + ] { + assert!(suite_tags + .iter() + .any(|item| item.as_str() == Some(expected_tag))); + } + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("Skill(pdf_read)")); + assert!(expected.contains("list_directory")); + assert!(expected.contains("read_file")); + assert!(expected.contains("frontend_direct_pdf_parse")); + assert!(expected.contains("generic_chat_summary_only")); + assert!(expected.contains("WebSearch")); + assert!(expected.contains("\"requiresHumanReview\": false")); + + let grader = fs::read_to_string(grader_path).expect("grader"); + assert!(grader.contains("多模态运行合同检查")); + assert!(grader.contains("Skill(pdf_read)")); + assert!(grader.contains("frontend_direct_pdf_parse")); + assert!(grader.contains("generic_chat_summary_only")); + + let links = + serde_json::from_str::(fs::read_to_string(links_path).expect("links").as_str()) + .expect("parse links"); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("pdf_read_skill_trace.modality_runtime_contract") + ); + } + + #[test] + fn should_carry_voice_generation_contract_into_replay_grader_checks() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "voice-contract-service-scene-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:01:40Z".to_string(), + completed_at: Some("2026-03-27T10:01:40Z".to_string()), + updated_at: "2026-03-27T10:01:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "voice_runtime".to_string(), + arguments: Some(json!({ + "service_scene_launch": { + "kind": "local_service_skill", + "service_scene_run": { + "skill_id": "voice-runtime", + "scene_key": "voice_runtime", + "user_input": "请为这段文案生成温暖旁白", + "entry_source": "at_voice_command", + "modality_contract_key": VOICE_GENERATION_CONTRACT_KEY, + "modality": "audio", + "required_capabilities": [ + "text_generation", + "voice_generation" + ], + "routing_slot": "voice_generation_model", + "runtime_contract": { + "contract_key": VOICE_GENERATION_CONTRACT_KEY, + "routing_slot": "voice_generation_model", + "executor_binding": { + "executor_kind": "service_skill", + "binding_key": "voice_runtime" + } + } + } + } + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let grader_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/grader.md"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + let input = + serde_json::from_str::(fs::read_to_string(input_path).expect("input").as_str()) + .expect("parse input"); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(VOICE_GENERATION_CONTRACT_KEY) + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("voice_generation_service_scene_trace.modality_runtime_contract") + ); + let suite_tags = input + .pointer("/classification/suiteTags") + .and_then(Value::as_array) + .expect("suite tags"); + for expected_tag in [ + "modality-voice_generation", + "voice-generation", + "voice-runtime", + "voice-generation-trace", + ] { + assert!(suite_tags + .iter() + .any(|item| item.as_str() == Some(expected_tag))); + } + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("service_scene_launch(scene_key=voice_runtime)")); + assert!(expected.contains("ServiceSkill runtime")); + assert!(expected.contains("legacy_tts_test_command")); + assert!(expected.contains("fake_cloud_scene_submitted")); + assert!(expected.contains("audio_task/audio_output")); + assert!(expected.contains("\"requiresHumanReview\": false")); + + let grader = fs::read_to_string(grader_path).expect("grader"); + assert!(grader.contains("多模态运行合同检查")); + assert!(grader.contains("voice_generation")); + assert!(grader.contains("voice_runtime")); + assert!(grader.contains("legacy_tts_test_command")); + + let links = + serde_json::from_str::(fs::read_to_string(links_path).expect("links").as_str()) + .expect("parse links"); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("voice_generation_service_scene_trace.modality_runtime_contract") + ); + } + + #[test] + fn should_carry_voice_generation_audio_task_into_replay_grader_checks() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + let audio_task_relative_path = ".lime/tasks/audio_generate/task-audio-1.json"; + + write_audio_contract_task_fixture(temp_dir.path(), audio_task_relative_path); + if let AgentThreadItemPayload::FileArtifact { path, metadata, .. } = + &mut detail.items[2].payload + { + *path = audio_task_relative_path.to_string(); + *metadata = Some(json!({ + "task_type": "audio_generate" + })); + } + + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + let input = + serde_json::from_str::(fs::read_to_string(input_path).expect("input").as_str()) + .expect("parse input"); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("audio_task.modality_runtime_contract") + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/taskType") + .and_then(Value::as_str), + Some("audio_generate") + ); + let suite_tags = input + .pointer("/classification/suiteTags") + .and_then(Value::as_array) + .expect("suite tags"); + assert!(suite_tags + .iter() + .any(|item| item.as_str() == Some("voice-generation-trace"))); + let failure_modes = input + .pointer("/classification/failureModes") + .and_then(Value::as_array) + .expect("failure modes"); + assert!(!failure_modes + .iter() + .any(|item| { item.as_str() == Some("voice_generation_missing_service_trace") })); + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("audio_task/audio_output")); + assert!(expected.contains("\"requiresHumanReview\": false")); + + let links = + serde_json::from_str::(fs::read_to_string(links_path).expect("links").as_str()) + .expect("parse links"); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("audio_task.modality_runtime_contract") + ); + } + + #[test] + fn should_carry_web_research_contract_into_replay_grader_checks() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "web-research-contract-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:01:40Z".to_string(), + completed_at: Some("2026-03-27T10:01:40Z".to_string()), + updated_at: "2026-03-27T10:01:40Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "report_generate", + "args": serde_json::to_string(&json!({ + "report_request": { + "query": "AI Agent 融资", + "modality_contract_key": WEB_RESEARCH_CONTRACT_KEY, + "modality": "mixed", + "required_capabilities": [ + "text_generation", + "web_search", + "structured_document_generation", + "long_context" + ], + "routing_slot": "report_generation_model", + "runtime_contract": { + "contract_key": WEB_RESEARCH_CONTRACT_KEY, + "routing_slot": "report_generation_model", + "executor_binding": { + "executor_kind": "skill", + "binding_key": "research" + } + }, + "entry_source": "at_report_command" + } + })).expect("serialize args") + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let grader_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/grader.md"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + let input = + serde_json::from_str::(fs::read_to_string(input_path).expect("input").as_str()) + .expect("parse input"); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(WEB_RESEARCH_CONTRACT_KEY) + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + let suite_tags = input + .pointer("/classification/suiteTags") + .and_then(Value::as_array) + .expect("suite tags"); + for expected_tag in [ + "modality-web_research", + "web-research", + "research-skill", + "web-research-trace", + ] { + assert!(suite_tags + .iter() + .any(|item| item.as_str() == Some(expected_tag))); + } + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("Skill(research)")); + assert!(expected.contains("Skill(site_search)")); + assert!(expected.contains("Skill(report_generate)")); + assert!(expected.contains("search_query")); + assert!(expected.contains("lime_site_*")); + assert!(expected.contains("model_memory_only_answer")); + assert!(expected.contains("local_file_search_before_research_skill")); + assert!(expected.contains("\"requiresHumanReview\": false")); + + let grader = fs::read_to_string(grader_path).expect("grader"); + assert!(grader.contains("多模态运行合同检查")); + assert!(grader.contains("Skill(research)")); + assert!(grader.contains("Skill(report_generate)")); + assert!(grader.contains("model_memory_only_answer")); + assert!(grader.contains("local_file_search_before_research_skill")); + + let links = + serde_json::from_str::(fs::read_to_string(links_path).expect("links").as_str()) + .expect("parse links"); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("web_research_skill_trace.modality_runtime_contract") + ); + } + + #[test] + fn should_carry_text_transform_contract_into_replay_grader_checks() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + + detail.items.push(AgentThreadItem { + id: "text-transform-contract-skill-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 5, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-27T10:01:45Z".to_string(), + completed_at: Some("2026-03-27T10:01:45Z".to_string()), + updated_at: "2026-03-27T10:01:45Z".to_string(), + payload: AgentThreadItemPayload::ToolCall { + tool_name: "Skill".to_string(), + arguments: Some(json!({ + "skill": "summary", + "args": serde_json::to_string(&json!({ + "summary_request": { + "source_path": "/tmp/meeting-notes.md", + "instruction": "总结成三点行动项", + "modality_contract_key": TEXT_TRANSFORM_CONTRACT_KEY, + "modality": "document", + "required_capabilities": [ + "text_generation", + "local_file_read", + "long_context" + ], + "routing_slot": "base_model", + "runtime_contract": { + "contract_key": TEXT_TRANSFORM_CONTRACT_KEY, + "routing_slot": "base_model", + "executor_binding": { + "executor_kind": "skill", + "binding_key": "text_transform" + } + }, + "entry_source": "at_summary_command" + } + })).expect("serialize args") + })), + output: Some("ok".to_string()), + success: Some(true), + error: None, + metadata: None, + }, + }); + + export_runtime_replay_case(&detail, &thread_read, temp_dir.path()).expect("export"); + + let input_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/input.json"); + let expected_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/expected.json"); + let grader_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/grader.md"); + let links_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/replay/evidence-links.json"); + + let input = + serde_json::from_str::(fs::read_to_string(input_path).expect("input").as_str()) + .expect("parse input"); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/contractKey") + .and_then(Value::as_str), + Some(TEXT_TRANSFORM_CONTRACT_KEY) + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/routingEvent") + .and_then(Value::as_str), + Some("executor_invoked") + ); + let suite_tags = input + .pointer("/classification/suiteTags") + .and_then(Value::as_array) + .expect("suite tags"); + for expected_tag in [ + "modality-text_transform", + "text-transform", + "text-transform-skill", + "text-transform-trace", + ] { + assert!(suite_tags + .iter() + .any(|item| item.as_str() == Some(expected_tag))); + } + + let expected = fs::read_to_string(expected_path).expect("expected"); + assert!(expected.contains("Skill(summary)")); + assert!(expected.contains("Skill(translation)")); + assert!(expected.contains("Skill(analysis)")); + assert!(expected.contains("list_directory")); + assert!(expected.contains("read_file")); + assert!(expected.contains("frontend_direct_text_transform")); + assert!(expected.contains("tool_search_before_text_transform_skill")); + assert!(expected.contains("web_search_before_text_transform_skill")); + assert!(expected.contains("\"requiresHumanReview\": false")); + + let grader = fs::read_to_string(grader_path).expect("grader"); + assert!(grader.contains("多模态运行合同检查")); + assert!(grader.contains("Skill(summary)")); + assert!(grader.contains("Skill(translation)")); + assert!(grader.contains("Skill(analysis)")); + assert!(grader.contains("frontend_direct_text_transform")); + + let links = + serde_json::from_str::(fs::read_to_string(links_path).expect("links").as_str()) + .expect("parse links"); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshots/0/source") + .and_then(Value::as_str), + Some("text_transform_skill_trace.modality_runtime_contract") + ); + } } diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index dc95c2857..5aa589045 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.23.0", + "version": "1.24.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", @@ -28,7 +28,7 @@ } ], "security": { - "csp": "default-src 'self' tauri:; img-src 'self' asset: tauri: data: https: blob:; style-src 'self' 'unsafe-inline' data: blob:; script-src 'self' 'unsafe-inline'; connect-src 'self' tauri: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'" + "csp": "default-src 'self' tauri:; img-src 'self' asset: tauri: data: https: blob: http://localhost:* http://127.0.0.1:* http://[::1]:*; style-src 'self' 'unsafe-inline' data: blob:; script-src 'self' 'unsafe-inline'; connect-src 'self' tauri: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'" }, "macOSPrivateApi": true }, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b834a0c7f..5a53fec9b 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.23.0", + "version": "1.24.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", @@ -44,7 +44,7 @@ } ], "security": { - "csp": "default-src 'self' tauri:; img-src 'self' asset: tauri: data: https: blob:; style-src 'self' 'unsafe-inline' data: blob:; script-src 'self' 'unsafe-inline'; connect-src 'self' tauri: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'" + "csp": "default-src 'self' tauri:; img-src 'self' asset: tauri: data: https: blob: http://localhost:* http://127.0.0.1:* http://[::1]:*; style-src 'self' 'unsafe-inline' data: blob:; script-src 'self' 'unsafe-inline'; connect-src 'self' tauri: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* http://[::1]:* ws://[::1]:* https:; frame-src https: http:; object-src 'none'" }, "macOSPrivateApi": true }, diff --git a/src/RootRouter.test.tsx b/src/RootRouter.test.tsx index 55cdb06e8..6cb4a958b 100644 --- a/src/RootRouter.test.tsx +++ b/src/RootRouter.test.tsx @@ -47,9 +47,7 @@ vi.mock("./components/ui/sonner", () => ({ })); vi.mock("./components/layout/AppCrashBoundary", () => ({ - AppCrashBoundary: ({ children }: { children: ReactNode }) => ( - <>{children} - ), + AppCrashBoundary: ({ children }: { children: ReactNode }) => <>{children}, })); vi.mock("./components/layout/CrashRecoveryPanel.helpers", () => ({ @@ -61,8 +59,7 @@ vi.mock("./lib/appVersion", () => ({ })); vi.mock("./lib/oemCloudStartupLogin", () => ({ - startOemCloudStartupLoginIfRequired: - mockStartOemCloudStartupLoginIfRequired, + startOemCloudStartupLoginIfRequired: mockStartOemCloudStartupLoginIfRequired, })); interface MountedRootRouter { diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 943b9987d..d8892b1a2 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -85,9 +85,7 @@ const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage); const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace); const AgentChatPage = lazy(loadAgentChatPage); -function serializeInitialInputCapabilityKey( - params: AgentPageParams, -): string { +function serializeInitialInputCapabilityKey(params: AgentPageParams): string { const route = params.initialInputCapability?.capabilityRoute; if (!route) { return "::0"; @@ -150,11 +148,7 @@ export function AppPageContent({ ? current : activeSceneAppsPageParams, ); - }, [ - activeSceneAppsPageParams, - activeSceneAppsPageParamsKey, - activePage, - ]); + }, [activeSceneAppsPageParams, activeSceneAppsPageParamsKey, activePage]); const shouldRenderSceneApps = hasVisitedSceneApps || activePage === "sceneapps"; diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 401916fc6..f6073f55d 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -5,6 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { AppSidebar } from "./AppSidebar"; +import { + TASK_CENTER_CREATE_DRAFT_TASK_EVENT, + TASK_CENTER_OPEN_TASK_EVENT, +} from "@/components/agent/chat/taskCenterDraftTaskEvents"; import { LIME_COLOR_SCHEME_STORAGE_KEY } from "@/lib/appearance/colorSchemes"; import { LIME_THEME_STORAGE_KEY } from "@/lib/appearance/themeMode"; import { @@ -23,7 +27,10 @@ const { mockSetI18nLanguage, mockScheduleMinimumDelayIdleTask, mockLogoutClient, + mockGetConfiguredOemCloudTarget, + mockBuildOemCloudUserCenterUrl, mockCreateExternalBrowserOpenTarget, + mockOpenExternalUrl, mockStartOemCloudLogin, mockGetClientReferralDashboard, mockClearSiteAdapterCatalogCache, @@ -43,7 +50,12 @@ const { return () => undefined; }), mockLogoutClient: vi.fn(), + mockGetConfiguredOemCloudTarget: vi.fn(), + mockBuildOemCloudUserCenterUrl: vi.fn( + (baseUrl: string, path = "") => `${baseUrl}${path}`, + ), mockCreateExternalBrowserOpenTarget: vi.fn(() => null), + mockOpenExternalUrl: vi.fn(), mockStartOemCloudLogin: vi.fn(), mockGetClientReferralDashboard: vi.fn(), mockClearSiteAdapterCatalogCache: vi.fn(), @@ -76,11 +88,14 @@ vi.mock("@/lib/api/agentRuntime", () => ({ vi.mock("@/lib/api/oemCloudControlPlane", () => ({ logoutClient: mockLogoutClient, + getConfiguredOemCloudTarget: mockGetConfiguredOemCloudTarget, getClientReferralDashboard: mockGetClientReferralDashboard, })); vi.mock("@/lib/oemCloudLoginLauncher", () => ({ + buildOemCloudUserCenterUrl: mockBuildOemCloudUserCenterUrl, createExternalBrowserOpenTarget: mockCreateExternalBrowserOpenTarget, + openExternalUrl: mockOpenExternalUrl, startOemCloudLogin: mockStartOemCloudLogin, })); @@ -233,6 +248,14 @@ describe("AppSidebar", () => { mockListAgentRuntimeSessions.mockResolvedValue([]); mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); mockLogoutClient.mockResolvedValue(undefined); + mockGetConfiguredOemCloudTarget.mockReturnValue({ + baseUrl: "https://user.limeai.run", + tenantId: "tenant-0001", + }); + mockBuildOemCloudUserCenterUrl.mockImplementation( + (baseUrl: string, path = "") => `${baseUrl}${path}`, + ); + mockOpenExternalUrl.mockResolvedValue(undefined); mockStartOemCloudLogin.mockResolvedValue({ mode: "login_url", openedUrl: "https://user.limeai.run/login", @@ -501,7 +524,7 @@ describe("AppSidebar", () => { }); }); - it("已登录账号弹框应展示真实套餐摘要并可直达详情", async () => { + it("已登录账号弹框应展示真实套餐摘要并跳出到用户中心详情", async () => { const onNavigate = vi.fn(); setStoredOemCloudSessionState({ token: "session-token", @@ -558,10 +581,16 @@ describe("AppSidebar", () => { await Promise.resolve(); }); - expect(onNavigate).toHaveBeenCalledWith("settings", { - tab: SettingsTabs.Providers, - providerView: "cloud", - }); + expect(onNavigate).not.toHaveBeenCalled(); + expect(mockBuildOemCloudUserCenterUrl).toHaveBeenCalledWith( + "https://user.limeai.run", + "/billing?tab=usage", + ); + expect(mockOpenExternalUrl).toHaveBeenCalledWith( + "https://user.limeai.run/billing?tab=usage", + { browserTarget: null }, + ); + expect(mockToastSuccess).toHaveBeenCalledWith("已打开 Lime 云端 用户中心"); }); it("云端开启邀请时应在头部展示入口并读取 share 事实源", async () => { @@ -709,7 +738,9 @@ describe("AppSidebar", () => { await act(async () => { accountMenu - ?.querySelector('button[aria-label="连接 Lime 云端"]') + ?.querySelector( + 'button[aria-label="连接 Lime 云端"]', + ) ?.click(); await Promise.resolve(); }); @@ -733,9 +764,7 @@ describe("AppSidebar", () => { const accountMenu = container.querySelector( '[data-testid="app-sidebar-account-menu"]', ); - expect(accountMenu?.textContent).not.toContain( - "本地开源功能可直接使用", - ); + expect(accountMenu?.textContent).not.toContain("本地开源功能可直接使用"); expect( accountMenu?.querySelector('button[aria-label="开源使用说明"]'), ).not.toBeNull(); @@ -881,10 +910,32 @@ describe("AppSidebar", () => { providerView: "settings", }); - await clickAccountMenuItem(container, "Lime 云端"); + await clickAccountMenuItem(container, "用户中心"); + expect(mockBuildOemCloudUserCenterUrl).toHaveBeenLastCalledWith( + "https://user.limeai.run", + "/welcome", + ); + expect(mockOpenExternalUrl).toHaveBeenLastCalledWith( + "https://user.limeai.run/welcome", + { browserTarget: null }, + ); expect(onNavigate).toHaveBeenLastCalledWith("settings", { tab: SettingsTabs.Providers, - providerView: "cloud", + providerView: "settings", + }); + + await clickAccountMenuItem(container, "Lime 云端"); + expect(mockBuildOemCloudUserCenterUrl).toHaveBeenLastCalledWith( + "https://user.limeai.run", + "/welcome", + ); + expect(mockOpenExternalUrl).toHaveBeenLastCalledWith( + "https://user.limeai.run/welcome", + { browserTarget: null }, + ); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Providers, + providerView: "settings", }); await clickAccountMenuItem(container, "关于"); @@ -949,6 +1000,103 @@ describe("AppSidebar", () => { ).not.toBeNull(); }); + it("任务中心内点击一级新建任务应交给本地草稿标签,不跳出到新建首页", async () => { + const onNavigate = vi.fn(); + const receivedDetails: unknown[] = []; + const listener = (event: Event) => { + receivedDetails.push( + event instanceof CustomEvent ? event.detail : undefined, + ); + event.preventDefault(); + }; + window.addEventListener(TASK_CENTER_CREATE_DRAFT_TASK_EVENT, listener); + + try { + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "claw", + projectId: "project-1", + initialSessionId: "session-current", + } as AgentPageParams, + onNavigate, + }); + await flushEffects(2); + + await act(async () => { + container + .querySelector('button[aria-label="新建任务"]') + ?.click(); + await Promise.resolve(); + }); + + expect(receivedDetails).toEqual([{ source: "sidebar" }]); + expect(onNavigate).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(TASK_CENTER_CREATE_DRAFT_TASK_EVENT, listener); + } + }); + + it("任务中心内点击已有会话应先通知本地标签栏,再继续导航", async () => { + const onNavigate = vi.fn(); + const receivedDetails: unknown[] = []; + const listener = (event: Event) => { + receivedDetails.push( + event instanceof CustomEvent ? event.detail : undefined, + ); + }; + window.addEventListener(TASK_CENTER_OPEN_TASK_EVENT, listener); + mockListAgentRuntimeSessions.mockResolvedValue([ + { + id: "session-target", + name: "目标历史会话", + created_at: 1713000000, + updated_at: 1713000600, + archived_at: null, + workspace_id: "project-1", + messages_count: 3, + }, + ]); + + try { + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "claw", + projectId: "project-1", + initialSessionId: "session-current", + } as AgentPageParams, + onNavigate, + }); + await flushEffects(2); + + await act(async () => { + container + .querySelector('button[title="目标历史会话"]') + ?.click(); + await Promise.resolve(); + }); + + expect(receivedDetails).toEqual([ + { + sessionId: "session-target", + workspaceId: "project-1", + source: "sidebar", + }, + ]); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + agentEntry: "claw", + projectId: "project-1", + initialSessionId: "session-target", + }), + ); + } finally { + window.removeEventListener(TASK_CENTER_OPEN_TASK_EVENT, listener); + } + }); + it("claw 页面不应再外露左侧工作台一级入口", async () => { const onNavigate = vi.fn(); mockListAgentRuntimeSessions.mockResolvedValue([ diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 58919bff7..69a5a87c2 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -53,6 +53,10 @@ import { buildClawAgentParams, buildHomeAgentParams, } from "@/lib/workspace/navigation"; +import { + notifyTaskCenterTaskOpen, + requestTaskCenterDraftTask, +} from "@/components/agent/chat/taskCenterDraftTaskEvents"; import { listAgentRuntimeSessions, updateAgentRuntimeSession, @@ -91,13 +95,16 @@ import { clearSkillCatalogCache } from "@/lib/api/skillCatalog"; import { clearServiceSkillCatalogCache } from "@/lib/api/serviceSkills"; import { getClientReferralDashboard, + getConfiguredOemCloudTarget, logoutClient, type OemCloudBootstrapResponse, type OemCloudReferralDashboard, } from "@/lib/api/oemCloudControlPlane"; import { clearSiteAdapterCatalogCache } from "@/lib/siteAdapterCatalogBootstrap"; import { + buildOemCloudUserCenterUrl, createExternalBrowserOpenTarget, + openExternalUrl, startOemCloudLogin, } from "@/lib/oemCloudLoginLauncher"; import { @@ -1961,7 +1968,8 @@ export function AppSidebar({ const agentEntry = (activePageParams as AgentPageParams | undefined) ?.agentEntry; const activeAgentPageParams = activePageParams as AgentPageParams | undefined; - const isClawTaskCenter = activePage === "agent" && agentEntry === "claw"; + const isAgentWorkspace = activePage === "agent"; + const isClawTaskCenter = isAgentWorkspace && agentEntry === "claw"; const isNewTaskHome = activePage === "agent" && agentEntry === "new-task"; const [rememberedProjectId, setRememberedProjectId] = useState( () => @@ -2815,8 +2823,18 @@ export function AppSidebar({ return activePage === item.page; }; + const tryOpenTaskCenterDraftFromSidebar = useCallback(() => { + return ( + isAgentWorkspace && requestTaskCenterDraftTask({ source: "sidebar" }) + ); + }, [isAgentWorkspace]); + const handleNavigate = (item: SidebarNavItem) => { if (item.id === "home-general") { + if (tryOpenTaskCenterDraftFromSidebar()) { + return; + } + const targetParams = buildHomeAgentParams({ projectId: currentProjectId ?? undefined, }); @@ -2926,6 +2944,14 @@ export function AppSidebar({ }; const handleNavigateToConversation = (session: AsterSessionInfo) => { + if (isClawTaskCenter) { + notifyTaskCenterTaskOpen({ + sessionId: session.id, + workspaceId: session.workspace_id ?? currentProjectId ?? null, + source: "sidebar", + }); + } + const targetParams = buildClawAgentParams({ projectId: session.workspace_id ?? currentProjectId ?? undefined, initialSessionId: session.id, @@ -2951,6 +2977,10 @@ export function AppSidebar({ }; const handleNavigateToNewTask = () => { + if (tryOpenTaskCenterDraftFromSidebar()) { + return; + } + const targetParams = buildHomeAgentParams({ projectId: currentProjectId ?? undefined, }); @@ -3096,6 +3126,32 @@ export function AppSidebar({ } }, [cloudBrandLabel]); + const handleOpenAccountUserCenter = useCallback( + async (path = "/welcome") => { + setAccountMenuOpen(false); + setLanguageMenuOpen(false); + + try { + const target = getConfiguredOemCloudTarget(); + const browserTarget = createExternalBrowserOpenTarget(); + await openExternalUrl( + buildOemCloudUserCenterUrl(target.baseUrl, path), + { + browserTarget, + }, + ); + toast.success(`已打开 ${cloudBrandLabel} 用户中心`); + } catch (error) { + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : `打开 ${cloudBrandLabel} 用户中心失败`; + toast.error(message); + } + }, + [cloudBrandLabel], + ); + const handleAccountLogout = useCallback(async () => { const tenantId = cloudSessionState?.session.tenant.id; setAccountLogoutPending(true); @@ -3505,10 +3561,7 @@ export function AppSidebar({ aria-label="查看套餐详情" data-testid="app-sidebar-cloud-account-card" onClick={() => - handleAccountMenuNavigate({ - tab: SettingsTabs.Providers, - providerView: "cloud", - }) + void handleOpenAccountUserCenter("/billing?tab=usage") } > @@ -3680,9 +3733,7 @@ export function AppSidebar({ type="button" aria-label="用户中心" onClick={() => - handleAccountMenuNavigate({ - tab: SettingsTabs.Profile, - }) + void handleOpenAccountUserCenter("/welcome") } > @@ -3713,10 +3764,7 @@ export function AppSidebar({ type="button" aria-label={cloudBrandLabel} onClick={() => - handleAccountMenuNavigate({ - tab: SettingsTabs.Providers, - providerView: "cloud", - }) + void handleOpenAccountUserCenter("/welcome") } > diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index fdbd597dc..a3b8ccb97 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -77,6 +77,7 @@ import { type AgentRuntimeReviewDecisionTemplate, type AgentRuntimeSaveReviewDecisionRequest, } from "@/lib/api/agentRuntime"; +import type { AgentRuntimeUpdateSessionRequest } from "@/lib/api/agentRuntime/types"; import { prepareSceneAppRunGovernanceArtifact, prepareSceneAppRunGovernanceArtifacts, @@ -216,6 +217,10 @@ import { TaskCenterTabStrip, type TaskCenterTabItem, } from "./components/TaskCenterTabStrip"; +import { + subscribeTaskCenterDraftTaskRequests, + subscribeTaskCenterTaskOpenRequests, +} from "./taskCenterDraftTaskEvents"; import type { GeneralWorkbenchFollowUpActionPayload } from "./components/generalWorkbenchSidebarContract"; import { RuntimeReviewDecisionDialog } from "./components/RuntimeReviewDecisionDialog"; import { @@ -330,14 +335,68 @@ import { const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; const BLANK_HOME_DEFERRED_LOAD_MS = 18_000; -const SESSION_ENTRY_DEFERRED_LOAD_MS = 12_000; +const SESSION_ENTRY_TOPIC_DEFERRED_LOAD_MS = 12_000; +const SESSION_ENTRY_AUXILIARY_DEFERRED_LOAD_MS = 45_000; +const SESSION_RECENT_METADATA_BACKGROUND_SYNC_DELAY_MS = 12_000; +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"; const BROWSER_WORKSPACE_HOME_HINT_MESSAGE = "在这里切换或新建工作区"; const BROWSER_WORKSPACE_HOME_HINT_AUTO_HIDE_MS = 5_500; +const TASK_CENTER_DRAFT_TAB_PREFIX = "task-draft"; const NOOP_SET_CHAT_MESSAGES: Dispatch> = () => undefined; +interface TaskCenterDraftTab { + id: string; + title: string; + createdAt: Date; + updatedAt: Date; + status: TaskCenterTabItem["status"]; +} + +type SessionRecentMetadataSyncPriority = "immediate" | "background"; + +interface SessionRecentMetadataSyncOptions { + priority?: SessionRecentMetadataSyncPriority; +} + +type SessionRecentMetadataPatch = Pick< + AgentRuntimeUpdateSessionRequest, + "recent_preferences" | "recent_team_selection" +>; + +interface PendingSessionRecentMetadataSync { + patch: SessionRecentMetadataPatch; + priority: SessionRecentMetadataSyncPriority; + cancel: (() => void) | null; + resolvers: Array<() => void>; + rejecters: Array<(error: unknown) => void>; +} + +function createTaskCenterDraftTabId(): string { + const random = + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID().slice(0, 8) + : Math.random().toString(36).slice(2, 10); + return `${TASK_CENTER_DRAFT_TAB_PREFIX}-${Date.now().toString(36)}-${random}`; +} + +function isTaskCenterDraftTabId(value: string): boolean { + return value.startsWith(`${TASK_CENTER_DRAFT_TAB_PREFIX}-`); +} + +function mergeSessionRecentMetadataSyncPriority( + current: SessionRecentMetadataSyncPriority, + next?: SessionRecentMetadataSyncPriority, +): SessionRecentMetadataSyncPriority { + if (current === "immediate" || next !== "background") { + return "immediate"; + } + + return "background"; +} + function resolveDefaultSelectedArtifact( activeTheme: string, artifacts: Artifact[], @@ -524,21 +583,152 @@ export function AgentChatWorkspace({ initialCreationMode ?? "guided", ); const activeSessionIdRef = useRef(null); + const pendingSessionRecentMetadataSyncRef = useRef< + Map + >(new Map()); const sessionRecentPreferencesBackfillKeyRef = useRef(null); + const flushSessionRecentMetadataSync = useCallback((sessionId: string) => { + const pending = pendingSessionRecentMetadataSyncRef.current.get(sessionId); + if (!pending) { + return; + } + + pending.cancel?.(); + pendingSessionRecentMetadataSyncRef.current.delete(sessionId); + + if ( + pending.priority === "background" && + activeSessionIdRef.current !== sessionId + ) { + pending.resolvers.forEach((resolve) => resolve()); + return; + } + + void updateAgentRuntimeSession({ + session_id: sessionId, + ...pending.patch, + }) + .then(() => { + pending.resolvers.forEach((resolve) => resolve()); + }) + .catch((error) => { + pending.rejecters.forEach((reject) => reject(error)); + }); + }, []); + const scheduleSessionRecentMetadataSync = useCallback( + (sessionId: string, priority: SessionRecentMetadataSyncPriority) => { + const pending = + pendingSessionRecentMetadataSyncRef.current.get(sessionId); + if (!pending) { + return; + } + + pending.cancel?.(); + pending.cancel = + priority === "background" + ? scheduleMinimumDelayIdleTask( + () => { + pending.cancel = null; + flushSessionRecentMetadataSync(sessionId); + }, + { + minimumDelayMs: + SESSION_RECENT_METADATA_BACKGROUND_SYNC_DELAY_MS, + idleTimeoutMs: + SESSION_RECENT_METADATA_BACKGROUND_SYNC_IDLE_TIMEOUT_MS, + }, + ) + : scheduleMinimumDelayIdleTask( + () => { + pending.cancel = null; + flushSessionRecentMetadataSync(sessionId); + }, + { + minimumDelayMs: 0, + idleTimeoutMs: 500, + }, + ); + }, + [flushSessionRecentMetadataSync], + ); + const enqueueSessionRecentMetadataSync = useCallback( + ( + sessionId: string, + patch: SessionRecentMetadataPatch, + options?: SessionRecentMetadataSyncOptions, + ): Promise => { + const trimmedSessionId = sessionId.trim(); + if (!trimmedSessionId) { + return Promise.resolve(); + } + + const requestedPriority = options?.priority ?? "immediate"; + + return new Promise((resolve, reject) => { + const pending = + pendingSessionRecentMetadataSyncRef.current.get(trimmedSessionId); + if (pending) { + const previousPriority = pending.priority; + pending.patch = { + ...pending.patch, + ...patch, + }; + pending.priority = mergeSessionRecentMetadataSyncPriority( + pending.priority, + requestedPriority, + ); + pending.resolvers.push(resolve); + pending.rejecters.push(reject); + if ( + previousPriority === "background" && + pending.priority === "immediate" + ) { + scheduleSessionRecentMetadataSync(trimmedSessionId, "immediate"); + } + return; + } + + pendingSessionRecentMetadataSyncRef.current.set(trimmedSessionId, { + patch, + priority: requestedPriority, + cancel: null, + resolvers: [resolve], + rejecters: [reject], + }); + scheduleSessionRecentMetadataSync(trimmedSessionId, requestedPriority); + }); + }, + [scheduleSessionRecentMetadataSync], + ); + useEffect(() => { + const pendingSyncMap = pendingSessionRecentMetadataSyncRef.current; + return () => { + const pendingSyncs = pendingSyncMap.values(); + for (const pending of pendingSyncs) { + pending.cancel?.(); + pending.resolvers.forEach((resolve) => resolve()); + } + pendingSyncMap.clear(); + }; + }, []); const syncSessionRecentPreferences = useCallback( async ( sessionId: string, preferences: Parameters< typeof createSessionRecentPreferencesFromChatToolPreferences >[0], + options?: SessionRecentMetadataSyncOptions, ) => { - await updateAgentRuntimeSession({ - session_id: sessionId, - recent_preferences: - createSessionRecentPreferencesFromChatToolPreferences(preferences), - }); + await enqueueSessionRecentMetadataSync( + sessionId, + { + recent_preferences: + createSessionRecentPreferencesFromChatToolPreferences(preferences), + }, + options, + ); }, - [], + [enqueueSessionRecentMetadataSync], ); const syncSessionRecentTeamSelection = useCallback( async ( @@ -547,14 +737,18 @@ export function AgentChatWorkspace({ typeof createSessionRecentTeamSelectionFromTeamDefinition >[0], theme?: string | null, + options?: SessionRecentMetadataSyncOptions, ) => { - await updateAgentRuntimeSession({ - session_id: sessionId, - recent_team_selection: - createSessionRecentTeamSelectionFromTeamDefinition(team, theme), - }); + await enqueueSessionRecentMetadataSync( + sessionId, + { + recent_team_selection: + createSessionRecentTeamSelectionFromTeamDefinition(team, theme), + }, + options, + ); }, - [], + [enqueueSessionRecentMetadataSync], ); const chatToolPreferenceSessionSync = useMemo( () => ({ @@ -626,7 +820,12 @@ export function AgentChatWorkspace({ const deferredWorkspaceAuxiliaryLoadMs = shouldPreserveBlankHomeSurface ? BLANK_HOME_DEFERRED_LOAD_MS : shouldPrioritizeInitialSessionEntry || shouldPrioritizeInitialPromptEntry - ? SESSION_ENTRY_DEFERRED_LOAD_MS + ? SESSION_ENTRY_AUXILIARY_DEFERRED_LOAD_MS + : undefined; + const deferredInitialTopicsLoadMs = shouldPreserveBlankHomeSurface + ? BLANK_HOME_DEFERRED_LOAD_MS + : shouldPrioritizeInitialSessionEntry || shouldPrioritizeInitialPromptEntry + ? SESSION_ENTRY_TOPIC_DEFERRED_LOAD_MS : undefined; const [isInitialContentLoading, setIsInitialContentLoading] = useState( shouldBootstrapCanvasOnEntry, @@ -1641,7 +1840,8 @@ export function AgentChatWorkspace({ }, { minimumDelayMs: - deferredWorkspaceAuxiliaryLoadMs ?? SESSION_ENTRY_DEFERRED_LOAD_MS, + deferredWorkspaceAuxiliaryLoadMs ?? + SESSION_ENTRY_AUXILIARY_DEFERRED_LOAD_MS, idleTimeoutMs: 1_500, }, ); @@ -1730,7 +1930,8 @@ export function AgentChatWorkspace({ const cancelDeferredCheck = shouldDeferWorkspaceAuxiliaryLoads ? scheduleMinimumDelayIdleTask(runWorkspaceCheck, { minimumDelayMs: - deferredWorkspaceAuxiliaryLoadMs ?? SESSION_ENTRY_DEFERRED_LOAD_MS, + deferredWorkspaceAuxiliaryLoadMs ?? + SESSION_ENTRY_AUXILIARY_DEFERRED_LOAD_MS, idleTimeoutMs: 1_500, }) : null; @@ -1866,7 +2067,7 @@ export function AgentChatWorkspace({ ? "deferred" : "immediate", initialTopicsDeferredDelayMs: shouldDeferInitialTopicsLoad - ? deferredWorkspaceAuxiliaryLoadMs + ? deferredInitialTopicsLoadMs : undefined, getSyncedSessionRecentPreferences, }); @@ -1994,10 +2195,9 @@ export function AgentChatWorkspace({ } sessionRecentPreferencesBackfillKeyRef.current = backfillKey; - void syncSessionRecentPreferences( - trimmedSessionId, - fallbackPreferences, - ).catch((error) => { + void syncSessionRecentPreferences(trimmedSessionId, fallbackPreferences, { + priority: "background", + }).catch((error) => { console.warn("[AgentChatPage] 回填会话 recent_preferences 失败:", error); }); }, [ @@ -3391,6 +3591,14 @@ export function AgentChatWorkspace({ taskCenterEmbeddedHomeSessionIds, setTaskCenterEmbeddedHomeSessionIds, ] = useState>(() => new Set()); + const [taskCenterDraftTabs, setTaskCenterDraftTabs] = useState< + TaskCenterDraftTab[] + >([]); + const [activeTaskCenterDraftTabId, setActiveTaskCenterDraftTabId] = useState< + string | null + >(null); + const taskCenterDraftTabsRef = useRef([]); + const activeTaskCenterDraftTabIdRef = useRef(null); const [taskCenterLocalSessionOverride, setTaskCenterLocalSessionOverride] = useState<{ sessionId: string; @@ -3398,10 +3606,13 @@ export function AgentChatWorkspace({ } | null>(null); const shouldRespectTaskCenterLocalSession = Boolean( taskCenterLocalSessionOverride && - sessionId && - taskCenterLocalSessionOverride.sessionId === sessionId && - taskCenterLocalSessionOverride.routeSessionId === - normalizedInitialSessionId, + (taskCenterLocalSessionOverride.routeSessionId === + normalizedInitialSessionId || + taskCenterLocalSessionOverride.sessionId === + normalizedInitialSessionId) && + (!sessionId || + taskCenterLocalSessionOverride.sessionId === sessionId || + taskCenterLocalSessionOverride.sessionId === normalizedInitialSessionId), ); const taskCenterOpenTabIds = useMemo( () => @@ -3416,11 +3627,20 @@ export function AgentChatWorkspace({ topicId: string; startedAt: number; } | null>(null); + const taskCenterRouteTabSyncRef = useRef(null); useEffect(() => { taskCenterOpenTabIdsRef.current = taskCenterOpenTabIds; }, [taskCenterOpenTabIds]); + useEffect(() => { + taskCenterDraftTabsRef.current = taskCenterDraftTabs; + }, [taskCenterDraftTabs]); + + useEffect(() => { + activeTaskCenterDraftTabIdRef.current = activeTaskCenterDraftTabId; + }, [activeTaskCenterDraftTabId]); + useEffect(() => { if (agentEntry !== "claw") { setTaskCenterTransitionTopicId(null); @@ -3441,6 +3661,8 @@ export function AgentChatWorkspace({ setTaskCenterEmbeddedHomeSessionIds((current) => current.size > 0 ? new Set() : current, ); + setTaskCenterDraftTabs((current) => (current.length > 0 ? [] : current)); + setActiveTaskCenterDraftTabId(null); setTaskCenterLocalSessionOverride(null); return; } @@ -3471,14 +3693,39 @@ export function AgentChatWorkspace({ return; } - setTaskCenterOpenTabMap((currentMap) => - replaceTaskCenterTabIdsForWorkspace( + const routeChanged = + taskCenterRouteTabSyncRef.current !== normalizedInitialSessionId; + taskCenterRouteTabSyncRef.current = normalizedInitialSessionId; + if (routeChanged || shouldRespectTaskCenterLocalSession) { + setActiveTaskCenterDraftTabId(null); + } + setTaskCenterOpenTabMap((currentMap) => { + if (shouldRespectTaskCenterLocalSession) { + return updateTaskCenterTabIdsForWorkspace( + currentMap, + taskCenterWorkspaceId, + (currentIds) => + [ + normalizedInitialSessionId, + ...currentIds.filter( + (topicId) => topicId !== normalizedInitialSessionId, + ), + ].slice(0, MAX_TASK_CENTER_OPEN_TABS), + ); + } + + return replaceTaskCenterTabIdsForWorkspace( currentMap, taskCenterWorkspaceId, normalizedInitialSessionId, - ), - ); - }, [agentEntry, normalizedInitialSessionId, taskCenterWorkspaceId]); + ); + }); + }, [ + agentEntry, + normalizedInitialSessionId, + shouldRespectTaskCenterLocalSession, + taskCenterWorkspaceId, + ]); useEffect(() => { if ( @@ -3522,6 +3769,11 @@ export function AgentChatWorkspace({ Boolean(normalizedInitialSessionId) && normalizedInitialSessionId !== sessionId && !shouldRespectTaskCenterLocalSession; + const effectiveCurrentTopicId = + shouldRespectTaskCenterLocalSession && + taskCenterLocalSessionOverride?.sessionId === normalizedInitialSessionId + ? normalizedInitialSessionId + : (sessionId ?? null); const nextIds = reconcileTaskCenterTabIds({ existingIds: resolveTaskCenterTabIdsForWorkspace( currentMap, @@ -3530,9 +3782,9 @@ export function AgentChatWorkspace({ topics, currentTopicId: isInitialSessionRoutePending ? null - : taskCenterDetachedTopicId === sessionId + : taskCenterDetachedTopicId === effectiveCurrentTopicId ? null - : (sessionId ?? null), + : effectiveCurrentTopicId, }); return updateTaskCenterTabIdsForWorkspace( currentMap, @@ -3545,6 +3797,7 @@ export function AgentChatWorkspace({ sessionId, normalizedInitialSessionId, shouldRespectTaskCenterLocalSession, + taskCenterLocalSessionOverride?.sessionId, taskCenterDetachedTopicId, taskCenterWorkspaceId, topics, @@ -3860,9 +4113,22 @@ export function AgentChatWorkspace({ .find((message) => message.role === "assistant")?.id ?? null, [displayMessages], ); + const activeTaskCenterDraftTab = useMemo( + () => + activeTaskCenterDraftTabId + ? (taskCenterDraftTabs.find( + (tab) => tab.id === activeTaskCenterDraftTabId, + ) ?? null) + : null, + [activeTaskCenterDraftTabId, taskCenterDraftTabs], + ); + const isTaskCenterDraftTabActive = Boolean( + agentEntry === "claw" && activeTaskCenterDraftTab, + ); // 布局层按实际展示内容判断,避免 bootstrap 预览等临时消息仍被视为空白态。 - const hasDisplayMessages = displayMessages.length > 0; + const hasDisplayMessages = + !isTaskCenterDraftTabActive && displayMessages.length > 0; const hasMessages = hasDisplayMessages; const effectiveShowChatPanel = showChatPanel || @@ -4036,9 +4302,15 @@ export function AgentChatWorkspace({ }, []); const finalizeFreshTaskCenterConversation = useCallback( - (newSessionId: string, workspaceIdOverride?: string | null) => { + ( + newSessionId: string, + workspaceIdOverride?: string | null, + options?: { preserveInput?: boolean }, + ) => { resetTopicLocalState(); - setInput(""); + if (options?.preserveInput !== true) { + setInput(""); + } setSelectedText(""); setMentionedCharacters([]); upsertTaskCenterOpenTab(newSessionId, workspaceIdOverride); @@ -4055,6 +4327,97 @@ export function AgentChatWorkspace({ upsertTaskCenterOpenTab, ], ); + const openTaskCenterDraftTab = useCallback(() => { + const now = new Date(); + const draftTab: TaskCenterDraftTab = { + id: createTaskCenterDraftTabId(), + title: "新对话", + createdAt: now, + updatedAt: now, + status: "draft", + }; + + startTransition(() => { + setTaskCenterTransitionTopicId(null); + setTaskCenterDetachedTopicId(null); + setActiveTaskCenterDraftTabId(draftTab.id); + setTaskCenterDraftTabs((current) => + [draftTab, ...current.filter((item) => item.id !== draftTab.id)].slice( + 0, + MAX_TASK_CENTER_OPEN_TABS, + ), + ); + resetTopicLocalState(); + setInput(""); + setSelectedText(""); + setMentionedCharacters([]); + }); + + logAgentDebug("AgentChatPage", "taskCenter.draftTab.open", { + draftTabId: draftTab.id, + workspaceId: taskCenterWorkspaceId, + }); + + return draftTab.id; + }, [ + resetTopicLocalState, + setInput, + setMentionedCharacters, + setSelectedText, + taskCenterWorkspaceId, + ]); + const materializeTaskCenterDraftTab = useCallback( + async (draftTabId: string): Promise => { + const draftExists = taskCenterDraftTabsRef.current.some( + (tab) => tab.id === draftTabId, + ); + if (!draftExists) { + return null; + } + + logAgentDebug("AgentChatPage", "taskCenter.draftTab.materialize.start", { + draftTabId, + workspaceId: taskCenterWorkspaceId, + }); + const newSessionId = await createFreshSession("新对话"); + if (!newSessionId) { + setTaskCenterDraftTabs((current) => + current.map((tab) => + tab.id === draftTabId + ? { ...tab, status: "failed", updatedAt: new Date() } + : tab, + ), + ); + return null; + } + + startTransition(() => { + setTaskCenterDraftTabs((current) => + current.filter((tab) => tab.id !== draftTabId), + ); + setActiveTaskCenterDraftTabId((current) => + current === draftTabId ? null : current, + ); + finalizeFreshTaskCenterConversation( + newSessionId, + taskCenterWorkspaceId, + { preserveInput: true }, + ); + }); + + logAgentDebug("AgentChatPage", "taskCenter.draftTab.materialize.done", { + draftTabId, + newSessionId, + workspaceId: taskCenterWorkspaceId, + }); + return newSessionId; + }, + [ + createFreshSession, + finalizeFreshTaskCenterConversation, + taskCenterWorkspaceId, + ], + ); const handleOpenTaskTopic = useCallback( async ( @@ -4080,14 +4443,33 @@ export function AgentChatWorkspace({ ...(shouldResume ? { resumeSessionStartHooks: true } : {}), } : undefined; + const wasOpenInTaskCenter = + taskCenterOpenTabIdsRef.current.includes(topicId); setTaskCenterTransitionTopicId(topicId); setTaskCenterDetachedTopicId(null); + setActiveTaskCenterDraftTabId(null); if (options?.replaceOpenTabs === true) { replaceTaskCenterOpenTabs(topicId, topicWorkspaceId); + } else if (agentEntry === "claw") { + upsertTaskCenterOpenTab(topicId, topicWorkspaceId); } + markTaskCenterLocalSessionOverride(topicId); const switchResult = await switchTopic(topicId, switchOptions); if (switchResult !== "success" && switchResult !== "deferred") { + if (!wasOpenInTaskCenter && options?.replaceOpenTabs !== true) { + setTaskCenterOpenTabMap((currentMap) => + updateTaskCenterTabIdsForWorkspace( + currentMap, + topicWorkspaceId, + (currentIds) => + currentIds.filter((currentId) => currentId !== topicId), + ), + ); + } + setTaskCenterLocalSessionOverride((current) => + current?.sessionId === topicId ? null : current, + ); setTaskCenterTransitionTopicId((current) => current === topicId ? null : current, ); @@ -4100,12 +4482,12 @@ export function AgentChatWorkspace({ upsertTaskCenterOpenTab(topicId, topicWorkspaceId); } } - markTaskCenterLocalSessionOverride(topicId); }, [ agentEntry, markTaskCenterLocalSessionOverride, replaceTaskCenterOpenTabs, + setActiveTaskCenterDraftTabId, switchTopic, taskCenterWorkspaceId, topics, @@ -4117,16 +4499,20 @@ export function AgentChatWorkspace({ const handleOpenArchivedTaskTopic = useCallback( async (topicId: string) => { + setActiveTaskCenterDraftTabId(null); setTaskCenterDetachedTopicId(topicId); setTaskCenterTransitionTopicId(topicId); + markTaskCenterLocalSessionOverride(topicId); const switchResult = await switchTopic(topicId, { allowDetachedSession: true, }); if (switchResult === "success" || switchResult === "deferred") { - markTaskCenterLocalSessionOverride(topicId); return; } + setTaskCenterLocalSessionOverride((current) => + current?.sessionId === topicId ? null : current, + ); setTaskCenterTransitionTopicId((current) => current === topicId ? null : current, ); @@ -4137,18 +4523,43 @@ export function AgentChatWorkspace({ [markTaskCenterLocalSessionOverride, switchTopic], ); + const handleSelectTaskCenterDraftTab = useCallback( + (draftTabId: string) => { + const draft = taskCenterDraftTabsRef.current.find( + (tab) => tab.id === draftTabId, + ); + if (!draft) { + return; + } + + startTransition(() => { + setTaskCenterTransitionTopicId(null); + setTaskCenterDetachedTopicId(null); + setActiveTaskCenterDraftTabId(draftTabId); + resetTopicLocalState(); + setInput(""); + setSelectedText(""); + setMentionedCharacters([]); + }); + }, + [resetTopicLocalState, setInput, setMentionedCharacters, setSelectedText], + ); + const handleSwitchTaskTopic = useCallback( async (topicId: string) => { + if (isTaskCenterDraftTabId(topicId)) { + handleSelectTaskCenterDraftTab(topicId); + return; + } + await handleOpenTaskTopic(topicId); }, - [handleOpenTaskTopic], + [handleOpenTaskTopic, handleSelectTaskCenterDraftTab], ); const handleOpenSidebarTaskTopic = useCallback( async (topicId: string) => { - await handleOpenTaskTopic(topicId, { - replaceOpenTabs: true, - }); + await handleOpenTaskTopic(topicId); }, [handleOpenTaskTopic], ); @@ -4158,7 +4569,6 @@ export function AgentChatWorkspace({ await handleOpenTaskTopic(topicId, { preferResume: true, forceRefresh: statusReason === "workspace_error", - replaceOpenTabs: true, }); }, [handleOpenTaskTopic], @@ -4183,11 +4593,32 @@ export function AgentChatWorkspace({ void handleOpenTaskTopic(recentSessionTopic.id, { preferResume: true, forceRefresh: recentSessionTopic.statusReason === "workspace_error", - replaceOpenTabs: true, }); }, [handleOpenTaskTopic, recentSessionTopic]); const handleCloseTaskCenterTab = useCallback( async (topicId: string) => { + if (isTaskCenterDraftTabId(topicId)) { + const remainingDraftTabs = taskCenterDraftTabsRef.current.filter( + (tab) => tab.id !== topicId, + ); + setTaskCenterDraftTabs(remainingDraftTabs); + if (activeTaskCenterDraftTabIdRef.current === topicId) { + const fallbackDraftId = remainingDraftTabs[0]?.id ?? null; + if (fallbackDraftId) { + handleSelectTaskCenterDraftTab(fallbackDraftId); + return; + } + + setActiveTaskCenterDraftTabId(null); + setInput(""); + const fallbackTopicId = taskCenterOpenTabIdsRef.current[0] ?? null; + if (fallbackTopicId) { + await handleSwitchTaskTopic(fallbackTopicId); + } + } + return; + } + const currentIds = taskCenterOpenTabIdsRef.current; const currentIndex = currentIds.indexOf(topicId); const remainingIds = currentIds.filter((item) => item !== topicId); @@ -4219,48 +4650,54 @@ export function AgentChatWorkspace({ if (fallbackId) { await handleSwitchTaskTopic(fallbackId); } else { - const newSessionId = await createFreshSession(); - if (newSessionId) { - finalizeFreshTaskCenterConversation( - newSessionId, - taskCenterWorkspaceId, - ); - } else { - upsertTaskCenterOpenTab(topicId, taskCenterWorkspaceId); - } + openTaskCenterDraftTab(); } } }, [ clearTaskCenterEmbeddedHomeSession, - createFreshSession, - finalizeFreshTaskCenterConversation, + handleSelectTaskCenterDraftTab, handleSwitchTaskTopic, + openTaskCenterDraftTab, sessionId, + setInput, taskCenterDetachedTopicId, taskCenterTransitionTopicId, taskCenterWorkspaceId, - upsertTaskCenterOpenTab, ], ); const handleOpenTaskCenterNewTaskPage = useCallback(() => { - setTaskCenterTransitionTopicId(null); - setTaskCenterDetachedTopicId(null); + if (agentEntry !== "claw") { + handleBackHome?.(); + return; + } - void (async () => { - const newSessionId = await createFreshSession(); - if (newSessionId) { - finalizeFreshTaskCenterConversation( - newSessionId, - taskCenterWorkspaceId, - ); - } - })(); - }, [ - createFreshSession, - finalizeFreshTaskCenterConversation, - taskCenterWorkspaceId, - ]); + openTaskCenterDraftTab(); + }, [agentEntry, handleBackHome, openTaskCenterDraftTab]); + useEffect(() => { + if (agentEntry !== "claw") { + return; + } + + return subscribeTaskCenterDraftTaskRequests(() => { + handleOpenTaskCenterNewTaskPage(); + }); + }, [agentEntry, handleOpenTaskCenterNewTaskPage]); + useEffect(() => { + if (agentEntry !== "claw") { + return; + } + + return subscribeTaskCenterTaskOpenRequests( + ({ sessionId: requestedSessionId, workspaceId }) => { + setTaskCenterTransitionTopicId(requestedSessionId); + setTaskCenterDetachedTopicId(null); + setActiveTaskCenterDraftTabId(null); + markTaskCenterLocalSessionOverride(requestedSessionId); + upsertTaskCenterOpenTab(requestedSessionId, workspaceId); + }, + ); + }, [agentEntry, markTaskCenterLocalSessionOverride, upsertTaskCenterOpenTab]); const taskCenterPreviewTopicId = useMemo( () => resolveTaskCenterPreviewTopicId({ @@ -4279,17 +4716,18 @@ export function AgentChatWorkspace({ [sessionId, taskCenterTransitionTopicId], ); const hasHomeConversationActivity = - hasDisplayMessages || - hasPendingA2UIForm || - isPreparingSend || - isSending || - queuedTurns.length > 0; + !isTaskCenterDraftTabActive && + (hasDisplayMessages || + hasPendingA2UIForm || + isPreparingSend || + isSending || + queuedTurns.length > 0); const shouldRenderTaskCenterEmbeddedHome = Boolean( agentEntry === "claw" && - sessionId && !taskCenterSessionSwitchPending && !hasHomeConversationActivity && - taskCenterEmbeddedHomeSessionIds.has(sessionId), + (isTaskCenterDraftTabActive || + (sessionId && taskCenterEmbeddedHomeSessionIds.has(sessionId))), ); useEffect(() => { if (!sessionId || !taskCenterEmbeddedHomeSessionIds.has(sessionId)) { @@ -4310,10 +4748,7 @@ export function AgentChatWorkspace({ shouldRenderTaskCenterEmbeddedHome; useEffect(() => { - if ( - !suppressHomeNavbarUtilityActions || - !harnessPanelVisible - ) { + if (!suppressHomeNavbarUtilityActions || !harnessPanelVisible) { return; } @@ -4354,26 +4789,44 @@ export function AgentChatWorkspace({ topics, ], ); - const taskCenterTabItems = useMemo( - () => - taskCenterVisibleTabIds - .map((topicId) => topics.find((topic) => topic.id === topicId)) - .filter((topic): topic is NonNullable => Boolean(topic)) - .map((topic) => ({ - id: topic.id, - title: - resolveInternalImageTaskDisplayName(topic.title) || "未命名任务", - status: topic.status ?? "done", - updatedAt: - topic.updatedAt instanceof Date - ? topic.updatedAt - : new Date(topic.updatedAt ?? topic.createdAt ?? Date.now()), - isActive: topic.id === (taskCenterPreviewTopicId ?? sessionId), - hasUnread: Boolean(topic.hasUnread), - isPinned: Boolean(topic.isPinned), - })), - [sessionId, taskCenterPreviewTopicId, taskCenterVisibleTabIds, topics], - ); + const taskCenterTabItems = useMemo(() => { + const draftItems = taskCenterDraftTabs.map((draft) => ({ + id: draft.id, + title: draft.title, + status: draft.status, + updatedAt: draft.updatedAt, + isActive: draft.id === activeTaskCenterDraftTabId, + hasUnread: false, + isPinned: false, + })); + const topicItems = taskCenterVisibleTabIds + .map((topicId) => topics.find((topic) => topic.id === topicId)) + .filter((topic): topic is NonNullable => Boolean(topic)) + .map((topic) => ({ + id: topic.id, + title: resolveInternalImageTaskDisplayName(topic.title) || "未命名任务", + status: topic.status ?? "done", + updatedAt: + topic.updatedAt instanceof Date + ? topic.updatedAt + : new Date(topic.updatedAt ?? topic.createdAt ?? Date.now()), + isActive: + !isTaskCenterDraftTabActive && + topic.id === (taskCenterPreviewTopicId ?? sessionId), + hasUnread: Boolean(topic.hasUnread), + isPinned: Boolean(topic.isPinned), + })); + + return [...draftItems, ...topicItems].slice(0, MAX_TASK_CENTER_OPEN_TABS); + }, [ + activeTaskCenterDraftTabId, + isTaskCenterDraftTabActive, + sessionId, + taskCenterDraftTabs, + taskCenterPreviewTopicId, + taskCenterVisibleTabIds, + topics, + ]); useEffect(() => { if ( agentEntry !== "claw" || @@ -4445,9 +4898,7 @@ export function AgentChatWorkspace({ visibleTabIds: taskCenterVisibleTabIds, }); - void handleOpenTaskTopic(fallbackId, { - replaceOpenTabs: true, - }); + void handleOpenTaskTopic(fallbackId); }, [ agentEntry, handleOpenTaskTopic, @@ -6559,6 +7010,28 @@ export function AgentChatWorkspace({ images?: MessageImage[], sendOptions?: HandleSendOptions, ) => { + const activeDraftTabId = activeTaskCenterDraftTabIdRef.current; + if (agentEntry === "claw" && activeDraftTabId) { + void (async () => { + const materializedSessionId = + await materializeTaskCenterDraftTab(activeDraftTabId); + if (!materializedSessionId) { + return; + } + + await handleSendRef.current( + images || [], + effectiveChatToolPreferences.webSearch, + effectiveChatToolPreferences.thinking, + text, + sendExecutionStrategy, + undefined, + sendOptions, + ); + })(); + return; + } + void handleSend( images || [], effectiveChatToolPreferences.webSearch, @@ -6570,34 +7043,53 @@ export function AgentChatWorkspace({ ); }, [ + agentEntry, effectiveChatToolPreferences.thinking, effectiveChatToolPreferences.webSearch, handleSend, + handleSendRef, + materializeTaskCenterDraftTab, ], ); - const sceneDisplayMessages = taskCenterSessionSwitchPending - ? [] - : displayMessages; - const sceneTurns = taskCenterSessionSwitchPending ? [] : turns; - const sceneThreadItems = taskCenterSessionSwitchPending - ? [] - : effectiveThreadItems; - const sceneCurrentTurnId = taskCenterSessionSwitchPending - ? null - : currentTurnId; - const sceneThreadRead = taskCenterSessionSwitchPending ? null : threadRead; - const scenePendingActions = taskCenterSessionSwitchPending - ? [] - : pendingActions; - const sceneSubmittedActionsInFlight = taskCenterSessionSwitchPending - ? [] - : submittedActionsInFlight; - const sceneQueuedTurns = taskCenterSessionSwitchPending ? [] : queuedTurns; - const sceneIsPreparingSend = taskCenterSessionSwitchPending - ? false - : isPreparingSend; - const sceneIsSending = taskCenterSessionSwitchPending ? false : isSending; + const sceneDisplayMessages = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? [] + : displayMessages; + const sceneTurns = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive ? [] : turns; + const sceneThreadItems = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? [] + : effectiveThreadItems; + const sceneCurrentTurnId = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? null + : currentTurnId; + const sceneThreadRead = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? null + : threadRead; + const scenePendingActions = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? [] + : pendingActions; + const sceneSubmittedActionsInFlight = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? [] + : submittedActionsInFlight; + const sceneQueuedTurns = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? [] + : queuedTurns; + const sceneIsPreparingSend = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? false + : isPreparingSend; + const sceneIsSending = + taskCenterSessionSwitchPending || isTaskCenterDraftTabActive + ? false + : isSending; const conversationSceneRuntime = useWorkspaceConversationSceneRuntime({ messageListEmptyStateVariant: @@ -6692,8 +7184,7 @@ export function AgentChatWorkspace({ fromResources, handleBackHome, handleToggleSidebar, - showHarnessToggle: - !suppressHomeNavbarUtilityActions && showHarnessToggle, + showHarnessToggle: !suppressHomeNavbarUtilityActions && showHarnessToggle, navbarHarnessPanelVisible: !suppressHomeNavbarUtilityActions && navbarHarnessPanelVisible, harnessPendingCount: suppressHomeNavbarUtilityActions @@ -6709,7 +7200,7 @@ export function AgentChatWorkspace({ isAutoRestoringSession || isSessionHydrating || taskCenterSessionSwitchPending, - sessionId, + sessionId: isTaskCenterDraftTabActive ? null : sessionId, syncStatus, pendingA2UIForm: effectivePendingA2UIForm, pendingA2UISource: effectivePendingA2UISource, @@ -6795,7 +7286,7 @@ export function AgentChatWorkspace({ } onExpandGeneralWorkbenchSidebar={handleExpandGeneralWorkbenchSidebar} mainAreaNode={conversationSceneRuntime.mainAreaNode} - currentTopicId={sessionId ?? null} + currentTopicId={activeTaskCenterDraftTabId ?? sessionId ?? null} topics={topics} onNewChat={shellHandleBackHome} onOpenTaskCenterHome={handleOpenTaskCenterNewTaskPage} diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index cb2a9fa77..6091774c4 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -26,6 +26,17 @@ const { mockGetConfig, mockProjectSelector } = vi.hoisted(() => ({ mockProjectSelector: vi.fn(), })); +const mockGetSkillCatalog = vi.hoisted(() => + vi.fn(async () => ({ + version: "test", + tenantId: "tenant-0001", + syncedAt: "2026-04-30T00:00:00Z", + groups: [], + items: [], + entries: [], + })), +); + const { mockGatewayChannelStatus, mockGetBrowserConnectorSettings, @@ -77,6 +88,16 @@ vi.mock("@/lib/api/unifiedMemory", () => ({ listUnifiedMemories: mockListUnifiedMemories, })); +vi.mock("@/lib/api/skillCatalog", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSkillCatalog: mockGetSkillCatalog, + listSkillCatalogSceneEntries: () => [], + subscribeSkillCatalogChanged: () => () => undefined, + }; +}); + vi.mock("@/lib/webview-api", () => ({ getBrowserConnectorSettings: mockGetBrowserConnectorSettings, getChromeBridgeStatus: mockGetChromeBridgeStatus, @@ -353,6 +374,29 @@ function findLauncherConfirmButton() { ); } +async function openHomeMoreSkillsDrawer( + container: HTMLElement, +): Promise { + const trigger = container.querySelector( + '[data-testid="home-more-skills-trigger"]', + ) as HTMLButtonElement | null; + expect(trigger).toBeTruthy(); + + await act(async () => { + trigger?.click(); + await Promise.resolve(); + }); + + const drawer = container.querySelector( + '[data-testid="home-more-skills-drawer"]', + ) as HTMLElement | null; + expect(drawer).toBeTruthy(); + if (!drawer) { + throw new Error("home more skills drawer should be visible"); + } + return drawer; +} + function createGithubSearchServiceSkill(): ServiceSkillHomeItem { return { id: "github-repo-radar", @@ -465,7 +509,7 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("新建任务"); }); - it("通用首页应展示更简洁的 Agent 引导式结果入口", async () => { + it("通用首页应展示轻量起手入口与下滑第二屏", async () => { const container = renderEmptyState({ activeTheme: "general", onLaunchBrowserAssist: vi.fn(), @@ -476,16 +520,29 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("先开始这一轮"); - expect(container.textContent).toContain( - "先选一个结果起手;后面的补参考、改方向和续做,都留在这一轮里继续推进。", - ); - expect(container.textContent).toContain("先从这个开始"); - expect(container.textContent).toContain("其他起手结果"); - expect(container.textContent).toContain("也可以直接按做法开工"); - expect(container.textContent).toContain( - "本轮产出会先写回当前任务,后续继续也会优先从这里接回。", - ); + expect( + container.querySelector('[data-testid="home-start-surface"]'), + ).toBeTruthy(); + expect( + container.querySelector('[data-testid="home-starter-chips"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("帮我想选题"); + expect(container.textContent).toContain("写第一版"); + expect(container.textContent).toContain("拆解爆款"); + expect(container.textContent).toContain("改成我的风格"); + expect(container.textContent).toContain("转成视频脚本"); + expect(container.textContent).toContain("复盘账号"); + expect(container.textContent).toContain("更多做法"); + expect( + container.querySelector('[data-testid="home-scroll-cue"]'), + ).toBeTruthy(); + expect( + container.querySelector('[data-testid="home-second-screen"]'), + ).toBeTruthy(); + expect( + container.querySelector('[data-testid="home-skill-gallery"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("你可以从这些任务开始"); expect(container.textContent).toContain("每日趋势摘要"); expect(container.textContent).toContain("内容主稿生成"); expect(container.textContent).toContain("拆解一条爆款内容"); @@ -494,6 +551,9 @@ describe("EmptyState", () => { expect(container.textContent).toContain("复盘这个账号/项目"); expect(container.textContent).toContain("复制轮播帖"); expect(container.textContent).toContain("复制视频脚本"); + expect(container.textContent).not.toContain("先开始这一轮"); + expect(container.textContent).not.toContain("其他起手结果"); + expect(container.textContent).not.toContain("也可以直接按做法开工"); expect(container.textContent).not.toContain("首选结果"); expect(container.textContent).not.toContain("更多结果"); expect(container.textContent).not.toContain("快捷做法"); @@ -503,30 +563,25 @@ describe("EmptyState", () => { const recommendationItems = Array.from( container.querySelectorAll( - '[data-testid^="entry-recommended-"], [data-testid^="entry-service-skill-"]', + '[data-testid^="home-gallery-entry-recommended-"], [data-testid^="home-gallery-entry-service-skill-"]', ), ).map((element) => element.textContent?.trim() ?? ""); - expect(recommendationItems).toHaveLength(8); - expect(recommendationItems[0]).toContain("每日趋势摘要"); - expect(recommendationItems[0]).toContain("先从这个开始"); - expect(recommendationItems[0]).toContain("趋势摘要、3 个优先选题"); - expect(recommendationItems[0]).toContain( - "趋势摘要会先写回当前内容,方便继续展开选题和主稿。", - ); - expect(recommendationItems[1]).toContain("内容主稿生成"); - expect(recommendationItems[2]).toContain("拆解一条爆款内容"); - expect(recommendationItems[3]).toContain("长文转多平台发布稿"); - expect(recommendationItems[4]).toContain("脚本转口播/字幕稿"); - expect(recommendationItems[5]).toContain("复盘这个账号/项目"); - expect(recommendationItems[6]).toContain("复制轮播帖"); - expect(recommendationItems[7]).toContain("复制视频脚本"); + expect(recommendationItems.length).toBeGreaterThanOrEqual(8); + expect(recommendationItems.join(" ")).toContain("每日趋势摘要"); + expect(recommendationItems.join(" ")).toContain("内容主稿生成"); + expect(recommendationItems.join(" ")).toContain("拆解一条爆款内容"); + expect(recommendationItems.join(" ")).toContain("长文转多平台发布稿"); + expect(recommendationItems.join(" ")).toContain("脚本转口播/字幕稿"); + expect(recommendationItems.join(" ")).toContain("复盘这个账号/项目"); + expect(recommendationItems.join(" ")).toContain("复制轮播帖"); + expect(recommendationItems.join(" ")).toContain("复制视频脚本"); expect( container.querySelector('[data-testid^="entry-continuation-"]'), ).toBeNull(); }); - it("保存判断结论后首页应围绕最近判断重排结果模板", async () => { + it("保存判断结论后不应打乱首屏固定起手入口", async () => { const projectId = "project-review-recommendation"; const container = renderEmptyState({ activeTheme: "general", @@ -543,6 +598,9 @@ describe("EmptyState", () => { expect( container.querySelector('[data-testid="entry-review-feedback-banner"]'), ).toBeNull(); + const initialStarterItems = Array.from( + container.querySelectorAll('[data-testid^="entry-recommended-"]'), + ).map((element) => element.getAttribute("data-testid")); await act(async () => { recordCuratedTaskRecommendationSignalFromReviewDecision( @@ -564,55 +622,15 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const recommendationItems = Array.from( + const starterItems = Array.from( container.querySelectorAll('[data-testid^="entry-recommended-"]'), ).map((element) => element.getAttribute("data-testid")); - expect(recommendationItems[0]).toBe( - "entry-recommended-account-project-review", - ); - expect(recommendationItems[1]).toBe( - "entry-recommended-viral-content-breakdown", - ); - - const reviewCard = container.querySelector( - '[data-testid="entry-recommended-account-project-review"]', - ); - const breakdownCard = container.querySelector( - '[data-testid="entry-recommended-viral-content-breakdown"]', - ); - const reviewBanner = container.querySelector( - '[data-testid="entry-review-feedback-banner"]', - ); - - expect(reviewCard?.textContent).toContain("复盘这个账号/项目"); - expect(reviewCard?.textContent).toContain("判断:短视频编排"); - expect(breakdownCard?.textContent).toContain("拆解一条爆款内容"); - expect(breakdownCard?.textContent).toContain("判断:短视频编排"); - expect(reviewBanner?.textContent).toContain("最近判断已更新"); - expect(reviewBanner?.textContent).toContain("短视频编排 · 补证据"); - expect(reviewBanner?.textContent).toContain("这轮结果还缺证据"); - expect(reviewBanner?.textContent).toContain( - "这轮判断更建议优先回到「复盘这个账号/项目」或「拆解一条爆款内容」", - ); - expect(reviewBanner?.textContent).toContain( - "更适合继续:复盘这个账号/项目 / 拆解一条爆款内容", - ); - - const bannerAction = container.querySelector( - '[data-testid="entry-review-feedback-banner-action"]', - ) as HTMLButtonElement | null; - expect(bannerAction?.textContent).toContain("继续去「复盘这个账号/项目」"); - - await act(async () => { - bannerAction?.click(); - await Promise.resolve(); - }); - - expect(document.body.textContent).toContain( - "开始这一步前,我先确认几件事。", - ); - expect(document.body.textContent).toContain("复盘这个账号/项目"); + expect(starterItems).toEqual(initialStarterItems); + expect(starterItems[0]).toBe("entry-recommended-daily-trend-briefing"); + expect( + container.querySelector('[data-testid="entry-review-feedback-banner"]'), + ).toBeNull(); }); it("无浏览器入口且无可续接动作时,不应默认渲染补充续接条", async () => { @@ -700,23 +718,17 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("也可以直接按做法开工"); - - const trendCard = container.querySelector( - '[data-testid="entry-service-skill-daily-trend-briefing"]', - ); - expect(trendCard?.textContent).toContain("每日趋势摘要"); - expect(trendCard?.textContent).not.toContain("云目录"); - expect(trendCard?.textContent).not.toContain("最近使用"); - expect(trendCard?.textContent).toContain("需要:当前无必填信息"); - expect(trendCard?.textContent).toContain("交付:趋势摘要 + 调度建议"); + expect(container.textContent).toContain("每日趋势摘要"); + expect(container.textContent).not.toContain("云目录"); + expect(container.textContent).not.toContain("最近使用"); const projectCard = container.querySelector( - '[data-testid="entry-service-skill-project-insight-flow"]', + '[data-testid="home-gallery-entry-service-skill-project-insight-flow"]', ); expect(projectCard?.textContent).toContain("项目线索整理"); - expect(projectCard?.textContent).toContain("需要:项目目标"); - expect(projectCard?.textContent).toContain("交付:线索清单 + 下一步建议"); + expect(projectCard?.textContent).toContain( + "围绕当前项目整理线索、结论和下一步动作。", + ); }); it("runtime tool surface 告警不应再透传到首页空态输入区", async () => { @@ -742,7 +754,7 @@ describe("EmptyState", () => { ); }); - it("有最近记录时应展示继续上次做法入口,并允许直接继续模板与方法", async () => { + it("有最近记录时应在更多做法抽屉里展示最近入口,并允许继续模板与方法", async () => { recordCuratedTaskTemplateUsage({ templateId: "social-post-starter", launchInputValues: { @@ -784,38 +796,25 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("继续上次做法"); + const drawer = await openHomeMoreSkillsDrawer(container); + expect(drawer.textContent).toContain("最近使用"); const continuationItems = Array.from( - container.querySelectorAll('[data-testid^="entry-continuation-"]'), + drawer.querySelectorAll('[data-testid^="home-drawer-"]'), ).map((element) => element.getAttribute("data-testid")); - expect(continuationItems).toEqual([ - "entry-continuation-solution-social-post-starter", - "entry-continuation-method-content-iteration-flow", - ]); + expect(continuationItems).toContain( + "home-drawer-entry-recommended-social-post-starter", + ); + expect(continuationItems).toContain( + "home-drawer-entry-service-skill-content-iteration-flow", + ); - const recentTemplateButton = container.querySelector( - '[data-testid="entry-continuation-solution-social-post-starter"]', + const recentTemplateButton = drawer.querySelector( + '[data-testid="home-drawer-entry-recommended-social-post-starter"]', ) as HTMLButtonElement | null; expect(recentTemplateButton).toBeTruthy(); expect(recentTemplateButton?.textContent).toContain("内容主稿生成"); - expect(recentTemplateButton?.textContent).toContain( - "上次填写:主题或产品信息=上次沉淀的主线升级;目标受众=品牌内容负责人", - ); - expect(recentTemplateButton?.textContent).toContain("参考:上次主稿参考"); - expect(recentTemplateButton?.textContent).toContain( - "需要:主题或产品信息、目标受众", - ); - expect(recentTemplateButton?.textContent).toContain( - "交付:内容首稿、结构提纲 等 3 项", - ); - expect(recentTemplateButton?.textContent).toContain( - "去向:首版主稿会先进入当前内容,方便继续改写、拆成多平台版本。", - ); - expect(recentTemplateButton?.textContent).toContain( - "下一步:改成多平台版本", - ); await act(async () => { recentTemplateButton?.click(); @@ -823,9 +822,6 @@ describe("EmptyState", () => { }); expect(container.textContent).toContain("开始这一步前,我先确认几件事。"); - expect(container.textContent).toContain( - "已根据你上次启动 内容主稿生成 时的参数自动预填,可继续修改后进入生成。", - ); expect(setInput).not.toHaveBeenCalled(); const subjectInput = document.body.querySelector( @@ -875,15 +871,11 @@ describe("EmptyState", () => { }), ); - const recentMethodButton = container.querySelector( - '[data-testid="entry-continuation-method-content-iteration-flow"]', + const recentMethodButton = drawer.querySelector( + '[data-testid="home-drawer-entry-service-skill-content-iteration-flow"]', ) as HTMLButtonElement | null; expect(recentMethodButton).toBeTruthy(); expect(recentMethodButton?.textContent).toContain("内容迭代整理"); - expect(recentMethodButton?.textContent).toContain("需要:当前无必填信息"); - expect(recentMethodButton?.textContent).toContain( - "交付:线索清单 + 下一步建议", - ); act(() => { recentMethodButton?.click(); @@ -897,7 +889,7 @@ describe("EmptyState", () => { ); }); - it("scene 最近使用记录也应把对应做法带回首页继续层", async () => { + it("scene 最近使用记录也应把对应做法带回更多做法抽屉", async () => { recordSlashEntryUsage({ kind: "scene", entryId: "project-insight-flow", @@ -915,9 +907,10 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("继续上次做法"); - const sceneContinuationButton = container.querySelector( - '[data-testid="entry-continuation-method-project-insight-flow"]', + const drawer = await openHomeMoreSkillsDrawer(container); + expect(drawer.textContent).toContain("最近使用"); + const sceneContinuationButton = drawer.querySelector( + '[data-testid="home-drawer-entry-service-skill-project-insight-flow"]', ) as HTMLButtonElement | null; expect(sceneContinuationButton).toBeTruthy(); @@ -933,7 +926,7 @@ describe("EmptyState", () => { ); }); - it("最近使用的本地已安装 skill 也应回到首页继续层,并恢复上次目标", async () => { + it("最近使用的本地已安装 skill 也应回到更多做法抽屉,并恢复上次目标", async () => { recordSlashEntryUsage({ kind: "skill", entryId: "content-playbook", @@ -965,25 +958,15 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("继续上次做法"); + const drawer = await openHomeMoreSkillsDrawer(container); + expect(drawer.textContent).toContain("最近使用"); - const installedSkillButton = container.querySelector( - '[data-testid="entry-continuation-method-content-playbook"]', + const installedSkillButton = drawer.querySelector( + '[data-testid="home-drawer-entry-installed-skill-content-playbook"]', ) as HTMLButtonElement | null; expect(installedSkillButton).toBeTruthy(); expect(installedSkillButton?.textContent).toContain("内容主稿方法"); - expect(installedSkillButton?.textContent).toContain( - "上次目标:继续优化这套内容主稿", - ); - expect(installedSkillButton?.textContent).toContain( - "当你需要继续复用这套内容主稿方法时使用。", - ); - expect(installedSkillButton?.textContent).toContain( - "需要:主题、受众与复盘约束", - ); - expect(installedSkillButton?.textContent).toContain( - "交付:带着这套内容主稿方法进入生成", - ); + expect(installedSkillButton?.textContent).toContain("继续优化这套内容主稿"); await act(async () => { installedSkillButton?.click(); @@ -994,7 +977,7 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("已挂载 内容主稿方法"); }); - it("同页内新增本地 skill 使用记录后,首页继续上次做法应即时刷新", async () => { + it("同页内新增本地 skill 使用记录后,更多做法抽屉应即时刷新", async () => { const installedSkill: Skill = { key: "content-playbook", name: "内容主稿方法", @@ -1015,7 +998,7 @@ describe("EmptyState", () => { expect( container.querySelector( - '[data-testid="entry-continuation-method-content-playbook"]', + '[data-testid="home-drawer-entry-installed-skill-content-playbook"]', ), ).toBeNull(); @@ -1029,15 +1012,14 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const installedSkillButton = container.querySelector( - '[data-testid="entry-continuation-method-content-playbook"]', + const drawer = await openHomeMoreSkillsDrawer(container); + const installedSkillButton = drawer.querySelector( + '[data-testid="home-drawer-entry-installed-skill-content-playbook"]', ) as HTMLButtonElement | null; expect(installedSkillButton).toBeTruthy(); - expect(container.textContent).toContain("继续上次做法"); + expect(drawer.textContent).toContain("最近使用"); expect(installedSkillButton?.textContent).toContain("内容主稿方法"); - expect(installedSkillButton?.textContent).toContain( - "上次目标:继续完善这套内容方法", - ); + expect(installedSkillButton?.textContent).toContain("继续完善这套内容方法"); }); it("最近使用的 service skill 也应显影上次补过的关键信息", async () => { @@ -1054,6 +1036,8 @@ describe("EmptyState", () => { const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); const recentMethod: ServiceSkillHomeItem = { ...createGithubSearchServiceSkill(), + defaultExecutorBinding: "agent_turn", + siteCapabilityBinding: undefined, recentUsedAt: 1_900_000_000_000, isRecent: true, }; @@ -1068,16 +1052,13 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const recentMethodButton = container.querySelector( - '[data-testid="entry-continuation-method-github-repo-radar"]', + const drawer = await openHomeMoreSkillsDrawer(container); + const recentMethodButton = drawer.querySelector( + '[data-testid="home-drawer-entry-service-skill-github-repo-radar"]', ) as HTMLButtonElement | null; expect(recentMethodButton).toBeTruthy(); expect(recentMethodButton?.textContent).toContain( - "上次填写:检索主题=AI Agent 监控", - ); - expect(recentMethodButton?.textContent).toContain("需要:检索主题"); - expect(recentMethodButton?.textContent).toContain( - "交付:仓库列表 + 关键线索", + "复用 GitHub 登录态检索项目。", ); }); @@ -1095,13 +1076,15 @@ describe("EmptyState", () => { container.querySelector('[data-testid="project-selector-stub"]'), ).toBeNull(); expect(mockProjectSelector).not.toHaveBeenCalled(); - expect(container.textContent).toContain("本轮产出会沉淀到当前项目"); + expect( + container.querySelector('[data-testid="home-start-surface"]'), + ).toBeTruthy(); expect( container.querySelector('[data-testid^="entry-project-continuation-"]'), ).toBeNull(); }); - it("通用首页应把补充入口收成直接续接条,不再展开支撑能力说明", async () => { + it("通用首页应把补充入口收成轻量按钮,不再展开支撑能力说明", async () => { const container = renderEmptyState({ activeTheme: "general", onLaunchBrowserAssist: vi.fn(), @@ -1112,14 +1095,15 @@ describe("EmptyState", () => { }); expect( - container.querySelector('[data-testid="entry-supplemental-panel"]'), + container.querySelector('[data-testid="home-supplemental-actions"]'), ).toBeTruthy(); - expect(container.textContent).toContain( - "需要网页登录时,也可以先把浏览器接上。", - ); expect( container.querySelector('[data-testid="entry-connect-browser"]'), ).toBeTruthy(); + expect(container.textContent).toContain("连接浏览器"); + expect(container.textContent).not.toContain( + "需要网页登录时,也可以先把浏览器接上。", + ); expect(container.textContent).not.toContain("查看支撑能力"); expect(container.textContent).not.toContain("收起支撑能力"); expect(container.textContent).not.toContain("重复任务可持续复用"); @@ -1489,7 +1473,7 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("GitHub 仓库线索检索"); const card = container.querySelector( - '[data-testid="entry-service-skill-short-video-script-replication"]', + '[data-testid="home-gallery-entry-service-skill-short-video-script-replication"]', ) as HTMLButtonElement | null; expect(card).toBeTruthy(); @@ -2071,9 +2055,9 @@ describe("EmptyState", () => { '[data-testid="entry-recommended-account-project-review"]', ) as HTMLButtonElement | null; expect(templateButton).toBeTruthy(); - expect(container.textContent).toContain("当前判断:先补复核与修复"); - expect(container.textContent).toContain("当前卡点:复核阻塞"); - expect(container.textContent).toContain("更适合去向:结果对齐"); + expect(container.textContent).not.toContain("当前判断:先补复核与修复"); + expect(container.textContent).not.toContain("当前卡点:复核阻塞"); + expect(container.textContent).not.toContain("更适合去向:结果对齐"); act(() => { templateButton?.click(); @@ -2108,7 +2092,7 @@ describe("EmptyState", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("首页下游结果模板卡片也应显影当前项目结果基线", async () => { + it("首页下游结果模板卡片不应把项目结果基线提前摊开", async () => { const container = renderEmptyState({ defaultCuratedTaskReferenceEntries: [ { @@ -2135,10 +2119,11 @@ describe("EmptyState", () => { ) as HTMLButtonElement | null; expect(templateButton).toBeTruthy(); - expect(templateButton?.textContent).toContain("当前结果基线:AI 内容周报"); - expect(templateButton?.textContent).toContain("当前判断:先补复核与修复"); - expect(templateButton?.textContent).toContain("当前卡点:复核阻塞"); - expect(templateButton?.textContent).toContain("更适合去向:结果对齐"); + expect(templateButton?.textContent).toContain("帮我想选题"); + expect(container.textContent).not.toContain("当前结果基线:AI 内容周报"); + expect(container.textContent).not.toContain("当前判断:先补复核与修复"); + expect(container.textContent).not.toContain("当前卡点:复核阻塞"); + expect(container.textContent).not.toContain("更适合去向:结果对齐"); }); it("当前带入参考灵感时,结果模板推荐应显式标记为围绕当前参考", async () => { @@ -2168,7 +2153,8 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("参考:品牌风格样本"); + expect(container.textContent).toContain("参考"); + expect(container.textContent).toContain("品牌风格样本"); expect(container.textContent).toContain("内容主稿生成"); }); @@ -2190,10 +2176,10 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("沿着当前上下文继续"); - expect(container.textContent).toContain("当前带入做法草稿"); - expect(container.textContent).toContain("这轮会先沿着这份做法草稿继续生成"); - expect(container.textContent).toContain("先沿着当前做法开工"); + expect(container.textContent).toContain("做法草稿"); + expect(container.textContent).toContain("账号复盘方法"); + expect(container.textContent).not.toContain("沿着当前上下文继续"); + expect(container.textContent).not.toContain("先沿着当前做法开工"); }); it("最近保存到灵感库的成果信号应影响首页结果模板推荐", async () => { @@ -2234,7 +2220,7 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("成果:账号复盘结论"); + expect(container.textContent).not.toContain("成果:账号复盘结论"); expect(container.textContent).toContain("复盘这个账号/项目"); }); @@ -2575,8 +2561,10 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("编排模式已开启"); expect(container.textContent).not.toContain("联网搜索已开启"); - expect(container.textContent).toContain("先开始这一轮"); - expect(container.textContent).toContain("先从这个开始"); + expect( + container.querySelector('[data-testid="home-start-surface"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("帮我想选题"); }); it("通用首页发送时不应自动注入任何历史默认 skill", async () => { @@ -2804,11 +2792,13 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("也可以直接续上这一轮。"); expect(container.textContent).toContain("短视频编排"); + expect( + container.querySelector('[data-testid="home-supplemental-actions"]'), + ).toBeTruthy(); expect( container.querySelector('[data-testid="sceneapps-home-directory"]'), - ).not.toBeNull(); + ).toBeNull(); const launchButton = container.querySelector( '[data-testid="sceneapp-launch-story-video-suite"]', diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 9733a8fbb..d1426d36b 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -1,9 +1,19 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import styled, { keyframes } from "styled-components"; -import { ArrowRight } from "lucide-react"; import { getConfig } from "@/lib/api/appConfig"; +import { + getSkillCatalog, + listSkillCatalogSceneEntries, + subscribeSkillCatalogChanged, + type SkillCatalogSceneEntry, +} from "@/lib/api/skillCatalog"; import type { CreationMode } from "./types"; -import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { buildRecommendationPrompt, @@ -11,12 +21,9 @@ import { isTeamRuntimeRecommendation, } from "../utils/contextualRecommendations"; import { - buildCuratedTaskRecentUsageDescription, - buildCuratedTaskCapabilityDescription, buildCuratedTaskLaunchPrompt, findCuratedTaskTemplateById, listCuratedTaskTemplates, - listFeaturedHomeCuratedTaskTemplates, recordCuratedTaskTemplateUsage, replaceCuratedTaskLaunchPromptInInput, resolveCuratedTaskTemplateLaunchPrefill, @@ -24,11 +31,7 @@ import { type CuratedTaskInputValues, type CuratedTaskTemplateItem, } from "../utils/curatedTaskTemplates"; -import { - listCuratedTaskRecommendationSignals, - subscribeCuratedTaskRecommendationSignalsChanged, -} from "../utils/curatedTaskRecommendationSignals"; -import { buildReviewFeedbackProjection } from "../utils/reviewFeedbackProjection"; +import { subscribeCuratedTaskRecommendationSignalsChanged } from "../utils/curatedTaskRecommendationSignals"; import { extractCuratedTaskReferenceMemoryIds, mergeCuratedTaskReferenceEntries, @@ -39,11 +42,9 @@ import type { CuratedTaskReferenceSelection, } from "../utils/curatedTaskReferenceSelection"; import { CuratedTaskLauncherDialog } from "./CuratedTaskLauncherDialog"; -import { CreationReplaySurfaceBanner } from "./CreationReplaySurfaceBanner"; import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel"; import { EmptyStateHero } from "./EmptyStateHero"; import { EmptyStateQuickActions } from "./EmptyStateQuickActions"; -import { EmptyStateSceneAppsPanel } from "./EmptyStateSceneAppsPanel"; import { EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME, EMPTY_STATE_PAGE_CONTAINER_CLASSNAME, @@ -74,23 +75,28 @@ import { getSiteSkillAutoLaunchExample, hasAutoLaunchableSiteSkill, } from "../service-skills/siteSkillExamplePrompts"; -import { resolveServiceSkillEntryDescription } from "../service-skills/entryAdapter"; import { listFeaturedHomeServiceSkills } from "../service-skills/homeEntrySkills"; -import { buildServiceSkillCapabilityDescription } from "../service-skills/skillPresentation"; -import { - buildServiceSkillLaunchPrefillSummary, - resolveServiceSkillLaunchPrefill, -} from "../service-skills/serviceSkillLaunchPrefill"; import type { SceneAppEntryCardItem } from "../sceneappEntryTypes"; import type { RuntimeToolAvailability } from "../utils/runtimeToolAvailability"; import type { AgentTaskRuntimeCardModel } from "../utils/agentTaskRuntime"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; import type { CreationReplaySurfaceModel } from "../utils/creationReplaySurface"; -import { buildInstalledSkillCapabilityDescription } from "@/components/skills/installedSkillPresentation"; import { - buildSceneAppExecutionReviewPrefillHighlights, - buildSceneAppExecutionReviewPrefillSnapshot, -} from "../utils/sceneAppCuratedTaskReference"; + HomeStartSurface, + type HomeSupplementalAction, +} from "../home/HomeStartSurface"; +import { HomeSkillGallery } from "../home/HomeSkillGallery"; +import { + buildHomeGalleryItems, + buildHomeSkillItems, + buildHomeSkillSections, + buildHomeStarterChips, +} from "../home/buildHomeSkillSurface"; +import { HOME_COMPOSER_PLACEHOLDER } from "../home/homeSurfaceCopy"; +import type { + HomeSkillSurfaceItem, + HomeStarterChip, +} from "../home/homeSurfaceTypes"; const contentReveal = keyframes` from { @@ -106,6 +112,15 @@ const contentReveal = keyframes` const PageContainer = styled.div.attrs({ className: EMPTY_STATE_PAGE_CONTAINER_CLASSNAME, })` + display: flex; + flex: 1 1 auto; + min-height: 0; + height: 100%; + flex-direction: column; + overflow-y: auto; + overscroll-behavior: contain; + scroll-behavior: smooth; + scroll-snap-type: y mandatory; isolation: isolate; background: radial-gradient( @@ -130,450 +145,132 @@ const ContentWrapper = styled.div.attrs({ className: EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME, })` display: flex; - flex: 1 1 auto; + flex: 0 0 auto; min-height: 100%; + height: 100%; + width: 100%; + box-sizing: border-box; + position: relative; + overflow: hidden; + scroll-snap-align: start; + scroll-snap-stop: always; animation: ${contentReveal} 560ms cubic-bezier(0.22, 1, 0.36, 1) both; - padding: 0.45rem 0.25rem 1.25rem; + padding: 0.45rem 0.25rem 4.7rem; @media (prefers-reduced-motion: reduce) { animation: none; } `; -const RecommendationShelf = styled.div` - display: flex; - min-width: 0; - flex-direction: column; - gap: 0.88rem; - border-radius: 30px; - border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92)); - background: linear-gradient( - 180deg, - var(--lime-surface-subtle, #fcfffb) 0%, - var(--lime-surface-soft, #f8fcfa) 100% - ); - padding: 1rem 1.05rem 0.95rem; - box-shadow: 0 22px 42px -38px var(--lime-shadow-color); -`; +const ComposerGlowFrame = styled.div` + position: relative; + isolation: isolate; -const RecommendationShelfHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - min-width: 0; -`; - -const RecommendationShelfHeaderBody = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.1rem; - min-width: 0; - flex: 1 1 auto; -`; - -const RecommendationShelfHeaderTitle = styled.div` - font-size: 13px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--lime-text, rgb(71 85 105)); -`; - -const RecommendationShelfHeaderDescription = styled.div` - font-size: 13px; - line-height: 1.55; - color: var(--lime-text-muted, rgb(100 116 139)); -`; - -const RecommendationShelfInlineBadge = styled.span` - display: inline-flex; - align-items: center; - border-radius: 9999px; - border: 1px solid var(--lime-surface-border, rgb(226 232 240)); - background: var(--lime-surface-soft, rgb(248 250 252)); - padding: 0.1rem 0.38rem; - font-size: 10px; - font-weight: 600; - line-height: 1; - color: var(--lime-text-muted, rgb(100 116 139)); -`; - -const RecommendationShelfBadge = styled.span` - display: inline-flex; - align-items: center; - border-radius: 9999px; - border: 1px solid var(--lime-surface-border-strong, rgb(209 250 229)); - background: var(--lime-brand-soft, rgb(236 253 245)); - padding: 0.18rem 0.42rem; - font-size: 10px; - font-weight: 600; - line-height: 1; - color: var(--lime-brand-strong, rgb(5 150 105)); -`; - -const RecommendationShelfEmptyState = styled.div` - font-size: 12px; - line-height: 1.5; - color: var(--lime-text-muted, rgb(148 163 184)); - padding: 0.1rem 0; -`; - -const RecommendationSignalBanner = styled.div` - display: flex; - flex-direction: column; - gap: 0.45rem; - border-radius: 18px; - border: 1px solid var(--lime-home-card-border, rgba(191, 219, 254, 0.95)); - background: - radial-gradient( - circle at top right, - var(--lime-home-glow-secondary, rgba(186, 230, 253, 0.26)), - rgba(255, 255, 255, 0) 48% - ), - linear-gradient( - 180deg, - var(--lime-surface-soft, rgba(248, 250, 252, 0.96)), - var(--lime-surface, rgba(255, 255, 255, 0.98)) + &::after { + content: ""; + position: absolute; + left: clamp(1.5rem, 9vw, 7rem); + right: clamp(1.5rem, 9vw, 7rem); + bottom: -1.1rem; + z-index: 0; + height: clamp(34px, 5vw, 58px); + border-radius: 999px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(187, 247, 208, 0.18) 16%, + rgba(110, 231, 183, 0.36) 42%, + rgba(45, 212, 191, 0.34) 58%, + rgba(186, 230, 253, 0.18) 84%, + transparent 100% ); - padding: 0.9rem 0.95rem; -`; + filter: blur(18px); + opacity: 0.86; + pointer-events: none; + } -const RecommendationSignalBannerHeader = styled.div` - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.45rem; -`; - -const RecommendationSignalBannerTitle = styled.div` - font-size: 12px; - font-weight: 600; - line-height: 1.6; - color: var(--lime-text-strong, rgb(15 23 42)); -`; - -const RecommendationSignalBannerSummary = styled.div` - font-size: 12px; - line-height: 1.65; - color: var(--lime-text, rgb(71 85 105)); -`; - -const RecommendationSignalBannerFootnote = styled.div` - font-size: 11px; - line-height: 1.6; - color: var(--lime-info, rgb(14 116 144)); -`; - -const RecommendationLeadCard = styled.button` - display: flex; - width: 100%; - min-width: 0; - flex-direction: column; - align-items: flex-start; - gap: 0.72rem; - border-radius: 30px; - border: 1px solid var(--lime-home-card-border, rgba(191, 219, 254, 0.92)); - background: var(--lime-home-card-surface-strong); - padding: 1.22rem 1.25rem 1.18rem; - text-align: left; - color: var(--lime-text, rgb(51 65 85)); - transition: - border-color 180ms ease, - box-shadow 180ms ease, - transform 180ms ease; - - &:hover { - border-color: var(--lime-home-card-hover-border, rgb(147 197 253)); - box-shadow: 0 18px 42px -30px var(--lime-shadow-color); - transform: translateY(-1px); + > * { + position: relative; + z-index: 1; } `; -const RecommendationLeadEyebrowRow = styled.div` - display: flex; - width: 100%; - min-width: 0; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.55rem; -`; - -const RecommendationLeadEyebrow = styled.span` - font-size: 12px; - font-weight: 600; - line-height: 1.4; - color: var(--lime-info, rgb(3 105 161)); -`; - -const RecommendationLeadTitle = styled.div` - font-size: 18px; - font-weight: 600; - line-height: 1.4; - color: var(--lime-text-strong, rgb(15 23 42)); -`; - -const RecommendationLeadSummary = styled.div` - font-size: 13px; - line-height: 1.65; - color: var(--lime-text, rgb(71 85 105)); -`; - -const RecommendationLeadMeta = styled.div` - font-size: 12px; - line-height: 1.6; - color: var(--lime-text-muted, rgb(100 116 139)); -`; - -const RecommendationLeadFooter = styled.div` - display: inline-flex; - align-items: center; - gap: 0.38rem; - padding-top: 0.2rem; - font-size: 12px; - font-weight: 600; - color: var(--lime-text-strong, rgb(15 23 42)); -`; - -const RecommendationAssistGroup = styled.div` - display: flex; - min-width: 0; - flex-direction: column; - gap: 0.28rem; -`; - -const RecommendationAssistLabel = styled.div` - font-size: 13px; - font-weight: 600; - line-height: 1.45; - color: var(--lime-text, rgb(71 85 105)); -`; - -const RecommendationAssistList = styled.div` +const ScrollCue = styled.a` + position: absolute; + left: 50%; + bottom: clamp(0.62rem, 1.8vh, 1.15rem); + z-index: 8; display: grid; - gap: 0.55rem; - grid-template-columns: minmax(0, 1fr); - align-items: stretch; - - @media (min-width: 768px) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - @media (min-width: 1240px) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -`; - -const RecommendationAssistCard = styled.button` - display: flex; - min-width: 0; - flex-direction: column; - align-items: flex-start; - gap: 0.35rem; - border-radius: 22px; - border: 1px solid - var(--lime-home-card-border-muted, rgba(219, 234, 254, 0.98)); - background: var(--lime-home-card-surface-strong); - padding: 0.96rem 1rem; - text-align: left; - color: var(--lime-text, rgb(51 65 85)); - transition: - border-color 180ms ease, - background-color 180ms ease, - transform 180ms ease; - - &:hover { - border-color: var(--lime-home-card-hover-border, rgb(147 197 253)); - background: var(--lime-surface, rgb(255 255 255)); - transform: translateY(-1px); - } -`; - -const RecommendationAssistCardHeader = styled.div` - display: flex; - width: 100%; - min-width: 0; - flex-wrap: wrap; + width: min(680px, calc(100% - 2rem)); + max-width: calc(100% - 2rem); + grid-template-columns: minmax(64px, 1fr) auto minmax(64px, 1fr); align-items: center; - gap: 0.35rem; -`; - -const RecommendationAssistCardTitle = styled.div` - font-size: 15px; - font-weight: 600; - line-height: 1.45; - color: var(--lime-text-strong, rgb(15 23 42)); -`; - -const RecommendationAssistCardSummary = styled.div` - font-size: 12px; - line-height: 1.55; + justify-content: center; + gap: 0.9rem; + transform: translateX(-50%); + padding: 0.35rem 0; color: var(--lime-text-muted, rgb(100 116 139)); -`; - -const RecommendationAssistFootnote = styled.div` font-size: 12px; - line-height: 1.55; - color: var(--lime-text-muted, rgb(148 163 184)); -`; - -const RecommendationSupplementalPanel = styled.div` - display: flex; - min-width: 0; - flex-direction: column; - gap: 0.56rem; - padding-top: 0.2rem; - border-top: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.88)); -`; - -const RecommendationSupplementalLabel = styled.div` - font-size: 12px; - line-height: 1.5; - color: var(--lime-text-muted, rgb(148 163 184)); -`; - -const RecommendationSupplementalRow = styled.div` - display: flex; - min-width: 0; - flex-wrap: wrap; - align-items: center; - gap: 0.45rem; -`; - -const RecommendationSupplementalLink = styled.button` - display: inline-flex; - align-items: center; - border-radius: 9999px; - border: 1px solid var(--lime-surface-border, rgb(226 232 240)); - background: var(--lime-surface, rgb(255 255 255)); - padding: 0.42rem 0.78rem; - font-size: 12px; - font-weight: 500; - line-height: 1.2; - color: var(--lime-text, rgb(71 85 105)); - transition: - border-color 180ms ease, - background-color 180ms ease, - color 180ms ease; - - &:hover { - border-color: var(--lime-surface-border-strong, rgb(203 213 225)); - background: var(--lime-surface-soft, rgb(248 250 252)); - color: var(--lime-text-strong, rgb(15 23 42)); - } -`; - -const RecommendationTabsRow = styled.div` - display: flex; - min-width: 0; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -`; - -const RecommendationTabsRail = styled.div` - display: flex; - min-width: 0; - flex-wrap: wrap; - align-items: center; - gap: 0.45rem; -`; - -const RecommendationTabButton = styled.button<{ $active: boolean }>` - display: inline-flex; - align-items: center; - gap: 0.5rem; - min-height: 36px; - border-radius: 9999px; - border: 1px solid - ${({ $active }) => - $active - ? "var(--lime-surface-border-strong, rgba(134, 239, 172, 0.72))" - : "var(--lime-surface-border, rgba(226, 232, 240, 0.94))"}; - background: ${({ $active }) => - $active - ? "var(--lime-brand-soft, linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.92)))" - : "var(--lime-surface, rgba(255, 255, 255, 0.94))"}; - padding: 0.5rem 0.85rem; - font-size: 12px; - font-weight: 600; + font-weight: 680; line-height: 1; - color: ${({ $active }) => - $active - ? "var(--lime-brand-strong, rgb(22 101 52))" - : "var(--lime-text, rgb(71 85 105))"}; + text-decoration: none; + white-space: nowrap; transition: - border-color 180ms ease, - background-color 180ms ease, - color 180ms ease, - transform 180ms ease, - box-shadow 180ms ease; + color 160ms ease, + transform 160ms ease; &:hover { - border-color: ${({ $active }) => - $active - ? "var(--lime-surface-border-strong, rgba(110, 231, 183, 0.84))" - : "var(--lime-surface-border, rgba(203, 213, 225, 0.96))"}; - color: var(--lime-text-strong, rgb(15 23 42)); - background: ${({ $active }) => - $active - ? "var(--lime-brand-soft, linear-gradient(180deg, rgba(236, 253, 245, 1), rgba(220, 252, 231, 0.98)))" - : "var(--lime-surface, rgb(255 255 255))"}; - box-shadow: 0 14px 28px -28px var(--lime-shadow-color); - transform: translateY(-1px); + color: var(--lime-text, rgb(71 85 105)); + transform: translateX(-50%) translateY(-1px); } `; -const RecommendationTabCount = styled.span<{ $active: boolean }>` +const ScrollCueLine = styled.span` + display: block; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--lime-surface-border-strong, rgba(203, 213, 225, 0.82)) 18%, + var(--lime-surface-border-strong, rgba(203, 213, 225, 0.82)) 82%, + transparent 100% + ); +`; + +const ScrollCueText = styled.span` + display: inline-flex; + align-items: center; + gap: 0.22rem; +`; + +const ScrollCueArrow = styled.span` display: inline-flex; align-items: center; justify-content: center; - min-width: 1.35rem; - height: 1.35rem; - border-radius: 9999px; - background: ${({ $active }) => - $active - ? "var(--lime-surface, rgba(255, 255, 255, 0.96))" - : "var(--lime-surface-soft, rgba(248, 250, 252, 0.98))"}; - border: 1px solid - ${({ $active }) => - $active - ? "var(--lime-surface-border-strong, rgba(134, 239, 172, 0.64))" - : "var(--lime-surface-border, rgba(226, 232, 240, 0.96))"}; - padding: 0 0.35rem; - font-size: 11px; - font-weight: 700; + color: inherit; + font-size: 14px; line-height: 1; - color: ${({ $active }) => - $active - ? "var(--lime-brand-strong, rgb(21 128 61))" - : "var(--lime-text-muted, rgb(100 116 139))"}; `; -const RecommendationTabCaption = styled.div` - font-size: 12px; - line-height: 1.6; - color: var(--lime-text-muted, rgb(100 116 139)); -`; - -const RecommendationPanels = styled.div` +const SecondScreenSection = styled.section` display: flex; - min-width: 0; - flex-direction: column; - gap: 0.8rem; + flex: 0 0 auto; + min-height: 100%; + height: 100%; + width: 100%; + box-sizing: border-box; + align-items: flex-start; + justify-content: center; + overflow-y: auto; + overscroll-behavior: auto; + scroll-snap-align: start; + scroll-snap-stop: always; + padding: clamp(3.5rem, 8vh, 6rem) 0.25rem clamp(3rem, 8vh, 5.5rem); `; -const RecommendationTabPanel = styled.section` - display: flex; +const SecondScreenInner = styled.div` + width: min(1180px, 100%); min-width: 0; - flex-direction: column; - gap: 0.78rem; - - &[hidden] { - display: none; - } `; interface EmptyStateProps extends SkillSelectionSourceProps { @@ -674,49 +371,6 @@ interface EmptyStateProps extends SkillSelectionSourceProps { defaultCuratedTaskReferenceEntries?: CuratedTaskReferenceEntry[]; } -type RecommendationShelfItem = - | { - kind: "service-skill"; - key: string; - title: string; - summary: string; - badge?: string; - hint: string; - meta: string; - contextSummary?: string; - reasonLabel?: string; - reasonSummary?: string; - testId: string; - onSelect: () => void; - } - | { - kind: "solution"; - key: string; - title: string; - summary: string; - badge?: string; - hint: string; - meta: string; - contextSummary?: string; - reasonLabel?: string; - reasonSummary?: string; - testId: string; - onSelect: () => void; - }; - -interface ContinuationShelfItem { - key: string; - title: string; - summary: string; - badge?: string; - usedAt: number; - testId: string; - onSelect: () => void; -} - -type LaunchDeckTab = "recommended" | "continuation" | "methods"; - -// 需要显示创作模式选择器的主题 const CREATION_THEMES: string[] = []; const THEME_ICONS: Record = { @@ -747,77 +401,6 @@ function truncatePrompt(value: string, maxLength = 92) { return `${normalized.slice(0, maxLength).trim()}…`; } -function compareRecentShelfItems< - T extends { - title: string; - usedAt: number; - }, ->(left: T, right: T): number { - if (left.usedAt !== right.usedAt) { - return right.usedAt - left.usedAt; - } - - return left.title.localeCompare(right.title, "zh-CN"); -} - -function resolveResultShelfTitle( - creationReplaySurface?: CreationReplaySurfaceModel | null, -): string { - return creationReplaySurface ? "沿着当前上下文继续" : "先开始这一轮"; -} - -function resolveResultShelfDescription( - creationReplaySurface?: CreationReplaySurfaceModel | null, -): string { - if (!creationReplaySurface) { - return "先选一个结果起手;后面的补参考、改方向和续做,都留在这一轮里继续推进。"; - } - - if (creationReplaySurface.kind === "skill_scaffold") { - return "这轮会先沿着当前带入的做法草稿继续生成;先拿一个结果开工,跑顺后再回到我的方法继续整理。"; - } - - return "这轮会默认带着当前参考一起生成;你选的结果模板会自动沿用这份上下文。"; -} - -function resolveLeadRecommendationEyebrow( - creationReplaySurface?: CreationReplaySurfaceModel | null, -): string { - if (!creationReplaySurface) { - return "先从这个开始"; - } - - return creationReplaySurface.kind === "skill_scaffold" - ? "先沿着当前做法开工" - : "先沿着当前参考继续"; -} - -function resolveLaunchDeckTabCaption(tab: LaunchDeckTab): string { - switch (tab) { - case "continuation": - return "把最近跑过的模板、方法和会话接回这一轮,省掉重复起手。"; - case "methods": - return "不想先选结果模板时,也可以直接沿着现成做法开工。"; - case "recommended": - default: - return "先从结果入口起手,后面的补参考、改方向和续做都留在这一轮里。"; - } -} - -function buildRecommendationContextSummary( - segments: Array, -): string | undefined { - const normalizedSegments = segments - .map((segment) => segment?.trim()) - .filter((segment): segment is string => Boolean(segment)); - - if (normalizedSegments.length === 0) { - return undefined; - } - - return normalizedSegments.join(" · "); -} - export const EmptyState: React.FC = ({ input, setInput, @@ -880,6 +463,7 @@ export const EmptyState: React.FC = ({ defaultCuratedTaskReferenceMemoryIds, defaultCuratedTaskReferenceEntries, }) => { + const pageContainerRef = useRef(null); const [activeCapability, setActiveCapability] = useState(null); const activeCuratedTaskCapability = @@ -1068,35 +652,6 @@ export const EmptyState: React.FC = ({ return listCuratedTaskTemplates(); }, [curatedTaskRecommendationSignalsVersion, curatedTaskTemplatesVersion]); - const latestReviewRecommendationSignal = useMemo(() => { - void curatedTaskRecommendationSignalsVersion; - return ( - listCuratedTaskRecommendationSignals({ - projectId, - }) - .filter((signal) => signal.source === "review_feedback") - .sort((left, right) => right.createdAt - left.createdAt)[0] ?? null - ); - }, [curatedTaskRecommendationSignalsVersion, projectId]); - - const recentSceneUsageBySceneKey = useMemo(() => { - void slashEntryUsageVersion; - return new Map( - listSlashEntryUsage() - .filter((record) => record.kind === "scene") - .map((record) => [record.entryId, record] as const), - ); - }, [slashEntryUsageVersion]); - - const recentInstalledSkillUsageBySkillKey = useMemo(() => { - void slashEntryUsageVersion; - return new Map( - listSlashEntryUsage() - .filter((record) => record.kind === "skill") - .map((record) => [record.entryId, record] as const), - ); - }, [slashEntryUsageVersion]); - const selectedTextPreview = useMemo(() => { const normalized = (recommendationSelectedText || "") .trim() @@ -1193,7 +748,7 @@ export const EmptyState: React.FC = ({ const getPlaceholder = () => { return hasAutoLaunchSiteSkill ? `直接说一句话,例如:${siteSkillAutoLaunchExample}` - : "先说这轮要做什么,目标、对象或限制都可以。"; + : HOME_COMPOSER_PLACEHOLDER; }; const handleApplyRecommendation = useCallback( @@ -1371,400 +926,6 @@ export const EmptyState: React.FC = ({ ], ); - const recommendationShelfItems = useMemo(() => { - const curatedTemplateRecommendations = listFeaturedHomeCuratedTaskTemplates( - curatedTaskTemplates, - { - projectId, - referenceEntries: effectiveDefaultCuratedTaskReferenceEntries, - }, - ).map((featured) => { - const template = featured.template; - const launchPrefill = resolveCuratedTaskTemplateLaunchPrefill(template); - const reviewPrefillSnapshot = buildSceneAppExecutionReviewPrefillSnapshot( - { - referenceEntries: [ - ...effectiveDefaultCuratedTaskReferenceEntries, - ...(launchPrefill?.referenceEntries ?? []), - ], - taskId: template.id, - }, - ); - const metaPrefix = featured.reasonSummary - ? `${featured.reasonSummary} · ` - : ""; - - return { - kind: "solution" as const, - key: template.id, - title: template.title, - summary: template.summary, - hint: template.outputHint, - meta: `${metaPrefix}${buildCuratedTaskCapabilityDescription(template, { - includeSummary: false, - includeResultDestination: true, - includeFollowUpActions: true, - followUpLimit: 1, - })}`, - contextSummary: buildRecommendationContextSummary( - reviewPrefillSnapshot - ? [ - `当前结果基线:${reviewPrefillSnapshot.sourceTitle}`, - reviewPrefillSnapshot.statusLabel - ? `当前判断:${reviewPrefillSnapshot.statusLabel}` - : null, - reviewPrefillSnapshot.failureSignalLabel - ? `当前卡点:${reviewPrefillSnapshot.failureSignalLabel}` - : null, - reviewPrefillSnapshot.destinationsLabel - ? `更适合去向:${reviewPrefillSnapshot.destinationsLabel}` - : reviewPrefillSnapshot.operatingAction - ? `经营动作:${reviewPrefillSnapshot.operatingAction}` - : null, - ] - : buildSceneAppExecutionReviewPrefillHighlights( - reviewPrefillSnapshot, - ), - ), - reasonLabel: featured.reasonLabel, - reasonSummary: featured.reasonSummary, - testId: `entry-recommended-${template.id}`, - onSelect: () => - handleCuratedTaskLauncherRequest( - template, - null, - effectiveDefaultCuratedTaskReferenceMemoryIds, - effectiveDefaultCuratedTaskReferenceEntries, - ), - }; - }); - - const featuredServiceSkills = listFeaturedHomeServiceSkills( - serviceSkills ?? [], - ); - - const serviceSkillRecommendations = isGeneralTheme - ? featuredServiceSkills.map((skill) => { - const requiresSlots = skill.slotSchema.some((slot) => slot.required); - - return { - kind: "service-skill" as const, - key: `service-skill-${skill.id}`, - title: skill.title, - summary: - skill.summary?.trim() || - resolveServiceSkillEntryDescription(skill), - hint: requiresSlots - ? "补齐这一步后开始" - : `${skill.actionLabel} · 当前对话继续`, - meta: buildServiceSkillCapabilityDescription(skill, { - includeSummary: false, - }), - testId: `entry-service-skill-${skill.id}`, - onSelect: () => - handleSelectInputCapability({ - kind: "service_skill", - skill, - }), - }; - }) - : []; - - return [...curatedTemplateRecommendations, ...serviceSkillRecommendations]; - }, [ - curatedTaskTemplates, - effectiveDefaultCuratedTaskReferenceEntries, - effectiveDefaultCuratedTaskReferenceMemoryIds, - handleCuratedTaskLauncherRequest, - handleSelectInputCapability, - isGeneralTheme, - projectId, - serviceSkills, - ]); - - const recommendationSolutionItems = useMemo( - () => recommendationShelfItems.filter((item) => item.kind === "solution"), - [recommendationShelfItems], - ); - - const recommendationServiceSkillItems = useMemo( - () => - recommendationShelfItems.filter((item) => item.kind === "service-skill"), - [recommendationShelfItems], - ); - - const primaryRecommendationItems = useMemo( - () => recommendationSolutionItems.slice(0, 2), - [recommendationSolutionItems], - ); - - const leadRecommendationItem = useMemo( - () => - primaryRecommendationItems[0] ?? - recommendationSolutionItems[0] ?? - recommendationShelfItems[0] ?? - null, - [ - primaryRecommendationItems, - recommendationShelfItems, - recommendationSolutionItems, - ], - ); - - const alternativeRecommendationItems = useMemo(() => { - const leadKey = leadRecommendationItem?.key; - return recommendationSolutionItems - .filter((item) => item.key !== leadKey) - .slice(0, 5); - }, [leadRecommendationItem?.key, recommendationSolutionItems]); - - const reviewFeedbackBanner = useMemo(() => { - if (!latestReviewRecommendationSignal) { - return null; - } - - const projection = buildReviewFeedbackProjection({ - signal: latestReviewRecommendationSignal, - }); - const highlightedRecommendations = recommendationSolutionItems - .filter((item) => item.reasonLabel === "围绕最近判断") - .slice(0, 2); - if (highlightedRecommendations.length === 0) { - return null; - } - const primarySuggestedRecommendation = - (projection?.suggestedTasks[0] - ? highlightedRecommendations.find( - (item) => item.key === projection.suggestedTasks[0]?.taskId, - ) - : null) ?? highlightedRecommendations[0]; - - return { - title: latestReviewRecommendationSignal.title, - summary: truncatePrompt( - [ - latestReviewRecommendationSignal.summary, - projection?.suggestionText ?? "", - ] - .filter((segment) => segment.trim().length > 0) - .join(" "), - 152, - ), - nextSteps: highlightedRecommendations - .map((item) => item.title) - .join(" / "), - actionLabel: primarySuggestedRecommendation - ? `继续去「${primarySuggestedRecommendation.title}」` - : null, - onAction: primarySuggestedRecommendation?.onSelect ?? null, - }; - }, [latestReviewRecommendationSignal, recommendationSolutionItems]); - - const continuationShelfItems = useMemo(() => { - const recentTemplateItems = curatedTaskTemplates - .filter( - (template) => - template.isRecent && typeof template.recentUsedAt === "number", - ) - .map((template) => { - const launchPrefill = resolveCuratedTaskTemplateLaunchPrefill(template); - return { - key: `solution-${template.id}`, - title: template.title, - summary: [ - buildCuratedTaskRecentUsageDescription({ - task: template, - prefill: launchPrefill, - }), - buildCuratedTaskCapabilityDescription(template, { - includeSummary: false, - includeResultDestination: true, - includeFollowUpActions: true, - followUpLimit: 1, - }), - ] - .filter((segment) => segment.length > 0) - .join(" · "), - usedAt: template.recentUsedAt as number, - testId: `entry-continuation-solution-${template.id}`, - onSelect: () => - handleCuratedTaskLauncherRequest( - template, - launchPrefill?.inputValues ?? null, - launchPrefill?.referenceMemoryIds ?? - effectiveDefaultCuratedTaskReferenceMemoryIds, - launchPrefill?.referenceEntries ?? - effectiveDefaultCuratedTaskReferenceEntries, - launchPrefill?.hint ?? null, - ), - }; - }); - - const recentInstalledSkillItems = skillSelection.skills - .map((skill) => { - const usage = recentInstalledSkillUsageBySkillKey.get(skill.key); - const usedAt = usage?.usedAt ?? 0; - - if (usedAt <= 0) { - return null; - } - - return { - key: `installed-skill-${skill.key}`, - title: skill.name, - summary: [ - usage?.replayText - ? `上次目标:${truncatePrompt(usage.replayText, 56)}` - : "", - buildInstalledSkillCapabilityDescription(skill), - ] - .filter((segment) => segment.length > 0) - .join(" · "), - usedAt, - testId: `entry-continuation-method-${skill.key}`, - onSelect: () => { - handleSelectInputCapability({ - kind: "installed_skill", - skill, - }); - if (usage?.replayText) { - setInput(usage.replayText); - } - }, - }; - }) - .filter((item): item is ContinuationShelfItem => item !== null); - - const recentServiceSkillItems = - typeof onSelectServiceSkill === "function" - ? (serviceSkills ?? []) - .map((skill) => { - const recentPrefill = resolveServiceSkillLaunchPrefill({ - skill, - }); - const serviceSkillUsedAt = - typeof skill.recentUsedAt === "number" ? skill.recentUsedAt : 0; - const sceneUsedAt = skill.sceneBinding?.sceneKey - ? (recentSceneUsageBySceneKey.get(skill.sceneBinding.sceneKey) - ?.usedAt ?? 0) - : 0; - const usedAt = Math.max(serviceSkillUsedAt, sceneUsedAt); - - if (usedAt <= 0) { - return null; - } - - return { - key: `method-${skill.id}`, - title: skill.title, - summary: [ - buildServiceSkillLaunchPrefillSummary({ - skill, - slotValues: recentPrefill?.slotValues, - launchUserInput: recentPrefill?.launchUserInput, - }), - buildServiceSkillCapabilityDescription(skill, { - includeSummary: false, - }), - ] - .filter((segment) => segment.length > 0) - .join(" · "), - usedAt, - testId: `entry-continuation-method-${skill.id}`, - onSelect: () => - handleSelectInputCapability({ - kind: "service_skill", - skill, - }), - }; - }) - .filter((item): item is ContinuationShelfItem => item !== null) - : []; - - return [ - ...recentTemplateItems, - ...recentInstalledSkillItems, - ...recentServiceSkillItems, - ] - .sort(compareRecentShelfItems) - .slice(0, 4); - }, [ - curatedTaskTemplates, - effectiveDefaultCuratedTaskReferenceEntries, - effectiveDefaultCuratedTaskReferenceMemoryIds, - handleCuratedTaskLauncherRequest, - handleSelectInputCapability, - onSelectServiceSkill, - recentInstalledSkillUsageBySkillKey, - recentSceneUsageBySceneKey, - serviceSkills, - setInput, - skillSelection.skills, - ]); - - const directMethodItems = useMemo(() => { - return recommendationServiceSkillItems.slice(0, 3).map((item) => ({ - key: item.key, - title: item.title, - summary: item.meta, - badge: item.badge, - testId: item.testId, - onSelect: item.onSelect, - })); - }, [recommendationServiceSkillItems]); - - const preferredLaunchDeckTab = useMemo(() => { - if (leadRecommendationItem) { - return "recommended"; - } - if (continuationShelfItems.length > 0) { - return "continuation"; - } - if (directMethodItems.length > 0) { - return "methods"; - } - return "recommended"; - }, [ - continuationShelfItems.length, - directMethodItems.length, - leadRecommendationItem, - ]); - - const [launchDeckTab, setLaunchDeckTab] = useState( - preferredLaunchDeckTab, - ); - - useEffect(() => { - const availableTabs = new Set(); - if (leadRecommendationItem || alternativeRecommendationItems.length > 0) { - availableTabs.add("recommended"); - } - if (continuationShelfItems.length > 0) { - availableTabs.add("continuation"); - } - if (directMethodItems.length > 0) { - availableTabs.add("methods"); - } - - if (availableTabs.size === 0 || availableTabs.has(launchDeckTab)) { - return; - } - - setLaunchDeckTab(preferredLaunchDeckTab); - }, [ - alternativeRecommendationItems.length, - continuationShelfItems.length, - directMethodItems.length, - launchDeckTab, - leadRecommendationItem, - preferredLaunchDeckTab, - ]); - - const launchDeckTabCaption = resolveLaunchDeckTabCaption(launchDeckTab); - - const shouldShowSceneAppsPanel = - sceneAppsLoading || featuredSceneApps.length > 0 || canResumeRecentSceneApp; - const quickActionItems = useMemo( () => currentRecommendations.slice(0, 4).map(([shortLabel, fullPrompt]) => ({ @@ -1833,84 +994,344 @@ export const EmptyState: React.FC = ({ return presets; }, []); + const homeStarterChips = useMemo(() => buildHomeStarterChips(), []); + const [homeCatalogSceneEntries, setHomeCatalogSceneEntries] = useState< + SkillCatalogSceneEntry[] + >([]); + + useEffect(() => { + let cancelled = false; + const loadCatalogScenes = async () => { + try { + const catalog = await getSkillCatalog(); + if (cancelled) { + return; + } + setHomeCatalogSceneEntries( + listSkillCatalogSceneEntries(catalog).filter((entry) => + (entry.surfaceScopes ?? []).includes("home"), + ), + ); + } catch { + if (!cancelled) { + setHomeCatalogSceneEntries([]); + } + } + }; + + void loadCatalogScenes(); + const unsubscribe = subscribeSkillCatalogChanged(() => { + void loadCatalogScenes(); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + const homeServiceSkillItems = useMemo( + () => listFeaturedHomeServiceSkills(serviceSkills ?? [], { limit: 6 }), + [serviceSkills], + ); + + const homeSkillItems = useMemo(() => { + void slashEntryUsageVersion; + return buildHomeSkillItems({ + curatedTasks: curatedTaskTemplates, + catalogSceneEntries: homeCatalogSceneEntries, + serviceSkills: homeServiceSkillItems, + installedSkills: skillSelection.skills ?? [], + sceneApps: featuredSceneApps, + slashEntryUsage: listSlashEntryUsage(), + }); + }, [ + curatedTaskTemplates, + featuredSceneApps, + homeCatalogSceneEntries, + homeServiceSkillItems, + skillSelection.skills, + slashEntryUsageVersion, + ]); + + const homeSkillSections = useMemo( + () => buildHomeSkillSections(homeSkillItems), + [homeSkillItems], + ); + const homeGalleryItems = useMemo( + () => buildHomeGalleryItems(homeSkillItems, "all"), + [homeSkillItems], + ); + + const handleSelectHomeSkillItem = useCallback( + (item: HomeSkillSurfaceItem) => { + if (item.launchKind === "curated_task_launcher") { + const template = findCuratedTaskTemplateById(item.id); + if (!template) { + return; + } + const prefill = resolveCuratedTaskTemplateLaunchPrefill(template); + handleCuratedTaskLauncherRequest( + template, + prefill?.inputValues ?? null, + prefill?.referenceMemoryIds ?? + effectiveDefaultCuratedTaskReferenceMemoryIds, + prefill?.referenceEntries ?? + effectiveDefaultCuratedTaskReferenceEntries, + prefill?.hint, + ); + return; + } + + if (item.launchKind === "service_skill") { + const skill = homeServiceSkillItems.find( + (candidate) => candidate.id === item.id, + ); + if (skill) { + handleSelectInputCapability({ kind: "service_skill", skill }); + } + return; + } + + if (item.launchKind === "installed_skill") { + const skill = (skillSelection.skills ?? []).find( + (candidate) => candidate.key === item.id, + ); + if (skill) { + handleSelectInputCapability({ kind: "installed_skill", skill }); + if (item.isRecent && item.summary.trim()) { + setInput(item.summary); + } + } + return; + } + + if (item.launchKind === "scene_app") { + void onLaunchSceneApp?.(item.id); + return; + } + + if (item.launchKind === "skill_catalog_scene") { + const launchPrompt = + item.launchPrompt?.trim() || + item.placeholder?.trim() || + item.summary.trim(); + if (launchPrompt) { + setInput(launchPrompt); + } + if (item.linkedSkillId) { + const skill = (serviceSkills ?? []).find( + (candidate) => candidate.id === item.linkedSkillId, + ); + if (skill) { + handleSelectInputCapability({ kind: "service_skill", skill }); + } + } + } + }, + [ + effectiveDefaultCuratedTaskReferenceEntries, + effectiveDefaultCuratedTaskReferenceMemoryIds, + handleCuratedTaskLauncherRequest, + handleSelectInputCapability, + homeServiceSkillItems, + onLaunchSceneApp, + serviceSkills, + setInput, + skillSelection.skills, + ], + ); + + const handleSelectHomeStarterChip = useCallback( + (chip: HomeStarterChip) => { + if (chip.launchKind === "open_manager") { + onOpenSceneAppsDirectory?.(); + return; + } + + const targetItem = chip.targetItemId + ? homeSkillItems.find((item) => item.id === chip.targetItemId) + : null; + if (targetItem) { + handleSelectHomeSkillItem(targetItem); + } + }, + [handleSelectHomeSkillItem, homeSkillItems, onOpenSceneAppsDirectory], + ); + + const recentSessionLinkLabel = useMemo(() => { + const normalizedTitle = truncatePrompt(recentSessionTitle || "", 18); + if (!normalizedTitle) { + return recentSessionActionLabel; + } + return `${recentSessionActionLabel} · ${normalizedTitle}`; + }, [recentSessionActionLabel, recentSessionTitle]); + const recentSessionLinkTitle = useMemo( + () => + [recentSessionTitle, recentSessionSummary] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)) + .join(" · "), + [recentSessionSummary, recentSessionTitle], + ); + + const homeSupplementalActions = useMemo(() => { + const actions: HomeSupplementalAction[] = []; + + if (recentSessionTitle && onResumeRecentSession) { + actions.push({ + id: "recent-session", + label: recentSessionLinkLabel, + title: recentSessionLinkTitle || undefined, + testId: "entry-recent-session-resume", + onSelect: onResumeRecentSession, + }); + } + + if (canResumeRecentSceneApp && onResumeRecentSceneApp) { + actions.push({ + id: "recent-sceneapp", + label: "继续最近做法", + testId: "entry-sceneapp-resume", + onSelect: onResumeRecentSceneApp, + }); + } + + featuredSceneApps.slice(0, 2).forEach((item) => { + actions.push({ + id: `sceneapp-${item.id}`, + label: + sceneAppLaunchingId === item.id + ? `${item.title} 准备中…` + : item.title, + title: `${item.businessLabel} · ${item.summary}`, + testId: `sceneapp-launch-${item.id}`, + onSelect: () => { + if (item.disabledReason || sceneAppLaunchingId === item.id) { + return; + } + void onLaunchSceneApp?.(item.id); + }, + }); + }); + + if (onOpenSceneAppsDirectory) { + actions.push({ + id: "sceneapps-directory", + label: sceneAppsLoading ? "做法加载中" : "查看全部做法", + testId: "entry-sceneapps-directory", + onSelect: onOpenSceneAppsDirectory, + }); + } + + if (onLaunchBrowserAssist) { + actions.push({ + id: "connect-browser", + label: browserAssistLoading ? "浏览器连接准备中" : "连接浏览器", + testId: "entry-connect-browser", + onSelect: () => { + void onLaunchBrowserAssist(); + }, + }); + } + + return actions; + }, [ + browserAssistLoading, + canResumeRecentSceneApp, + featuredSceneApps, + onLaunchBrowserAssist, + onLaunchSceneApp, + onOpenSceneAppsDirectory, + onResumeRecentSceneApp, + onResumeRecentSession, + recentSessionLinkLabel, + recentSessionLinkTitle, + recentSessionTitle, + sceneAppLaunchingId, + sceneAppsLoading, + ]); + const composerPanel = ( - - handleCuratedTaskLauncherRequest( - activeCuratedTask, - activeCuratedTaskLaunchInputValues, - activeCuratedTaskReferenceMemoryIds || - effectiveDefaultCuratedTaskReferenceMemoryIds, - activeCuratedTaskReferenceEntries || - effectiveDefaultCuratedTaskReferenceEntries, - ) - : undefined - } - onApplyCuratedTaskReviewSuggestion={ - activeCuratedTask - ? (task) => - handleCuratedTaskLauncherRequest( - task, - activeCuratedTaskLaunchInputValues, - activeCuratedTaskReferenceMemoryIds || - effectiveDefaultCuratedTaskReferenceMemoryIds, - activeCuratedTaskReferenceEntries || - effectiveDefaultCuratedTaskReferenceEntries, - "已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。", - ) - : undefined - } - creationReplaySurface={creationReplaySurface} - projectId={projectId} - sessionId={sessionId} - defaultCuratedTaskReferenceMemoryIds={ - effectiveDefaultCuratedTaskReferenceMemoryIds - } - defaultCuratedTaskReferenceEntries={ - effectiveDefaultCuratedTaskReferenceEntries - } - showCreationModeSelector={showCreationModeSelector} - creationMode={creationMode} - onCreationModeChange={onCreationModeChange} - thinkingEnabled={thinkingEnabled} - onThinkingEnabledChange={onThinkingEnabledChange} - subagentEnabled={subagentEnabled} - onSubagentEnabledChange={onSubagentEnabledChange} - selectedTeam={selectedTeam} - onSelectTeam={onSelectTeam} - teamWorkspaceSettings={teamWorkspaceSettings} - onPersistCustomTeams={onPersistCustomTeams} - onEnableSuggestedTeam={onEnableSuggestedTeam} - webSearchEnabled={webSearchEnabled} - onWebSearchEnabledChange={onWebSearchEnabledChange} - pendingImages={pendingImages} - onFileSelect={handleFileSelect} - onPaste={handlePaste} - onRemoveImage={handleRemoveImage} - /> + + + handleCuratedTaskLauncherRequest( + activeCuratedTask, + activeCuratedTaskLaunchInputValues, + activeCuratedTaskReferenceMemoryIds || + effectiveDefaultCuratedTaskReferenceMemoryIds, + activeCuratedTaskReferenceEntries || + effectiveDefaultCuratedTaskReferenceEntries, + ) + : undefined + } + onApplyCuratedTaskReviewSuggestion={ + activeCuratedTask + ? (task) => + handleCuratedTaskLauncherRequest( + task, + activeCuratedTaskLaunchInputValues, + activeCuratedTaskReferenceMemoryIds || + effectiveDefaultCuratedTaskReferenceMemoryIds, + activeCuratedTaskReferenceEntries || + effectiveDefaultCuratedTaskReferenceEntries, + "已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。", + ) + : undefined + } + creationReplaySurface={creationReplaySurface} + projectId={projectId} + sessionId={sessionId} + defaultCuratedTaskReferenceMemoryIds={ + effectiveDefaultCuratedTaskReferenceMemoryIds + } + defaultCuratedTaskReferenceEntries={ + effectiveDefaultCuratedTaskReferenceEntries + } + showCreationModeSelector={showCreationModeSelector} + creationMode={creationMode} + onCreationModeChange={onCreationModeChange} + thinkingEnabled={thinkingEnabled} + onThinkingEnabledChange={onThinkingEnabledChange} + subagentEnabled={subagentEnabled} + onSubagentEnabledChange={onSubagentEnabledChange} + selectedTeam={selectedTeam} + onSelectTeam={onSelectTeam} + teamWorkspaceSettings={teamWorkspaceSettings} + onPersistCustomTeams={onPersistCustomTeams} + onEnableSuggestedTeam={onEnableSuggestedTeam} + webSearchEnabled={webSearchEnabled} + onWebSearchEnabledChange={onWebSearchEnabledChange} + pendingImages={pendingImages} + onFileSelect={handleFileSelect} + onPaste={handlePaste} + onRemoveImage={handleRemoveImage} + /> + ); const defaultQuickActionsPanel = ( @@ -1928,393 +1349,29 @@ export const EmptyState: React.FC = ({ /> ); - const generalResultShelfPanel = ( - - - - - {resolveResultShelfTitle(creationReplaySurface)} - - - {resolveResultShelfDescription(creationReplaySurface)} - - - {selectedTextPreview ? ( - - 当前会带上选中内容 - - ) : null} - - - {creationReplaySurface ? ( - - ) : null} - - {reviewFeedbackBanner ? ( - - - - 最近判断已更新:{reviewFeedbackBanner.title} - - - - {reviewFeedbackBanner.summary} - - - 更适合继续:{reviewFeedbackBanner.nextSteps} - - {reviewFeedbackBanner.actionLabel && reviewFeedbackBanner.onAction ? ( -
- -
- ) : null} -
- ) : null} - - - - {(leadRecommendationItem || - alternativeRecommendationItems.length > 0) && ( - setLaunchDeckTab("recommended")} - > - 先开始这一轮 - - {recommendationSolutionItems.length} - - - )} - - {continuationShelfItems.length > 0 ? ( - setLaunchDeckTab("continuation")} - > - 继续这轮 - - {continuationShelfItems.length} - - - ) : null} - - {directMethodItems.length > 0 ? ( - setLaunchDeckTab("methods")} - > - 直接开工 - - {directMethodItems.length} - - - ) : null} - - - - {launchDeckTabCaption} - - - - - - - - - - - - - {projectId - ? "本轮产出会沉淀到当前项目,后续继续也会优先从这里接回。" - : "本轮产出会先写回当前任务,后续继续也会优先从这里接回。"} - -
+ const homeStartSurfacePanel = ( + ); + const handleSecondScreenWheel = useCallback( + (event: React.WheelEvent) => { + if (event.deltaY >= 0 || event.currentTarget.scrollTop > 1) { + return; + } - const hasRecentSessionContinuation = Boolean( - recentSessionTitle && onResumeRecentSession, + event.preventDefault(); + pageContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + }, + [], ); - const shouldShowContinuationSupplemental = - shouldShowSceneAppsPanel || - hasRecentSessionContinuation || - Boolean(onOpenSceneAppsDirectory); - const shouldShowSupplementalPanel = - shouldShowContinuationSupplemental || Boolean(onLaunchBrowserAssist); - const recentSessionLinkLabel = useMemo(() => { - const normalizedTitle = truncatePrompt(recentSessionTitle || "", 18); - if (!normalizedTitle) { - return recentSessionActionLabel; - } - return `${recentSessionActionLabel} · ${normalizedTitle}`; - }, [recentSessionActionLabel, recentSessionTitle]); - const recentSessionLinkTitle = useMemo( - () => - [recentSessionTitle, recentSessionSummary] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)) - .join(" · "), - [recentSessionSummary, recentSessionTitle], - ); - - const supplementalSceneAppItems = useMemo( - () => featuredSceneApps.slice(0, 2), - [featuredSceneApps], - ); - - const generalSupplementalPanel = shouldShowSupplementalPanel ? ( - - - {shouldShowContinuationSupplemental - ? "也可以直接续上这一轮。" - : "需要网页登录时,也可以先把浏览器接上。"} - - - {recentSessionTitle && onResumeRecentSession ? ( - - {recentSessionLinkLabel} - - ) : null} - - {canResumeRecentSceneApp && onResumeRecentSceneApp ? ( - - 继续最近做法 - - ) : null} - - - - {onOpenSceneAppsDirectory ? ( - - 查看全部做法 - - ) : null} - - {onLaunchBrowserAssist ? ( - { - void onLaunchBrowserAssist(); - }} - > - {browserAssistLoading ? "浏览器连接准备中" : "连接浏览器"} - - ) : null} - - - ) : null; return ( - + = ({ cards={[]} prioritySlot={composerPanel} supportingSlot={ - isGeneralTheme ? ( - <> - {generalResultShelfPanel} - {generalSupplementalPanel} - - ) : ( - defaultQuickActionsPanel - ) + isGeneralTheme ? homeStartSurfacePanel : defaultQuickActionsPanel } /> + {isGeneralTheme && homeGalleryItems.length > 0 ? ( + + + + 向下滚动或点击以查看示例 + + + + + ) : null} + {isGeneralTheme && homeGalleryItems.length > 0 ? ( + + + + + + ) : null} { expect(outputGrid?.textContent).toContain("2"); }); + it("任务 viewer 应展示已按多模态运行合同路由的状态", () => { + const { container } = renderComponent({ + tasks: [ + { + id: "task-contract-accepted-1", + mode: "generate", + status: "complete", + prompt: "青柠品牌主视觉", + rawText: "@配图 青柠品牌主视觉", + expectedCount: 1, + outputIds: ["output-contract-accepted-1"], + createdAt: 1, + runtimeContract: { + contractKey: "image_generation", + routingSlot: "image_task", + routingEvent: "model_routing_decision", + routingOutcome: "accepted", + }, + }, + ], + outputs: [ + { + id: "output-contract-accepted-1", + refId: "img-contract-accepted-1", + taskId: "task-contract-accepted-1", + url: "https://example.com/contract-accepted.png", + prompt: "青柠品牌主视觉", + createdAt: 1, + }, + ], + selectedOutputId: "output-contract-accepted-1", + }); + + const badge = container.querySelector( + '[data-testid="image-task-viewer-runtime-contract"]', + ); + + expect(badge?.textContent).toContain( + "运行合同 · 已按 image_generation 路由", + ); + }); + + it("任务 viewer 应展示合同阻止与 registry 能力缺口", () => { + const { container } = renderComponent({ + tasks: [ + { + id: "task-contract-blocked-1", + mode: "generate", + status: "error", + prompt: "青柠品牌主视觉", + rawText: "@配图 青柠品牌主视觉", + expectedCount: 1, + outputIds: [], + createdAt: 1, + failureMessage: "model registry 显示当前模型不具备图片生成能力。", + runtimeContract: { + contractKey: "image_generation", + routingSlot: "image_task", + providerId: "openai", + model: "gpt-5.2", + routingEvent: "routing_not_possible", + routingOutcome: "blocked", + failureCode: "image_generation_model_capability_gap", + modelCapabilityAssessmentSource: "model_registry", + modelSupportsImageGeneration: false, + }, + }, + ], + outputs: [], + selectedOutputId: null, + }); + + expect(container.textContent).toContain( + "运行合同阻止 · image_generation_model_capability_gap", + ); + expect(container.textContent).toContain( + "模型能力来自 model_registry · 不支持图片生成", + ); + }); + it("3x3 分镜任务应使用九宫格缩略布局并展示编号", () => { const outputs = Array.from({ length: 9 }, (_, index) => ({ id: `output-storyboard-${index + 1}`, diff --git a/src/components/agent/chat/components/ImageTaskViewer.tsx b/src/components/agent/chat/components/ImageTaskViewer.tsx index 92e11a1d3..1336fe9d9 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.tsx @@ -5,6 +5,7 @@ import type { ResourceManagerSourceContext } from "@/features/resource-manager"; import { cn } from "@/lib/utils"; import { RenderableTaskImage } from "./RenderableTaskImage"; import type { ImageTaskViewerProps } from "./imageWorkbenchTypes"; +import type { ImageRuntimeContractSnapshot } from "../types"; const IMAGE_TASK_PRIMARY_BUTTON_CLASSNAME = "inline-flex items-center justify-center rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 py-2 text-sm font-medium text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95"; @@ -189,6 +190,53 @@ function resolveStatusTone(status?: string): string { } } +function resolveRuntimeContractBadge( + runtimeContract?: ImageRuntimeContractSnapshot | null, +): { label: string; tone: string } | null { + if (!runtimeContract) { + return null; + } + + const contractKey = runtimeContract.contractKey?.trim() || "image_generation"; + const outcome = (runtimeContract.routingOutcome || "").trim().toLowerCase(); + if (outcome === "blocked") { + return { + label: `运行合同阻止 · ${ + runtimeContract.failureCode?.trim() || contractKey + }`, + tone: "border-amber-200 bg-amber-50 text-amber-800", + }; + } + if (outcome === "failed") { + return { + label: `运行合同失败 · ${ + runtimeContract.failureCode?.trim() || contractKey + }`, + tone: "border-rose-200 bg-rose-50 text-rose-700", + }; + } + + return { + label: `运行合同 · 已按 ${contractKey} 路由`, + tone: "border-emerald-200 bg-emerald-50 text-emerald-700", + }; +} + +function resolveRuntimeContractRegistryLabel( + runtimeContract?: ImageRuntimeContractSnapshot | null, +): string | null { + if (runtimeContract?.modelCapabilityAssessmentSource !== "model_registry") { + return null; + } + if (runtimeContract.modelSupportsImageGeneration === false) { + return "模型能力来自 model_registry · 不支持图片生成"; + } + if (runtimeContract.modelSupportsImageGeneration === true) { + return "模型能力来自 model_registry · 支持图片生成"; + } + return "模型能力来自 model_registry"; +} + function resolveEmptyStateDescription( status?: string, failureMessage?: string, @@ -351,6 +399,12 @@ export function ImageTaskViewer({ selectedTask?.status, selectedTask?.mode, ); + const runtimeContractBadge = resolveRuntimeContractBadge( + selectedTask?.runtimeContract, + ); + const runtimeContractRegistryLabel = resolveRuntimeContractRegistryLabel( + selectedTask?.runtimeContract, + ); const layoutLabel = resolveLayoutLabel(selectedTask?.layoutHint); const selectedOutputLabel = resolveSelectedOutputLabel({ selectedIndex: selectedOutputIndex, @@ -637,6 +691,25 @@ export function ImageTaskViewer({ ) : null}
+ {runtimeContractBadge ? ( + + {runtimeContractBadge.label} + + ) : null} + {runtimeContractRegistryLabel ? ( + + {runtimeContractRegistryLabel} + + ) : null} {layoutLabel ? ( {layoutLabel} diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index f36a0aef0..1bd373207 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -277,15 +277,18 @@ describe("MessageList", () => { expect(messageColumn?.className).toContain("justify-start"); }); - it("短对话首帧应贴近输入区底部,避免发送后消息短暂贴顶", () => { - const container = render([ - { - id: "msg-user-first-frame", - role: "user", - content: "你好", - timestamp: new Date("2026-04-25T10:00:00.000Z"), - } as Message, - ]); + it("短对话发送首帧也应吸顶展示,避免完成前后跳动", () => { + const container = render( + [ + { + id: "msg-user-first-frame", + role: "user", + content: "你好", + timestamp: new Date("2026-04-25T10:00:00.000Z"), + } as Message, + ], + { isSending: true }, + ); const messageColumn = container.querySelector( '[data-testid="message-list-column"]', @@ -293,10 +296,11 @@ describe("MessageList", () => { expect(messageColumn?.textContent).toContain("你好"); expect(messageColumn?.className).toContain("min-h-full"); - expect(messageColumn?.className).toContain("justify-end"); + expect(messageColumn?.className).toContain("justify-start"); + expect(messageColumn?.className).not.toContain("justify-end"); }); - it("任务中心进入对话后也应将短消息贴近输入区底部", () => { + it("任务中心发送首帧也应吸顶展示", () => { const container = render( [ { @@ -308,6 +312,7 @@ describe("MessageList", () => { ], { emptyStateVariant: "task-center", + isSending: true, }, ); @@ -316,7 +321,50 @@ describe("MessageList", () => { ); expect(messageColumn?.textContent).toContain("从任务中心开始对话"); - expect(messageColumn?.className).toContain("justify-end"); + expect(messageColumn?.className).toContain("justify-start"); + expect(messageColumn?.className).not.toContain("justify-end"); + }); + + it("已完成的旧会话短消息应吸顶展示,避免打开历史时贴近输入区", () => { + const container = render( + [ + { + id: "msg-user-history-short", + role: "user", + content: "打开旧会话", + timestamp: new Date("2026-04-25T10:00:00.000Z"), + } as Message, + { + id: "msg-assistant-history-short", + role: "assistant", + content: "这是历史回复", + timestamp: new Date("2026-04-25T10:00:01.000Z"), + } as Message, + ], + { + currentTurnId: "turn-history-completed", + turns: [ + { + id: "turn-history-completed", + thread_id: "session-history-short", + prompt_text: "打开旧会话", + status: "completed", + started_at: "2026-04-25T10:00:00.000Z", + completed_at: "2026-04-25T10:00:01.000Z", + created_at: "2026-04-25T10:00:00.000Z", + updated_at: "2026-04-25T10:00:01.000Z", + }, + ], + }, + ); + + const messageColumn = container.querySelector( + '[data-testid="message-list-column"]', + ); + + expect(messageColumn?.textContent).toContain("打开旧会话"); + expect(messageColumn?.className).toContain("justify-start"); + expect(messageColumn?.className).not.toContain("justify-end"); }); it("自动恢复生成会话时应展示恢复占位而不是空白引导", () => { @@ -394,7 +442,11 @@ describe("MessageList", () => { text: `历史执行轨迹 ${index + 1}`, })); - const container = render(messages, { turns, threadItems }); + const container = render(messages, { + currentTurnId: "turn-30", + turns, + threadItems, + }); expect(container.textContent).toContain("消息 60"); expect(mockAgentThreadTimeline).not.toHaveBeenCalled(); @@ -405,13 +457,40 @@ describe("MessageList", () => { expect(mockAgentThreadTimeline).toHaveBeenCalled(); expect(mockAgentThreadTimeline).toHaveBeenCalledWith( - expect.objectContaining({ deferCompletedSingleDetails: true }), + expect.objectContaining({ + deferCompletedSingleDetails: true, + isCurrentTurn: false, + }), ); expect( container.querySelector('[data-testid="agent-thread-timeline:leading"]'), ).not.toBeNull(); }); + it("旧会话已分页窗口首帧应只挂载更小的尾部批次", () => { + vi.useFakeTimers(); + const messages = createConversationMessages(40); + const container = render(messages, { + sessionHistoryWindow: { + loadedMessages: 40, + totalMessages: 188, + isLoadingFull: false, + error: null, + }, + }); + + expect(container.textContent).toContain("最近 40 / 188 条消息"); + expect(container.textContent).toContain("消息 40"); + expect(container.textContent).toContain("消息 21"); + expect(container.textContent).not.toContain("消息 20"); + + act(() => { + vi.advanceTimersByTime(130); + }); + + expect(container.textContent).toContain("消息 20"); + }); + it("任务中心空列表时应展示最近对话空态而不是普通新对话文案", () => { const container = render([], { emptyStateVariant: "task-center", @@ -2167,7 +2246,9 @@ describe("MessageList", () => { container.querySelector('[data-testid="agent-thread-timeline:leading"]'), ).not.toBeNull(); expect( - container.querySelector('[data-testid="assistant-primary-timeline-shell"]'), + container.querySelector( + '[data-testid="assistant-primary-timeline-shell"]', + ), ).not.toBeNull(); expect(mockStreamingRenderer).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index fd3e163fc..ed397e58c 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -189,6 +189,9 @@ interface MessageListProps { const MESSAGE_LIST_PROGRESSIVE_RENDER_THRESHOLD = 72; const MESSAGE_LIST_INITIAL_RENDER_COUNT = 36; const MESSAGE_LIST_RENDER_BATCH_SIZE = 48; +const MESSAGE_LIST_RESTORED_PROGRESSIVE_RENDER_THRESHOLD = 20; +const MESSAGE_LIST_RESTORED_INITIAL_RENDER_COUNT = 20; +const MESSAGE_LIST_RESTORED_RENDER_BATCH_SIZE = 12; const MESSAGE_LIST_TIMELINE_ITEMS_FACTOR = 4; const MESSAGE_LIST_TIMELINE_DEFER_MESSAGE_THRESHOLD = 24; const MESSAGE_LIST_TIMELINE_DEFER_ITEM_THRESHOLD = 24; @@ -567,10 +570,24 @@ const MessageListInner: React.FC = ({ const visibleMessageFirstId = visibleMessages[0]?.id ?? null; const visibleMessageLastId = visibleMessages[visibleMessages.length - 1]?.id ?? null; + const persistedHiddenHistoryCount = + sessionHistoryWindow && + sessionHistoryWindow.totalMessages > sessionHistoryWindow.loadedMessages + ? sessionHistoryWindow.totalMessages - sessionHistoryWindow.loadedMessages + : 0; + const isRestoredHistoryWindow = + isRestoringSession || persistedHiddenHistoryCount > 0; + const progressiveRenderThreshold = isRestoredHistoryWindow + ? MESSAGE_LIST_RESTORED_PROGRESSIVE_RENDER_THRESHOLD + : MESSAGE_LIST_PROGRESSIVE_RENDER_THRESHOLD; + const progressiveInitialRenderCount = isRestoredHistoryWindow + ? MESSAGE_LIST_RESTORED_INITIAL_RENDER_COUNT + : MESSAGE_LIST_INITIAL_RENDER_COUNT; + const progressiveRenderBatchSize = isRestoredHistoryWindow + ? MESSAGE_LIST_RESTORED_RENDER_BATCH_SIZE + : MESSAGE_LIST_RENDER_BATCH_SIZE; const shouldUseProgressiveRender = - !isRestoringSession && - !isSending && - visibleMessages.length > MESSAGE_LIST_PROGRESSIVE_RENDER_THRESHOLD; + !isSending && visibleMessages.length > progressiveRenderThreshold; const visibleMessageWindowRef = useRef<{ firstId: string | null; lastId: string | null; @@ -578,7 +595,7 @@ const MessageListInner: React.FC = ({ } | null>(null); const [renderedMessageCount, setRenderedMessageCount] = useState(() => shouldUseProgressiveRender - ? Math.min(visibleMessages.length, MESSAGE_LIST_INITIAL_RENDER_COUNT) + ? Math.min(visibleMessages.length, progressiveInitialRenderCount) : visibleMessages.length, ); @@ -603,7 +620,7 @@ const MessageListInner: React.FC = ({ if (!isAppendOnlyUpdate) { setRenderedMessageCount( - Math.min(visibleMessages.length, MESSAGE_LIST_INITIAL_RENDER_COUNT), + Math.min(visibleMessages.length, progressiveInitialRenderCount), ); return; } @@ -616,10 +633,11 @@ const MessageListInner: React.FC = ({ setRenderedMessageCount((current) => Math.min( visibleMessages.length, - Math.max(current + appendedCount, MESSAGE_LIST_INITIAL_RENDER_COUNT), + Math.max(current + appendedCount, progressiveInitialRenderCount), ), ); }, [ + progressiveInitialRenderCount, shouldUseProgressiveRender, visibleMessageFirstId, visibleMessageLastId, @@ -629,11 +647,6 @@ const MessageListInner: React.FC = ({ const hiddenHistoryCount = shouldUseProgressiveRender ? Math.max(0, visibleMessages.length - renderedMessageCount) : 0; - const persistedHiddenHistoryCount = - sessionHistoryWindow && - sessionHistoryWindow.totalMessages > sessionHistoryWindow.loadedMessages - ? sessionHistoryWindow.totalMessages - sessionHistoryWindow.loadedMessages - : 0; useEffect(() => { if ( @@ -649,7 +662,7 @@ const MessageListInner: React.FC = ({ setRenderedMessageCount((current) => Math.min( visibleMessages.length, - current + MESSAGE_LIST_RENDER_BATCH_SIZE, + current + progressiveRenderBatchSize, ), ); }, @@ -661,6 +674,7 @@ const MessageListInner: React.FC = ({ }, [ hiddenHistoryCount, isUserScrolling, + progressiveRenderBatchSize, shouldUseProgressiveRender, visibleMessages.length, ]); @@ -676,10 +690,15 @@ const MessageListInner: React.FC = ({ () => hiddenHistoryCount > 0 ? turns.slice( - -Math.max(renderedMessageCount, MESSAGE_LIST_INITIAL_RENDER_COUNT), + -Math.max(renderedMessageCount, progressiveInitialRenderCount), ) : turns, - [hiddenHistoryCount, renderedMessageCount, turns], + [ + hiddenHistoryCount, + progressiveInitialRenderCount, + renderedMessageCount, + turns, + ], ); const renderedThreadItems = useMemo( () => @@ -687,12 +706,30 @@ const MessageListInner: React.FC = ({ ? threadItems.slice( -Math.max( renderedMessageCount * MESSAGE_LIST_TIMELINE_ITEMS_FACTOR, - MESSAGE_LIST_INITIAL_RENDER_COUNT, + progressiveInitialRenderCount, ), ) : threadItems, - [hiddenHistoryCount, renderedMessageCount, threadItems], + [ + hiddenHistoryCount, + progressiveInitialRenderCount, + renderedMessageCount, + threadItems, + ], ); + const activeCurrentTurn = useMemo(() => { + if (!currentTurnId) { + return null; + } + + return renderedTurns.find((entry) => entry.id === currentTurnId) ?? null; + }, [currentTurnId, renderedTurns]); + const activeCurrentTurnId = + activeCurrentTurn && + (activeCurrentTurn.status === "running" || + activeCurrentTurn.status === "failed") + ? activeCurrentTurn.id + : null; const timelineHydrationKey = [ renderedMessages[renderedMessages.length - 1]?.id ?? "no-message", renderedTurns[renderedTurns.length - 1]?.id ?? "no-turn", @@ -700,7 +737,7 @@ const MessageListInner: React.FC = ({ ].join("|"); const shouldDeferHistoricalTimeline = !isSending && - !currentTurnId && + !activeCurrentTurnId && !focusedTimelineItemId && renderedMessages.length >= MESSAGE_LIST_TIMELINE_DEFER_MESSAGE_THRESHOLD && renderedThreadItems.length >= MESSAGE_LIST_TIMELINE_DEFER_ITEM_THRESHOLD; @@ -731,25 +768,22 @@ const MessageListInner: React.FC = ({ }, [shouldDeferHistoricalTimeline, timelineHydrationKey]); const canBuildHistoricalTimeline = !shouldDeferHistoricalTimeline || isHistoricalTimelineReady; - const timelineByMessageId = useMemo( - () => { - if (!canBuildHistoricalTimeline) { - return new Map(); - } + const timelineByMessageId = useMemo(() => { + if (!canBuildHistoricalTimeline) { + return new Map(); + } - return buildMessageTurnTimeline( - renderedMessages, - renderedTurns, - renderedThreadItems, - ); - }, - [ - canBuildHistoricalTimeline, + return buildMessageTurnTimeline( renderedMessages, - renderedThreadItems, renderedTurns, - ], - ); + renderedThreadItems, + ); + }, [ + canBuildHistoricalTimeline, + renderedMessages, + renderedThreadItems, + renderedTurns, + ]); const lastAssistantMessageId = useMemo( () => [...renderedMessages] @@ -766,7 +800,7 @@ const MessageListInner: React.FC = ({ messages: renderedMessages, turns: renderedTurns, threadItems: renderedThreadItems, - currentTurnId, + currentTurnId: activeCurrentTurnId, threadRead, pendingActions, queuedTurns, @@ -774,8 +808,8 @@ const MessageListInner: React.FC = ({ isSending, }); }, [ + activeCurrentTurnId, childSubagentSessions, - currentTurnId, isSending, lastAssistantMessageId, pendingActions, @@ -786,44 +820,33 @@ const MessageListInner: React.FC = ({ threadRead, ]); const currentTurnTimeline = useMemo(() => { - if (!currentTurnId || !lastAssistantMessageId) { - return null; - } - - const turn = renderedTurns.find((entry) => entry.id === currentTurnId); - if (!turn) { + if (!activeCurrentTurnId || !activeCurrentTurn || !lastAssistantMessageId) { return null; } const mappedMessageId = [...timelineByMessageId.values()].find( - (entry) => entry.turn.id === turn.id, + (entry) => entry.turn.id === activeCurrentTurnId, )?.messageId ?? null; return { messageId: mappedMessageId || lastAssistantMessageId, - turn, - items: renderedThreadItems.filter((item) => item.turn_id === turn.id), + turn: activeCurrentTurn, + items: renderedThreadItems.filter( + (item) => item.turn_id === activeCurrentTurnId, + ), }; }, [ - currentTurnId, + activeCurrentTurn, + activeCurrentTurnId, lastAssistantMessageId, renderedThreadItems, - renderedTurns, timelineByMessageId, ]); const messageGroups = useMemo( () => buildMessageTurnGroups(renderedMessages), [renderedMessages], ); - const hasStickyTopContent = - Boolean(leadingContent) || - persistedHiddenHistoryCount > 0 || - hiddenHistoryCount > 0; - const shouldBottomAnchorMessageStack = - messageGroups.length > 0 && - !hasStickyTopContent && - !isRestoringSession; const renderGroups = useMemo( () => messageGroups.map((group) => { @@ -1243,11 +1266,11 @@ const MessageListInner: React.FC = ({ items={primaryTimeline.items} threadRead={threadRead} actionRequests={primaryActionRequests} - isCurrentTurn={primaryTimeline.turn.id === currentTurnId} + isCurrentTurn={primaryTimeline.turn.id === activeCurrentTurnId} collapseInactiveDetails={!isSending} deferCompletedSingleDetails={ shouldDeferHistoricalTimelineDetails && - primaryTimeline.turn.id !== currentTurnId + primaryTimeline.turn.id !== activeCurrentTurnId } placement="leading" onFileClick={onFileClick} @@ -1448,11 +1471,13 @@ const MessageListInner: React.FC = ({ items={trailingTimeline.items} threadRead={threadRead} actionRequests={trailingActionRequests} - isCurrentTurn={trailingTimeline.turn.id === currentTurnId} + isCurrentTurn={ + trailingTimeline.turn.id === activeCurrentTurnId + } collapseInactiveDetails={!isSending} deferCompletedSingleDetails={ shouldDeferHistoricalTimelineDetails && - trailingTimeline.turn.id !== currentTurnId + trailingTimeline.turn.id !== activeCurrentTurnId } placement="trailing" onFileClick={onFileClick} @@ -1621,10 +1646,8 @@ const MessageListInner: React.FC = ({ data-testid="message-list-column" className={[ "mx-auto flex min-h-full w-full max-w-[1040px] flex-col gap-4 py-4", - compactLeadingSpacing - ? "pl-2.5 pr-3" - : "pl-4 pr-4", - shouldBottomAnchorMessageStack ? "justify-end" : "justify-start", + compactLeadingSpacing ? "pl-2.5 pr-3" : "pl-4 pr-4", + "justify-start", ].join(" ")} > {leadingContent ? ( diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.tsx index df330cdd0..10acecc1e 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.tsx @@ -359,8 +359,7 @@ const EmptyStateCard = styled.section` border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.96)); border-radius: 24px; background: var(--lime-surface, #ffffff); - box-shadow: 0 18px 52px -34px - var(--lime-shadow-color, rgba(15, 23, 42, 0.24)); + box-shadow: 0 18px 52px -34px var(--lime-shadow-color, rgba(15, 23, 42, 0.24)); `; const EmptyStateBody = styled.div` diff --git a/src/components/agent/chat/components/imageWorkbenchTypes.ts b/src/components/agent/chat/components/imageWorkbenchTypes.ts index e7c4fffeb..05777954b 100644 --- a/src/components/agent/chat/components/imageWorkbenchTypes.ts +++ b/src/components/agent/chat/components/imageWorkbenchTypes.ts @@ -1,4 +1,7 @@ -import type { ImageStoryboardSlot } from "../types"; +import type { + ImageRuntimeContractSnapshot, + ImageStoryboardSlot, +} from "../types"; export type ImageWorkbenchTaskMode = "generate" | "edit" | "variation"; @@ -35,6 +38,7 @@ export interface ImageWorkbenchTaskView { sourceImageCount?: number; createdAt: number; failureMessage?: string; + runtimeContract?: ImageRuntimeContractSnapshot | null; } export interface ImageWorkbenchOutputView { diff --git a/src/components/agent/chat/home/HomeMoreSkillsDrawer.tsx b/src/components/agent/chat/home/HomeMoreSkillsDrawer.tsx new file mode 100644 index 000000000..c95ff5536 --- /dev/null +++ b/src/components/agent/chat/home/HomeMoreSkillsDrawer.tsx @@ -0,0 +1,191 @@ +import styled from "styled-components"; +import type { + HomeSkillSection, + HomeSkillSurfaceItem, +} from "./homeSurfaceTypes"; +import { resolveHomeCoverAsset } from "./homeCoverAssets"; + +const Drawer = styled.div` + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.9rem; + border-radius: 26px; + border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92)); + background: var(--lime-surface, #fff); + padding: 1rem; + box-shadow: 0 22px 54px -40px var(--lime-shadow-color, rgba(15, 23, 42, 0.28)); +`; + +const Section = styled.section` + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.45rem; +`; + +const SectionTitle = styled.h3` + margin: 0; + padding: 0 0.35rem; + color: var(--lime-text, rgb(71 85 105)); + font-size: 13px; + font-weight: 750; + line-height: 1.4; +`; + +const ItemGrid = styled.div` + display: grid; + min-width: 0; + gap: 0.5rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + @media (max-width: 760px) { + grid-template-columns: minmax(0, 1fr); + } +`; + +const DrawerItem = styled.button` + display: grid; + min-width: 0; + grid-template-columns: 46px minmax(0, 1fr); + align-items: center; + gap: 0.62rem; + border-radius: 18px; + border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92)); + background: var(--lime-surface-soft, rgba(248, 250, 252, 0.98)); + padding: 0.45rem; + text-align: left; + transition: + border-color 160ms ease, + background-color 160ms ease, + transform 160ms ease; + + &:hover { + border-color: var(--lime-surface-border-strong, rgba(203, 213, 225, 0.96)); + background: var(--lime-surface, #fff); + transform: translateY(-1px); + } +`; + +const Cover = styled.span<{ $token: string }>` + display: block; + overflow: hidden; + width: 46px; + height: 46px; + border-radius: 15px; + background: ${({ $token }) => resolveCoverGradient($token)}; +`; + +const CoverImage = styled.img` + display: block; + height: 100%; + width: 100%; + object-fit: cover; +`; + +const ItemText = styled.span` + display: block; + min-width: 0; +`; + +const ItemTitle = styled.span` + display: block; + overflow: hidden; + color: var(--lime-text-strong, rgb(15 23 42)); + font-size: 13px; + font-weight: 780; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ItemSummary = styled.span` + display: block; + margin-top: 0.12rem; + overflow: hidden; + color: var(--lime-text-muted, rgb(100 116 139)); + font-size: 11px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +`; + +function resolveCoverGradient(token: string): string { + switch (token) { + case "trend": + return "linear-gradient(135deg, #dad78d, #55613d)"; + case "viral": + return "linear-gradient(135deg, #99c8c3, #34535a)"; + case "voice": + return "linear-gradient(135deg, #bfe9ff, #1f3d53)"; + case "review": + return "linear-gradient(135deg, #c9d7bb, #334136)"; + case "rewrite": + return "linear-gradient(135deg, #f3c18d, #4a2f26)"; + case "draft": + return "linear-gradient(135deg, #f5e0bd, #9a6542)"; + default: + return "linear-gradient(135deg, #d9e5d2, #64745d)"; + } +} + +interface HomeMoreSkillsDrawerProps { + open: boolean; + sections: HomeSkillSection[]; + onSelectItem: (item: HomeSkillSurfaceItem) => void; +} + +export function HomeMoreSkillsDrawer({ + open, + sections, + onSelectItem, +}: HomeMoreSkillsDrawerProps) { + if (!open) { + return null; + } + + return ( + + {sections.map((section) => ( +
+ {section.title} + + {section.items.map((item) => { + const imageUrl = resolveHomeCoverAsset(item.coverToken); + + return ( + onSelectItem(item)} + > + + {imageUrl ? ( + { + event.currentTarget.style.display = "none"; + }} + /> + ) : null} + + + /{item.title} + {item.summary} + + + ); + })} + +
+ ))} +
+ ); +} diff --git a/src/components/agent/chat/home/HomeSceneSkillManagerDialog.test.tsx b/src/components/agent/chat/home/HomeSceneSkillManagerDialog.test.tsx new file mode 100644 index 000000000..cc7b88dcd --- /dev/null +++ b/src/components/agent/chat/home/HomeSceneSkillManagerDialog.test.tsx @@ -0,0 +1,223 @@ +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 { HomeSceneSkillManagerDialog } from "./HomeSceneSkillManagerDialog"; +import type { SkillCatalog } from "@/lib/api/skillCatalog"; + +const { + mockGetClientSceneSkillPreferences, + mockUpdateClientSceneSkillPreferences, + mockGetSkillCatalog, + mockRefreshSkillCatalogFromRemote, +} = vi.hoisted(() => ({ + mockGetClientSceneSkillPreferences: vi.fn(), + mockUpdateClientSceneSkillPreferences: vi.fn(), + mockGetSkillCatalog: vi.fn(), + mockRefreshSkillCatalogFromRemote: vi.fn(), +})); + +vi.mock("@/lib/api/oemCloudRuntime", () => ({ + resolveOemCloudRuntimeContext: () => ({ + baseUrl: "https://cloud.lime.test", + controlPlaneBaseUrl: "https://cloud.lime.test/api", + sceneBaseUrl: "https://cloud.lime.test/scene-api", + gatewayBaseUrl: "https://cloud.lime.test/gateway-api", + tenantId: "tenant-0001", + sessionToken: "session-token", + hubProviderName: null, + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }), + hasOemCloudSession: () => true, +})); + +vi.mock("@/lib/api/oemCloudControlPlane", () => ({ + getClientSceneSkillPreferences: mockGetClientSceneSkillPreferences, + updateClientSceneSkillPreferences: mockUpdateClientSceneSkillPreferences, +})); + +vi.mock("@/lib/api/skillCatalog", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getSkillCatalog: mockGetSkillCatalog, + refreshSkillCatalogFromRemote: mockRefreshSkillCatalogFromRemote, + }; +}); + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function buildCatalog(): SkillCatalog { + return { + version: "test", + tenantId: "tenant-0001", + syncedAt: "2026-04-30T00:00:00Z", + groups: [], + items: [], + entries: [ + { + id: "skill:service-skill-0005", + kind: "skill", + title: "趋势简报", + summary: "整理平台趋势。", + skillId: "service-skill-0005", + groupKey: "general", + execution: { kind: "automation_job" }, + }, + { + id: "command:image_generate", + kind: "command", + title: "配图", + summary: "生成图片。", + commandKey: "image_generate", + triggers: [{ mode: "mention", prefix: "@配图" }], + }, + ], + }; +} + +function renderDialog() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ root, container }); + act(() => { + root.render(); + }); + return container; +} + +function setInputValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + const descriptor = Object.getOwnPropertyDescriptor( + element instanceof HTMLInputElement + ? HTMLInputElement.prototype + : HTMLTextAreaElement.prototype, + "value", + ); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +function setSelectValue(element: HTMLSelectElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLSelectElement.prototype, + "value", + ); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event("change", { bubbles: true })); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockGetSkillCatalog.mockResolvedValue(buildCatalog()); + mockGetClientSceneSkillPreferences.mockResolvedValue({ + tenantId: "tenant-0001", + userId: "user-0001", + orderedEntryIds: ["skill:service-skill-0005"], + hiddenEntryIds: [], + customScenes: [], + updatedAt: "2026-04-30T00:00:00Z", + }); + mockUpdateClientSceneSkillPreferences.mockResolvedValue({}); + mockRefreshSkillCatalogFromRemote.mockResolvedValue(buildCatalog()); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +describe("HomeSceneSkillManagerDialog", () => { + it("保存显隐和排序到云端偏好", async () => { + const container = renderDialog(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const checkbox = container.querySelector( + 'input[type="checkbox"]', + ); + expect(checkbox).not.toBeNull(); + + act(() => { + checkbox?.click(); + }); + const done = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "完成", + ); + expect(done).toBeTruthy(); + + await act(async () => { + done?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockUpdateClientSceneSkillPreferences).toHaveBeenCalledWith( + "tenant-0001", + expect.objectContaining({ + orderedEntryIds: ["skill:service-skill-0005"], + hiddenEntryIds: ["skill:service-skill-0005"], + }), + ); + expect(mockRefreshSkillCatalogFromRemote).toHaveBeenCalledTimes(1); + }); + + it("新增自定义场景时要求绑定已有 SkillCatalog entry", async () => { + const container = renderDialog(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const add = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "新增场景", + ); + act(() => { + add?.click(); + }); + + const inputs = container.querySelectorAll("input"); + const select = container.querySelector("select"); + const textarea = container.querySelector("textarea"); + act(() => { + setInputValue(inputs[0], "每日复盘"); + if (select) { + setSelectValue(select, "skill:service-skill-0005"); + } + if (textarea) { + setInputValue(textarea, "请帮我复盘今天的内容表现。"); + } + }); + + const submit = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "加入列表", + ); + act(() => { + submit?.click(); + }); + + expect(container.textContent).toContain("每日复盘"); + }); +}); diff --git a/src/components/agent/chat/home/HomeSceneSkillManagerDialog.tsx b/src/components/agent/chat/home/HomeSceneSkillManagerDialog.tsx new file mode 100644 index 000000000..0abd740e1 --- /dev/null +++ b/src/components/agent/chat/home/HomeSceneSkillManagerDialog.tsx @@ -0,0 +1,754 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import styled from "styled-components"; +import { + getClientSceneSkillPreferences, + updateClientSceneSkillPreferences, + type OemCloudCustomScene, + type OemCloudSceneSkillPreference, +} from "@/lib/api/oemCloudControlPlane"; +import { + hasOemCloudSession, + resolveOemCloudRuntimeContext, +} from "@/lib/api/oemCloudRuntime"; +import { + getSkillCatalog, + listSkillCatalogEntries, + refreshSkillCatalogFromRemote, + type SkillCatalogEntry, +} from "@/lib/api/skillCatalog"; + +const Backdrop = styled.div` + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + background: rgba(15, 23, 42, 0.22); + padding: 1.25rem; +`; + +const Dialog = styled.div` + display: flex; + width: min(760px, 100%); + max-height: min(720px, calc(100vh - 2.5rem)); + flex-direction: column; + overflow: hidden; + border-radius: 28px; + border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.96)); + background: var(--lime-surface, #fff); + box-shadow: 0 26px 74px -42px rgba(15, 23, 42, 0.46); +`; + +const Header = styled.header` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.9)); + padding: 1.05rem 1.15rem 0.9rem; +`; + +const TitleBlock = styled.div` + min-width: 0; +`; + +const Title = styled.h2` + margin: 0; + color: var(--lime-text-strong, rgb(15 23 42)); + font-size: 17px; + font-weight: 780; + line-height: 1.35; +`; + +const Description = styled.p` + margin: 0.22rem 0 0; + color: var(--lime-text-muted, rgb(100 116 139)); + font-size: 12px; + line-height: 1.55; +`; + +const Body = styled.div` + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 0.85rem; + overflow: auto; + padding: 1rem 1.15rem; +`; + +const Footer = styled.footer` + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + border-top: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.9)); + padding: 0.85rem 1.15rem 1rem; +`; + +const ActionRow = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +`; + +const Button = styled.button<{ $primary?: boolean }>` + display: inline-flex; + min-height: 34px; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid + ${({ $primary }) => + $primary + ? "var(--lime-brand-strong, #2f533c)" + : "var(--lime-surface-border, rgba(203, 213, 225, 0.96))"}; + background: ${({ $primary }) => + $primary ? "var(--lime-brand-strong, #2f533c)" : "#fff"}; + padding: 0.48rem 0.82rem; + color: ${({ $primary }) => + $primary ? "#fff" : "var(--lime-text, rgb(71 85 105))"}; + font-size: 12px; + font-weight: 720; + line-height: 1; + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } +`; + +const List = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +const Row = styled.div` + display: grid; + grid-template-columns: 26px minmax(0, 1fr) auto; + align-items: center; + gap: 0.65rem; + border-radius: 18px; + border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92)); + background: var(--lime-surface-soft, rgba(248, 250, 252, 0.98)); + padding: 0.55rem 0.7rem; +`; + +const DragHandle = styled.span` + color: var(--lime-text-subtle, rgb(148 163 184)); + font-size: 15px; + line-height: 1; + text-align: center; +`; + +const RowTitle = styled.div` + min-width: 0; +`; + +const RowName = styled.div` + overflow: hidden; + color: var(--lime-text-strong, rgb(15 23 42)); + font-size: 13px; + font-weight: 740; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RowMeta = styled.div` + overflow: hidden; + color: var(--lime-text-muted, rgb(100 116 139)); + font-size: 11px; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const RowControls = styled.div` + display: inline-flex; + align-items: center; + gap: 0.38rem; +`; + +const IconButton = styled.button` + display: inline-flex; + height: 28px; + min-width: 28px; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid var(--lime-surface-border, rgba(203, 213, 225, 0.9)); + background: #fff; + color: var(--lime-text, rgb(71 85 105)); + font-size: 12px; + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } +`; + +const ToggleLabel = styled.label` + display: inline-flex; + align-items: center; + gap: 0.28rem; + color: var(--lime-text, rgb(71 85 105)); + font-size: 11px; + font-weight: 680; +`; + +const Notice = styled.div` + border-radius: 18px; + border: 1px solid rgba(190, 242, 100, 0.62); + background: rgba(247, 254, 231, 0.86); + padding: 0.68rem 0.78rem; + color: rgb(63 98 18); + font-size: 12px; + line-height: 1.55; +`; + +const ErrorNotice = styled(Notice)` + border-color: rgba(254, 202, 202, 0.9); + background: rgba(254, 242, 242, 0.92); + color: rgb(153 27 27); +`; + +const FieldGrid = styled.form` + display: grid; + gap: 0.72rem; +`; + +const Field = styled.label` + display: grid; + gap: 0.3rem; + color: var(--lime-text, rgb(71 85 105)); + font-size: 12px; + font-weight: 700; +`; + +const Input = styled.input` + min-height: 36px; + border-radius: 14px; + border: 1px solid var(--lime-surface-border, rgba(203, 213, 225, 0.94)); + background: #fff; + padding: 0.5rem 0.62rem; + color: var(--lime-text-strong, rgb(15 23 42)); + font-size: 13px; + outline: none; +`; + +const Select = styled.select` + min-height: 36px; + border-radius: 14px; + border: 1px solid var(--lime-surface-border, rgba(203, 213, 225, 0.94)); + background: #fff; + padding: 0.5rem 0.62rem; + color: var(--lime-text-strong, rgb(15 23 42)); + font-size: 13px; +`; + +const TextArea = styled.textarea` + min-height: 74px; + resize: vertical; + border-radius: 14px; + border: 1px solid var(--lime-surface-border, rgba(203, 213, 225, 0.94)); + background: #fff; + padding: 0.55rem 0.62rem; + color: var(--lime-text-strong, rgb(15 23 42)); + font-size: 13px; + line-height: 1.5; + outline: none; +`; + +interface ManagerRow { + id: string; + title: string; + summary: string; + kind: SkillCatalogEntry["kind"]; + visible: boolean; +} + +interface DraftScene { + title: string; + summary: string; + linkedEntryId: string; + placeholder: string; + templateTitle: string; + templateDescription: string; + templatePrompt: string; +} + +const emptyPreference: OemCloudSceneSkillPreference = { + tenantId: "", + userId: "", + orderedEntryIds: [], + hiddenEntryIds: [], + customScenes: [], +}; + +const defaultDraft: DraftScene = { + title: "", + summary: "", + linkedEntryId: "", + placeholder: "", + templateTitle: "开始", + templateDescription: "", + templatePrompt: "", +}; + +function canManageEntry(entry: SkillCatalogEntry): boolean { + return entry.kind === "skill" || entry.kind === "scene"; +} + +function moveRow( + rows: ManagerRow[], + id: string, + direction: -1 | 1, +): ManagerRow[] { + const index = rows.findIndex((row) => row.id === id); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= rows.length) { + return rows; + } + const next = [...rows]; + const [item] = next.splice(index, 1); + next.splice(nextIndex, 0, item); + return next; +} + +function toRows( + entries: SkillCatalogEntry[], + preference: OemCloudSceneSkillPreference, +): ManagerRow[] { + const hidden = new Set(preference.hiddenEntryIds); + const baseRows = entries.filter(canManageEntry).map((entry) => ({ + id: entry.id, + title: entry.title, + summary: entry.summary, + kind: entry.kind, + visible: !hidden.has(entry.id), + })); + const byId = new Map(baseRows.map((row) => [row.id, row])); + const ordered = preference.orderedEntryIds + .map((id) => byId.get(id)) + .filter((row): row is ManagerRow => Boolean(row)); + const orderedIds = new Set(ordered.map((row) => row.id)); + return [...ordered, ...baseRows.filter((row) => !orderedIds.has(row.id))]; +} + +function buildCustomScene(draft: DraftScene): OemCloudCustomScene { + const idSuffix = + draft.title + .trim() + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-") + .replace(/^-+|-+$/g, "") || `scene-${Date.now()}`; + + return { + id: `custom_scene:${idSuffix}`, + title: draft.title.trim(), + summary: draft.summary.trim(), + linkedEntryId: draft.linkedEntryId, + placeholder: draft.placeholder.trim(), + enabled: true, + templates: [ + { + id: "default", + title: draft.templateTitle.trim() || "开始", + description: draft.templateDescription.trim(), + prompt: draft.templatePrompt.trim(), + }, + ], + }; +} + +interface HomeSceneSkillManagerDialogProps { + open: boolean; + onClose: () => void; +} + +export function HomeSceneSkillManagerDialog({ + open, + onClose, +}: HomeSceneSkillManagerDialogProps) { + const [view, setView] = useState<"list" | "create">("list"); + const [entries, setEntries] = useState([]); + const [preference, setPreference] = + useState(emptyPreference); + const [rows, setRows] = useState([]); + const [customScenes, setCustomScenes] = useState([]); + const [draft, setDraft] = useState(defaultDraft); + const [draggingId, setDraggingId] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [canSync, setCanSync] = useState(false); + + useEffect(() => { + if (!open) { + return; + } + + let cancelled = false; + const load = async () => { + setLoading(true); + setError(null); + try { + const runtime = resolveOemCloudRuntimeContext(); + const syncAvailable = hasOemCloudSession(runtime); + setCanSync(syncAvailable); + const catalog = await getSkillCatalog(); + const nextEntries = + listSkillCatalogEntries(catalog).filter(canManageEntry); + let nextPreference: OemCloudSceneSkillPreference = { + ...emptyPreference, + tenantId: runtime?.tenantId ?? "", + }; + if (syncAvailable && runtime) { + nextPreference = await getClientSceneSkillPreferences( + runtime.tenantId, + ); + } else { + setError("需要登录云端账号后,才能同步场景 Skills 管理结果。"); + } + if (cancelled) { + return; + } + setEntries(nextEntries); + setPreference(nextPreference); + setCustomScenes(nextPreference.customScenes); + setRows(toRows(nextEntries, nextPreference)); + } catch (reason) { + if (!cancelled) { + setError(reason instanceof Error ? reason.message : String(reason)); + setRows([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void load(); + return () => { + cancelled = true; + }; + }, [open]); + + const linkableEntries = useMemo( + () => + entries.filter( + (entry) => entry.kind === "skill" || entry.kind === "scene", + ), + [entries], + ); + + const handleDropOnRow = (targetId: string) => { + if (!draggingId || draggingId === targetId) { + setDraggingId(null); + return; + } + setRows((current) => { + const sourceIndex = current.findIndex((row) => row.id === draggingId); + const targetIndex = current.findIndex((row) => row.id === targetId); + if (sourceIndex < 0 || targetIndex < 0) { + return current; + } + const next = [...current]; + const [item] = next.splice(sourceIndex, 1); + next.splice(targetIndex, 0, item); + return next; + }); + setDraggingId(null); + }; + + const handleRestoreDefault = () => { + setRows( + toRows(entries, { + ...preference, + orderedEntryIds: [], + hiddenEntryIds: [], + }), + ); + }; + + const handleCreateSubmit = (event: FormEvent) => { + event.preventDefault(); + const title = draft.title.trim(); + const linkedEntryId = draft.linkedEntryId.trim(); + const prompt = draft.templatePrompt.trim(); + if (!title || !linkedEntryId || !prompt) { + setError("请填写场景名称、关联技能和模板提示词。"); + return; + } + + const scene = buildCustomScene(draft); + setCustomScenes((current) => [...current, scene]); + setRows((current) => [ + ...current, + { + id: scene.id ?? `custom_scene:${Date.now()}`, + title: scene.title, + summary: scene.summary || scene.templates[0]?.prompt || "自定义场景", + kind: "scene", + visible: true, + }, + ]); + setDraft(defaultDraft); + setView("list"); + setError(null); + }; + + const handleSave = async () => { + const runtime = resolveOemCloudRuntimeContext(); + const syncAvailable = hasOemCloudSession(runtime); + if (!runtime || !syncAvailable) { + setCanSync(false); + setError("需要登录云端账号后,才能同步场景 Skills 管理结果。"); + return; + } + setSaving(true); + setError(null); + try { + await updateClientSceneSkillPreferences(runtime.tenantId, { + orderedEntryIds: rows.map((row) => row.id), + hiddenEntryIds: rows.filter((row) => !row.visible).map((row) => row.id), + customScenes, + }); + await refreshSkillCatalogFromRemote(); + onClose(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSaving(false); + } + }; + + if (!open) { + return null; + } + + return ( + + +
+ + + {view === "list" ? "场景管理" : "新增自定义场景"} + + + {view === "list" + ? "调整首页场景和 Skills 的顺序、显隐;新增入口会同步到云端。" + : "把已有 SkillCatalog 里的技能包装成更贴近你工作流的起手场景。"} + + + +
+ + + {error ? {error} : null} + {!error && !canSync ? ( + + 当前未检测到云端会话,列表可以预览,但保存需要先登录。 + + ) : null} + + {view === "list" ? ( + <> + + + + {loading ? 正在载入场景 Skills… : null} + + {rows.map((row, index) => ( + setDraggingId(row.id)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => handleDropOnRow(row.id)} + > + ⋮⋮ + + {row.title} + + {row.kind === "scene" ? "场景" : "Skill"} ·{" "} + {row.summary} + + + + + setRows((current) => moveRow(current, row.id, -1)) + } + > + ↑ + + + setRows((current) => moveRow(current, row.id, 1)) + } + > + ↓ + + + { + const visible = event.currentTarget.checked; + setRows((current) => + current.map((item) => + item.id === row.id + ? { ...item, visible } + : item, + ), + ); + }} + /> + 显示 + + + + ))} + + + ) : ( + + + 场景名称 + + setDraft((current) => ({ + ...current, + title: event.target.value, + })) + } + /> + + + 关联技能 + + + + 输入框占位提示 + + setDraft((current) => ({ + ...current, + placeholder: event.target.value, + })) + } + /> + + + 快捷模板标题 + + setDraft((current) => ({ + ...current, + templateTitle: event.target.value, + })) + } + /> + + + 快捷模板描述 + + setDraft((current) => ({ + ...current, + templateDescription: event.target.value, + })) + } + /> + + + 模板提示词 +