chore: release v1.24.0

This commit is contained in:
coso
2026-04-30 04:46:39 +08:00
parent ee84d69c29
commit ad64ac7cce
468 changed files with 18163 additions and 7233 deletions
+51 -55
View File
@@ -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`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 904 KiB

@@ -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 8Browser 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 9PDF 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 10Voice 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 11Web 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 12Text 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 viewerPDF 本轮只收 executor trace、evidence 和 replay,避免在底层事实源未完全稳定前再开一套产物协议。
5. 暂不新增 audio viewer / 真实音频 workervoice 本轮只落最小 `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 与 requestprompt 显式输出底层合同、所需能力与 `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` contractRust `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。
@@ -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 与 DevBridgeworkspace / 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 与 DevBridgeworkspace-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 与 DevBridgeworkspace-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 与 DevBridgeworkspace-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 旧会话性能采样。
+7 -4
View File
@@ -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.*`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.23.0",
"version": "1.24.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+1 -1
View File
@@ -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"
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.23.0",
"version": "1.24.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

@@ -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,
+11 -11
View File
@@ -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");
+17 -17
View File
@@ -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",
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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 = [
+51
View File
@@ -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<Self, Self::Err> {
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");
@@ -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<reqwest::Url> {
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<String> {
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<String> {
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<String> {
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<String> {
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!(
+14 -1
View File
@@ -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::<BTreeSet<_>>();
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));
+1
View File
@@ -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,
@@ -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 = "<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>> {
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<String, serde_json::Value>,
) {
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}\""),
@@ -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<String>,
pub(crate) routing_slot: String,
pub(crate) runtime_contract: serde_json::Value,
pub(crate) entry_source: Option<String>,
}
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<BrowserBackendType>,
pub(crate) auto_launch: bool,
pub(crate) launch_url: Option<String>,
pub(crate) modality_runtime_contract: Option<BrowserAssistModalityRuntimeContract>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -45,6 +73,94 @@ pub(crate) fn parse_browser_backend_hint(value: &str) -> Option<BrowserBackendTy
}
}
fn extract_browser_assist_string(
browser_assist: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_browser_assist_value(
browser_assist: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<serde_json::Value> {
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<BrowserAssistModalityRuntimeContract> {
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<BrowserAssistRuntimeHint> {
@@ -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,
),
})
}
@@ -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 = "<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_object_value(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<serde_json::Value> {
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<String, serde_json::Value>> {
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<String, serde_json::Value>) {
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}\""),
@@ -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::{
@@ -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 = "<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_object_value(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<serde_json::Value> {
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<String, serde_json::Value>> {
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<String, serde_json::Value>) {
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}\""),
@@ -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}"));
}
@@ -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 = "<<LIME_REPORT_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_object_value(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<serde_json::Value> {
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<String, serde_json::Value>> {
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<String, serde_json::Value>) {
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}"),
@@ -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 = "<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_object_value(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<serde_json::Value> {
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<String, serde_json::Value>> {
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<String, serde_json::Value>) {
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}\""),
@@ -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<String>,
pub(crate) content_id: Option<String>,
pub(crate) entry_source: Option<String>,
pub(crate) modality_contract_key: Option<String>,
pub(crate) modality: Option<String>,
pub(crate) required_capabilities: Vec<String>,
pub(crate) routing_slot: Option<String>,
pub(crate) runtime_contract: Option<serde_json::Value>,
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>(),
),
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<String, serde_json::Value>> {
@@ -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<serde_json::Value> {
@@ -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)
}
@@ -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 = "<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Vec<String> {
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::<Vec<_>>()
})
.unwrap_or_default()
}
fn extract_object_value(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<serde_json::Value> {
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<String, serde_json::Value>> {
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<String, serde_json::Value>) {
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(),
@@ -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 = "<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>> {
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<String, serde_json::Value>,
) {
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}\""),
+519 -16
View File
@@ -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("<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>"));
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("<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>"));
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("<<LIME_REPORT_SKILL_LAUNCH_HINT>>"));
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("<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>"));
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("<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>"));
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("<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>"));
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("<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>"));
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("<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>"));
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!({
@@ -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)
}
}
@@ -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<String>,
}
#[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<String>,
#[serde(default, alias = "raw_text")]
raw_text: Option<String>,
#[serde(default)]
voice: Option<String>,
#[serde(default, alias = "voice_style")]
voice_style: Option<String>,
#[serde(default, alias = "target_language")]
target_language: Option<String>,
#[serde(default, alias = "mime_type")]
mime_type: Option<String>,
#[serde(default, alias = "audio_path")]
audio_path: Option<String>,
#[serde(default, alias = "duration_ms")]
duration_ms: Option<u64>,
#[serde(default, alias = "provider_id")]
provider_id: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default, alias = "session_id")]
session_id: Option<String>,
#[serde(default, alias = "project_id")]
project_id: Option<String>,
#[serde(default, alias = "content_id")]
content_id: Option<String>,
#[serde(default, alias = "entry_source")]
entry_source: Option<String>,
#[serde(default, alias = "modality_contract_key")]
modality_contract_key: Option<String>,
#[serde(default)]
modality: Option<String>,
#[serde(default, alias = "required_capabilities")]
required_capabilities: Vec<String>,
#[serde(default, alias = "routing_slot")]
routing_slot: Option<String>,
#[serde(default, alias = "runtime_contract")]
runtime_contract: Option<serde_json::Value>,
#[serde(default, alias = "requested_target")]
requested_target: Option<String>,
#[serde(default, alias = "output_path")]
output_path: Option<String>,
}
#[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": "会话 IDsnake_case 兼容,可选)。" },
"projectId": { "type": "string", "description": "项目 ID(可选)。" },
"project_id": { "type": "string", "description": "项目 IDsnake_case 兼容,可选)。" },
"contentId": { "type": "string", "description": "内容 ID(可选)。" },
"content_id": { "type": "string", "description": "内容 IDsnake_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<String>) -> Option<String> {
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<String>,
env_keys: &[&str],
) -> Option<String> {
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<ToolResult, ToolError> {
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<ToolResult, ToolError> {
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!({
@@ -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 = "<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>> {
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<String, serde_json::Value>,
) {
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}\""),
+656 -5
View File
@@ -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<Mutex<HashSet<String>>> =
Lazy::new(|| Mutex::new(HashSet::new()));
@@ -56,7 +60,7 @@ pub struct CreateImageGenerationTaskArtifactRequest {
pub title_generation_result: Option<serde_json::Value>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
#[serde(default, alias = "raw_text")]
pub raw_text: Option<String>,
#[serde(default)]
pub layout_hint: Option<String>,
@@ -112,6 +116,56 @@ pub struct CreateImageGenerationTaskArtifactRequest {
pub storyboard_slots: Vec<ImageStoryboardSlotInput>,
}
#[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<String>,
#[serde(default)]
pub raw_text: Option<String>,
#[serde(default)]
pub voice: Option<String>,
#[serde(default, alias = "voice_style")]
pub voice_style: Option<String>,
#[serde(default, alias = "target_language")]
pub target_language: Option<String>,
#[serde(default, alias = "mime_type")]
pub mime_type: Option<String>,
#[serde(default, alias = "audio_path")]
pub audio_path: Option<String>,
#[serde(default, alias = "duration_ms")]
pub duration_ms: Option<u64>,
#[serde(default, alias = "provider_id")]
pub provider_id: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default, alias = "session_id")]
pub session_id: Option<String>,
#[serde(default, alias = "project_id")]
pub project_id: Option<String>,
#[serde(default, alias = "content_id")]
pub content_id: Option<String>,
#[serde(default, alias = "entry_source")]
pub entry_source: Option<String>,
#[serde(default, alias = "modality_contract_key")]
pub modality_contract_key: Option<String>,
#[serde(default)]
pub modality: Option<String>,
#[serde(default, alias = "required_capabilities")]
pub required_capabilities: Vec<String>,
#[serde(default, alias = "routing_slot")]
pub routing_slot: Option<String>,
#[serde(default, alias = "runtime_contract")]
pub runtime_contract: Option<serde_json::Value>,
#[serde(default, alias = "requested_target")]
pub requested_target: Option<String>,
#[serde(default, alias = "output_path")]
pub output_path: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaTaskLookupRequest {
@@ -130,6 +184,12 @@ pub struct ListMediaTaskArtifactsRequest {
#[serde(default)]
pub task_type: Option<String>,
#[serde(default)]
#[serde(alias = "modality_contract_key")]
pub modality_contract_key: Option<String>,
#[serde(default)]
#[serde(alias = "routing_outcome")]
pub routing_outcome: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
@@ -138,9 +198,43 @@ pub struct MediaTaskListFilters {
pub status: Option<String>,
pub task_family: Option<String>,
pub task_type: Option<String>,
pub modality_contract_key: Option<String>,
pub routing_outcome: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct MediaTaskModalityRuntimeContractIndexEntry {
pub task_id: String,
pub task_type: String,
pub normalized_status: String,
pub contract_key: Option<String>,
pub routing_slot: Option<String>,
pub provider_id: Option<String>,
pub model: Option<String>,
pub routing_event: String,
pub routing_outcome: String,
pub failure_code: Option<String>,
pub model_capability_assessment_source: Option<String>,
pub model_supports_image_generation: Option<bool>,
}
#[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<String>,
pub blocked_count: usize,
pub routing_outcomes: Vec<MediaTaskRoutingOutcomeCount>,
pub model_registry_assessment_count: usize,
pub snapshots: Vec<MediaTaskModalityRuntimeContractIndexEntry>,
}
#[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<MediaTaskOutput>,
}
@@ -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<String, String> {
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<bool> {
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<String> {
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<String>, value: Option<String>) {
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<MediaTaskRoutingOutcomeCount>, 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<MediaTaskOutput, String> {
@@ -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<MediaTaskOutput, String> {
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<MediaTaskOutput, String> {
@@ -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<MediaTaskOutput, String> {
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<MediaTaskOutput, String> {
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");
@@ -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<String> {
.collect()
}
pub(crate) fn browser_control_required_capabilities() -> Vec<String> {
BROWSER_CONTROL_REQUIRED_CAPABILITIES
.iter()
.map(|value| (*value).to_string())
.collect()
}
pub(crate) fn pdf_extract_required_capabilities() -> Vec<String> {
PDF_EXTRACT_REQUIRED_CAPABILITIES
.iter()
.map(|value| (*value).to_string())
.collect()
}
pub(crate) fn voice_generation_required_capabilities() -> Vec<String> {
VOICE_GENERATION_REQUIRED_CAPABILITIES
.iter()
.map(|value| (*value).to_string())
.collect()
}
pub(crate) fn web_research_required_capabilities() -> Vec<String> {
WEB_RESEARCH_REQUIRED_CAPABILITIES
.iter()
.map(|value| (*value).to_string())
.collect()
}
pub(crate) fn text_transform_required_capabilities() -> Vec<String> {
TEXT_TRANSFORM_REQUIRED_CAPABILITIES
.iter()
.map(|value| (*value).to_string())
.collect()
}
pub(crate) fn normalize_image_generation_contract_key(
value: Option<String>,
) -> Result<String, String> {
@@ -124,6 +196,66 @@ pub(crate) fn normalize_image_generation_required_capabilities(
Ok(expected)
}
fn normalize_expected_voice_generation_contract_string(
value: Option<String>,
expected: &str,
field_name: &str,
) -> Result<String, String> {
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<String>,
) -> Result<String, String> {
normalize_expected_voice_generation_contract_string(
value,
VOICE_GENERATION_CONTRACT_KEY,
"modality_contract_key",
)
}
pub(crate) fn normalize_voice_generation_modality(value: Option<String>) -> Result<String, String> {
normalize_expected_voice_generation_contract_string(
value,
VOICE_GENERATION_MODALITY,
"modality",
)
}
pub(crate) fn normalize_voice_generation_routing_slot(
value: Option<String>,
) -> Result<String, String> {
normalize_expected_voice_generation_contract_string(
value,
VOICE_GENERATION_ROUTING_SLOT,
"routing_slot",
)
}
pub(crate) fn normalize_voice_generation_required_capabilities(
values: Vec<String>,
) -> Result<Vec<String>, 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<String, V
);
}
pub(crate) fn insert_pdf_extract_contract_fields(record: &mut Map<String, Value>) {
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<String, Value>) {
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<String, Value>) {
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<String, Value>) {
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::*;
+1
View File
@@ -10,6 +10,7 @@
//! - ✅ lime-providers crateproviders, converter, streaming, translator, stream, session 部分)
#![allow(clippy::all)]
#![recursion_limit = "256"]
//! - 主 crate 保留 Tauri 相关业务逻辑
// 抑制 objc crate 宏内部的 unexpected_cfgs 警告
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
},
+2 -2
View File
@@ -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
},
+2 -5
View File
@@ -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 {
+2 -8
View File
@@ -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";
+159 -11
View File
@@ -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<HTMLButtonElement>('button[aria-label="连接 Lime 云端"]')
?.querySelector<HTMLButtonElement>(
'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<HTMLButtonElement>('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<HTMLButtonElement>('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([
+60 -12
View File
@@ -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<string | null>(
() =>
@@ -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")
}
>
<AccountPlanHeader>
@@ -3680,9 +3733,7 @@ export function AppSidebar({
type="button"
aria-label="用户中心"
onClick={() =>
handleAccountMenuNavigate({
tab: SettingsTabs.Profile,
})
void handleOpenAccountUserCenter("/welcome")
}
>
<AccountMenuItemLeading>
@@ -3713,10 +3764,7 @@ export function AppSidebar({
type="button"
aria-label={cloudBrandLabel}
onClick={() =>
handleAccountMenuNavigate({
tab: SettingsTabs.Providers,
providerView: "cloud",
})
void handleOpenAccountUserCenter("/welcome")
}
>
<AccountMenuItemLeading>
File diff suppressed because it is too large Load Diff
@@ -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<typeof import("@/lib/api/skillCatalog")>();
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<HTMLElement> {
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"]',
File diff suppressed because it is too large Load Diff
@@ -262,6 +262,86 @@ describe("ImageTaskViewer", () => {
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}`,
@@ -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}
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
{runtimeContractBadge ? (
<span
data-testid="image-task-viewer-runtime-contract"
className={cn(
"rounded-full border px-2.5 py-1 font-medium",
runtimeContractBadge.tone,
)}
>
{runtimeContractBadge.label}
</span>
) : null}
{runtimeContractRegistryLabel ? (
<span
data-testid="image-task-viewer-runtime-contract-registry"
className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-medium text-slate-600"
>
{runtimeContractRegistryLabel}
</span>
) : null}
{layoutLabel ? (
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 font-medium text-sky-700">
{layoutLabel}
@@ -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({
@@ -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<MessageListProps> = ({
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<MessageListProps> = ({
} | 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<MessageListProps> = ({
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<MessageListProps> = ({
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<MessageListProps> = ({
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<MessageListProps> = ({
setRenderedMessageCount((current) =>
Math.min(
visibleMessages.length,
current + MESSAGE_LIST_RENDER_BATCH_SIZE,
current + progressiveRenderBatchSize,
),
);
},
@@ -661,6 +674,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
}, [
hiddenHistoryCount,
isUserScrolling,
progressiveRenderBatchSize,
shouldUseProgressiveRender,
visibleMessages.length,
]);
@@ -676,10 +690,15 @@ const MessageListInner: React.FC<MessageListProps> = ({
() =>
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<MessageListProps> = ({
? 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<MessageListProps> = ({
].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<MessageListProps> = ({
}, [shouldDeferHistoricalTimeline, timelineHydrationKey]);
const canBuildHistoricalTimeline =
!shouldDeferHistoricalTimeline || isHistoricalTimelineReady;
const timelineByMessageId = useMemo(
() => {
if (!canBuildHistoricalTimeline) {
return new Map<string, MessageTurnTimeline>();
}
const timelineByMessageId = useMemo(() => {
if (!canBuildHistoricalTimeline) {
return new Map<string, MessageTurnTimeline>();
}
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<MessageListProps> = ({
messages: renderedMessages,
turns: renderedTurns,
threadItems: renderedThreadItems,
currentTurnId,
currentTurnId: activeCurrentTurnId,
threadRead,
pendingActions,
queuedTurns,
@@ -774,8 +808,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
isSending,
});
}, [
activeCurrentTurnId,
childSubagentSessions,
currentTurnId,
isSending,
lastAssistantMessageId,
pendingActions,
@@ -786,44 +820,33 @@ const MessageListInner: React.FC<MessageListProps> = ({
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<MessageListProps> = ({
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<MessageListProps> = ({
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<MessageListProps> = ({
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 ? (
@@ -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`
@@ -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 {
@@ -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 (
<Drawer data-testid="home-more-skills-drawer" aria-label="更多做法">
{sections.map((section) => (
<Section key={section.id}>
<SectionTitle>{section.title}</SectionTitle>
<ItemGrid>
{section.items.map((item) => {
const imageUrl = resolveHomeCoverAsset(item.coverToken);
return (
<DrawerItem
key={`${item.sourceKind}-${item.id}`}
type="button"
data-testid={
item.testId ? `home-drawer-${item.testId}` : undefined
}
title={`${item.title} · ${item.summary}`}
onClick={() => onSelectItem(item)}
>
<Cover $token={item.coverToken} aria-hidden>
{imageUrl ? (
<CoverImage
src={imageUrl}
alt=""
decoding="async"
draggable={false}
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
) : null}
</Cover>
<ItemText>
<ItemTitle>/{item.title}</ItemTitle>
<ItemSummary>{item.summary}</ItemSummary>
</ItemText>
</DrawerItem>
);
})}
</ItemGrid>
</Section>
))}
</Drawer>
);
}
@@ -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<typeof import("@/lib/api/skillCatalog")>();
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(<HomeSceneSkillManagerDialog open onClose={vi.fn()} />);
});
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<HTMLInputElement>(
'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("每日复盘");
});
});
@@ -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<SkillCatalogEntry[]>([]);
const [preference, setPreference] =
useState<OemCloudSceneSkillPreference>(emptyPreference);
const [rows, setRows] = useState<ManagerRow[]>([]);
const [customScenes, setCustomScenes] = useState<OemCloudCustomScene[]>([]);
const [draft, setDraft] = useState<DraftScene>(defaultDraft);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLFormElement>) => {
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 (
<Backdrop role="presentation" data-testid="home-scene-skill-manager">
<Dialog
role="dialog"
aria-modal="true"
aria-labelledby="home-scene-skill-manager-title"
>
<Header>
<TitleBlock>
<Title id="home-scene-skill-manager-title">
{view === "list" ? "场景管理" : "新增自定义场景"}
</Title>
<Description>
{view === "list"
? "调整首页场景和 Skills 的顺序、显隐;新增入口会同步到云端。"
: "把已有 SkillCatalog 里的技能包装成更贴近你工作流的起手场景。"}
</Description>
</TitleBlock>
<Button type="button" onClick={onClose}>
</Button>
</Header>
<Body>
{error ? <ErrorNotice role="alert">{error}</ErrorNotice> : null}
{!error && !canSync ? (
<Notice>
</Notice>
) : null}
{view === "list" ? (
<>
<ActionRow>
<Button type="button" onClick={() => setView("create")}>
</Button>
</ActionRow>
{loading ? <Notice> Skills</Notice> : null}
<List aria-label="场景 Skills 列表">
{rows.map((row, index) => (
<Row
key={row.id}
draggable
onDragStart={() => setDraggingId(row.id)}
onDragOver={(event) => event.preventDefault()}
onDrop={() => handleDropOnRow(row.id)}
>
<DragHandle aria-hidden></DragHandle>
<RowTitle>
<RowName>{row.title}</RowName>
<RowMeta>
{row.kind === "scene" ? "场景" : "Skill"} ·{" "}
{row.summary}
</RowMeta>
</RowTitle>
<RowControls>
<IconButton
type="button"
aria-label={`上移 ${row.title}`}
disabled={index === 0}
onClick={() =>
setRows((current) => moveRow(current, row.id, -1))
}
>
</IconButton>
<IconButton
type="button"
aria-label={`下移 ${row.title}`}
disabled={index === rows.length - 1}
onClick={() =>
setRows((current) => moveRow(current, row.id, 1))
}
>
</IconButton>
<ToggleLabel>
<input
type="checkbox"
checked={row.visible}
onChange={(event) => {
const visible = event.currentTarget.checked;
setRows((current) =>
current.map((item) =>
item.id === row.id
? { ...item, visible }
: item,
),
);
}}
/>
</ToggleLabel>
</RowControls>
</Row>
))}
</List>
</>
) : (
<FieldGrid onSubmit={handleCreateSubmit}>
<Field>
<Input
value={draft.title}
placeholder="例如:每日账号复盘"
onChange={(event) =>
setDraft((current) => ({
...current,
title: event.target.value,
}))
}
/>
</Field>
<Field>
<Select
value={draft.linkedEntryId}
onChange={(event) =>
setDraft((current) => ({
...current,
linkedEntryId: event.target.value,
}))
}
>
<option value=""> Skill / </option>
{linkableEntries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.kind === "scene" ? "场景" : "Skill"} ·{" "}
{entry.title}
</option>
))}
</Select>
</Field>
<Field>
<Input
value={draft.placeholder}
placeholder="例如:今天想复盘哪个账号?"
onChange={(event) =>
setDraft((current) => ({
...current,
placeholder: event.target.value,
}))
}
/>
</Field>
<Field>
<Input
value={draft.templateTitle}
onChange={(event) =>
setDraft((current) => ({
...current,
templateTitle: event.target.value,
}))
}
/>
</Field>
<Field>
<Input
value={draft.templateDescription}
placeholder="简短说明这个模板适合什么场景"
onChange={(event) =>
setDraft((current) => ({
...current,
templateDescription: event.target.value,
}))
}
/>
</Field>
<Field>
<TextArea
value={draft.templatePrompt}
placeholder="点击该场景时填入输入框的启动文本"
onChange={(event) =>
setDraft((current) => ({
...current,
templatePrompt: event.target.value,
}))
}
/>
</Field>
<ActionRow>
<Button type="button" onClick={() => setView("list")}>
</Button>
<Button type="submit" $primary>
</Button>
</ActionRow>
</FieldGrid>
)}
</Body>
{view === "list" ? (
<Footer>
<Button type="button" onClick={handleRestoreDefault}>
</Button>
<ActionRow>
<Button type="button" onClick={onClose}>
</Button>
<Button
type="button"
$primary
disabled={saving || loading}
onClick={handleSave}
>
{saving ? "同步中…" : "完成"}
</Button>
</ActionRow>
</Footer>
) : null}
</Dialog>
</Backdrop>
);
}
@@ -0,0 +1,201 @@
import styled from "styled-components";
import type { HomeSkillSurfaceItem } from "./homeSurfaceTypes";
import { resolveHomeCoverAsset } from "./homeCoverAssets";
const GallerySection = styled.section`
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.95rem;
padding-top: 1.15rem;
`;
const GalleryHeader = styled.div`
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 0.9rem;
`;
const HeaderText = styled.div`
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.16rem;
`;
const Title = styled.h2`
margin: 0;
color: var(--lime-text-strong, rgb(15 23 42));
font-size: 18px;
font-weight: 760;
line-height: 1.35;
`;
const Description = styled.p`
margin: 0;
color: var(--lime-text-muted, rgb(100 116 139));
font-size: 12px;
line-height: 1.6;
`;
const Grid = styled.div`
display: grid;
min-width: 0;
gap: 0.8rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
@media (max-width: 1120px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 720px) {
grid-template-columns: minmax(0, 1fr);
}
`;
const Card = styled.button`
display: flex;
min-width: 0;
flex-direction: column;
overflow: hidden;
border-radius: 24px;
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.92));
background: var(--lime-surface, #fff);
padding: 0;
text-align: left;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
&:hover {
border-color: var(--lime-surface-border-strong, rgba(203, 213, 225, 0.98));
box-shadow: 0 18px 44px -36px
var(--lime-shadow-color, rgba(15, 23, 42, 0.3));
transform: translateY(-1px);
}
`;
const Cover = styled.span<{ $token: string }>`
display: block;
overflow: hidden;
height: 112px;
background: ${({ $token }) =>
`radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.36), transparent 20%), ${resolveCoverGradient($token)}`};
`;
const CoverImage = styled.img`
display: block;
height: 100%;
width: 100%;
object-fit: cover;
`;
const Body = styled.span`
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.32rem;
padding: 0.85rem 0.95rem 0.95rem;
`;
const CardTitle = styled.span`
color: var(--lime-text-strong, rgb(15 23 42));
font-size: 14px;
font-weight: 780;
line-height: 1.45;
`;
const CardSummary = styled.span`
display: -webkit-box;
overflow: hidden;
color: var(--lime-text-muted, rgb(100 116 139));
font-size: 12px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
`;
function resolveCoverGradient(token: string): string {
switch (token) {
case "trend":
return "linear-gradient(135deg, #d9d27c, #59633d)";
case "viral":
return "linear-gradient(135deg, #9ed6cd, #2d4a52)";
case "voice":
return "linear-gradient(135deg, #bce8ff, #18384f)";
case "review":
return "linear-gradient(135deg, #bdccb2, #27392f)";
case "rewrite":
return "linear-gradient(135deg, #f2bf88, #3d261f)";
case "draft":
return "linear-gradient(135deg, #f6dfb8, #99613d)";
default:
return "linear-gradient(135deg, #d8e8d0, #5f705b)";
}
}
interface HomeSkillGalleryProps {
items: HomeSkillSurfaceItem[];
onSelectItem: (item: HomeSkillSurfaceItem) => void;
}
export function HomeSkillGallery({
items,
onSelectItem,
}: HomeSkillGalleryProps) {
if (items.length === 0) {
return null;
}
return (
<GallerySection data-testid="home-skill-gallery">
<GalleryHeader>
<HeaderText>
<Title></Title>
<Description>
</Description>
</HeaderText>
</GalleryHeader>
<Grid>
{items.map((item) => {
const imageUrl = resolveHomeCoverAsset(item.coverToken);
return (
<Card
key={`${item.sourceKind}-${item.id}`}
type="button"
data-testid={
item.testId ? `home-gallery-${item.testId}` : undefined
}
title={`${item.title} · ${item.summary}`}
onClick={() => onSelectItem(item)}
>
<Cover $token={item.coverToken} aria-hidden>
{imageUrl ? (
<CoverImage
src={imageUrl}
alt=""
decoding="async"
draggable={false}
onError={(event) => {
event.currentTarget.style.display = "none";
}}
/>
) : null}
</Cover>
<Body>
<CardTitle>{item.title}</CardTitle>
<CardSummary>{item.summary}</CardSummary>
</Body>
</Card>
);
})}
</Grid>
</GallerySection>
);
}
@@ -0,0 +1,140 @@
import { useEffect, useState } from "react";
import styled from "styled-components";
import { HomeStarterChips } from "./HomeStarterChips";
import { HomeMoreSkillsDrawer } from "./HomeMoreSkillsDrawer";
import { HomeSceneSkillManagerDialog } from "./HomeSceneSkillManagerDialog";
import type {
HomeSkillSection,
HomeSkillSurfaceItem,
HomeStarterChip,
} from "./homeSurfaceTypes";
const Surface = styled.div`
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 0.9rem;
`;
const SupplementalRow = styled.div`
display: flex;
min-width: 0;
flex-wrap: wrap;
justify-content: center;
gap: 0.45rem;
`;
const SupplementalButton = styled.button`
display: inline-flex;
min-height: 32px;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid var(--lime-surface-border, rgba(226, 232, 240, 0.9));
background: var(--lime-surface, #fff);
padding: 0.42rem 0.72rem;
color: var(--lime-text, rgb(71 85 105));
font-size: 12px;
font-weight: 650;
line-height: 1;
transition:
border-color 160ms ease,
background-color 160ms ease,
color 160ms ease;
&:hover {
border-color: var(--lime-surface-border-strong, rgba(203, 213, 225, 0.96));
background: var(--lime-surface-soft, rgba(248, 250, 252, 0.98));
color: var(--lime-text-strong, rgb(15 23 42));
}
`;
export interface HomeSupplementalAction {
id: string;
label: string;
title?: string;
testId?: string;
onSelect: () => void;
}
interface HomeStartSurfaceProps {
starterChips: HomeStarterChip[];
sections: HomeSkillSection[];
supplementalActions?: HomeSupplementalAction[];
onSelectStarterChip: (chip: HomeStarterChip) => void;
onSelectSkillItem: (item: HomeSkillSurfaceItem) => void;
}
export function HomeStartSurface({
starterChips,
sections,
supplementalActions = [],
onSelectStarterChip,
onSelectSkillItem,
}: HomeStartSurfaceProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const [managerOpen, setManagerOpen] = useState(false);
useEffect(() => {
if (!drawerOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setDrawerOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [drawerOpen]);
const handleSelectStarterChip = (chip: HomeStarterChip) => {
if (chip.launchKind === "open_drawer") {
setDrawerOpen((current) => !current);
return;
}
if (chip.launchKind === "open_manager") {
setManagerOpen(true);
return;
}
onSelectStarterChip(chip);
};
return (
<Surface data-testid="home-start-surface">
<HomeStarterChips
chips={starterChips}
onSelect={handleSelectStarterChip}
/>
{supplementalActions.length > 0 ? (
<SupplementalRow data-testid="home-supplemental-actions">
{supplementalActions.map((action) => (
<SupplementalButton
key={action.id}
type="button"
data-testid={action.testId}
title={action.title}
onClick={action.onSelect}
>
{action.label}
</SupplementalButton>
))}
</SupplementalRow>
) : null}
<HomeMoreSkillsDrawer
open={drawerOpen}
sections={sections}
onSelectItem={onSelectSkillItem}
/>
<HomeSceneSkillManagerDialog
open={managerOpen}
onClose={() => setManagerOpen(false)}
/>
</Surface>
);
}
@@ -0,0 +1,77 @@
import type { HomeStarterChip } from "./homeSurfaceTypes";
import styled from "styled-components";
const StarterRow = styled.div`
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.2rem 0.25rem 0;
`;
const StarterButton = 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, rgba(47, 83, 60, 0.96))"
: "var(--lime-surface-border, rgba(209, 220, 204, 0.92))"};
background: ${({ $primary }) =>
$primary
? "var(--lime-brand-strong, #2f533c)"
: "var(--lime-surface-soft, rgba(239, 244, 236, 0.96))"};
padding: 0.46rem 0.78rem;
color: ${({ $primary }) =>
$primary ? "#fff" : "var(--lime-text, rgb(71 85 105))"};
font-size: 12px;
font-weight: 700;
line-height: 1;
transition:
border-color 160ms ease,
background-color 160ms ease,
color 160ms ease,
transform 160ms ease;
&:hover {
transform: translateY(-1px);
border-color: ${({ $primary }) =>
$primary
? "var(--lime-brand-strong, #2f533c)"
: "var(--lime-surface-border-strong, rgba(148, 163, 184, 0.72))"};
background: ${({ $primary }) =>
$primary ? "var(--lime-brand-strong, #2f533c)" : "#fff"};
color: ${({ $primary }) =>
$primary ? "#fff" : "var(--lime-text-strong, rgb(15 23 42))"};
}
`;
interface HomeStarterChipsProps {
chips: HomeStarterChip[];
onSelect: (chip: HomeStarterChip) => void;
}
export function HomeStarterChips({ chips, onSelect }: HomeStarterChipsProps) {
return (
<StarterRow data-testid="home-starter-chips" aria-label="首页起手入口">
{chips.map((chip) => (
<StarterButton
key={chip.id}
type="button"
$primary={chip.primary}
data-testid={chip.testId}
aria-label={chip.label === "⚙" ? "管理做法" : chip.label}
title={chip.label === "⚙" ? "管理做法" : chip.label}
onClick={() => onSelect(chip)}
>
{chip.label}
</StarterButton>
))}
</StarterRow>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,146 @@
import type { Skill } from "@/lib/api/skills";
import type { SkillCatalogSceneEntry } from "@/lib/api/skillCatalog";
import type { ServiceSkillHomeItem } from "../service-skills/types";
import type { SceneAppEntryCardItem } from "../sceneappEntryTypes";
import type { CuratedTaskTemplateItem } from "../utils/curatedTaskTemplates";
import type { SlashEntryUsageRecord } from "../skill-selection/slashEntryUsage";
import {
HOME_CATEGORY_LABELS,
HOME_CATEGORY_ORDER,
HOME_STARTER_CHIPS,
} from "./homeSurfaceCopy";
import {
fromCuratedTaskTemplate,
fromInstalledSkill,
fromSceneAppEntry,
fromServiceSkill,
fromSkillCatalogSceneEntry,
} from "./homeSkillSurfaceAdapters";
import type {
HomeSkillCategory,
HomeSkillSection,
HomeSkillSurfaceItem,
} from "./homeSurfaceTypes";
interface BuildHomeSkillSurfaceInput {
curatedTasks: CuratedTaskTemplateItem[];
serviceSkills?: ServiceSkillHomeItem[];
catalogSceneEntries?: SkillCatalogSceneEntry[];
installedSkills?: Skill[];
sceneApps?: SceneAppEntryCardItem[];
slashEntryUsage?: SlashEntryUsageRecord[];
}
function compareHomeItems(
left: HomeSkillSurfaceItem,
right: HomeSkillSurfaceItem,
): number {
if (left.isRecent !== right.isRecent) {
return left.isRecent ? -1 : 1;
}
if (left.usedAt !== right.usedAt) {
return (right.usedAt ?? 0) - (left.usedAt ?? 0);
}
if (left.isRecommended !== right.isRecommended) {
return left.isRecommended ? -1 : 1;
}
return left.title.localeCompare(right.title, "zh-CN");
}
function getUsageByKind(
records: SlashEntryUsageRecord[] | undefined,
kind: SlashEntryUsageRecord["kind"],
): Map<string, SlashEntryUsageRecord> {
return new Map(
(records ?? [])
.filter((record) => record.kind === kind)
.map((record) => [record.entryId, record] as const),
);
}
function uniqueItems(items: HomeSkillSurfaceItem[]): HomeSkillSurfaceItem[] {
const seen = new Set<string>();
return items.filter((item) => {
// 同一任务可能同时来自 curated task 和 service skill,首页只保留一个入口。
const key = item.id;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export function buildHomeSkillItems({
curatedTasks,
serviceSkills = [],
catalogSceneEntries = [],
installedSkills = [],
sceneApps = [],
slashEntryUsage = [],
}: BuildHomeSkillSurfaceInput): HomeSkillSurfaceItem[] {
const installedUsage = getUsageByKind(slashEntryUsage, "skill");
const sceneUsage = getUsageByKind(slashEntryUsage, "scene");
return uniqueItems([
...curatedTasks.map(fromCuratedTaskTemplate),
...catalogSceneEntries.map((entry) =>
fromSkillCatalogSceneEntry(entry, sceneUsage.get(entry.id)),
),
...serviceSkills.map((skill) =>
fromServiceSkill(
skill,
sceneUsage.get(skill.sceneBinding?.sceneKey ?? "") ??
sceneUsage.get(skill.id),
),
),
...installedSkills.map((skill) =>
fromInstalledSkill(skill, installedUsage.get(skill.key)),
),
...sceneApps.map((item) =>
fromSceneAppEntry(item, sceneUsage.get(item.id)),
),
]).sort(compareHomeItems);
}
export function buildHomeSkillSections(
items: HomeSkillSurfaceItem[],
): HomeSkillSection[] {
const recentItems = items
.filter((item) => item.isRecent)
.sort(compareHomeItems)
.slice(0, 6);
return HOME_CATEGORY_ORDER.map((category) => {
const sectionItems =
category === "recent"
? recentItems
: items
.filter((item) => item.category === category)
.sort(compareHomeItems)
.slice(0, 8);
return {
id: category,
title: HOME_CATEGORY_LABELS[category],
items: sectionItems,
};
}).filter((section) => section.items.length > 0);
}
export function buildHomeGalleryItems(
items: HomeSkillSurfaceItem[],
category: HomeSkillCategory | "all" = "all",
): HomeSkillSurfaceItem[] {
return items
.filter((item) => category === "all" || item.category === category)
.sort(compareHomeItems)
.slice(0, 12);
}
export function buildHomeStarterChips() {
return HOME_STARTER_CHIPS;
}
@@ -0,0 +1,50 @@
// 首页封面走 public 绝对路径,保持与 logo 一致,避免桌面 WebView 中 CSS import URL 失效。
const HOME_COVER_ASSETS: Record<string, string> = {
"account-performance-tracking":
"/home-covers/home-cover-account-performance-tracking.jpg",
"article-to-slide-video-outline":
"/home-covers/home-cover-article-to-slide-video-outline.jpg",
"carousel-post-replication":
"/home-covers/home-cover-carousel-post-replication.jpg",
"cloud-video-dubbing": "/home-covers/home-cover-cloud-video-dubbing.jpg",
"daily-trend-briefing": "/home-covers/home-cover-trend.jpg",
draft: "/home-covers/home-cover-draft.jpg",
review: "/home-covers/home-cover-review.jpg",
rewrite: "/home-covers/home-cover-rewrite.jpg",
scene: "/home-covers/home-cover-short-video-script-replication.jpg",
service: "/home-covers/home-cover-review.jpg",
sky: "/home-covers/home-cover-article-to-slide-video-outline.jpg",
"short-video-script-replication":
"/home-covers/home-cover-short-video-script-replication.jpg",
trend: "/home-covers/home-cover-trend.jpg",
"video-dubbing-language":
"/home-covers/home-cover-video-dubbing-language.jpg",
viral: "/home-covers/home-cover-viral.jpg",
voice: "/home-covers/home-cover-voice.jpg",
};
export function resolveHomeCoverAsset(token: string): string | undefined {
const normalized = token.trim().toLowerCase();
if (HOME_COVER_ASSETS[normalized]) {
return HOME_COVER_ASSETS[normalized];
}
if (/carousel|post|social|xiaohongshu|内容/.test(normalized)) {
return HOME_COVER_ASSETS["carousel-post-replication"];
}
if (/script|short-video|video/.test(normalized)) {
return HOME_COVER_ASSETS["short-video-script-replication"];
}
if (/dub|voice|audio|language|配音/.test(normalized)) {
return HOME_COVER_ASSETS["cloud-video-dubbing"];
}
if (/slide|article|outline|knowledge/.test(normalized)) {
return HOME_COVER_ASSETS["article-to-slide-video-outline"];
}
if (/account|growth|tracking|review/.test(normalized)) {
return HOME_COVER_ASSETS["account-performance-tracking"];
}
if (/trend|brief|report/.test(normalized)) {
return HOME_COVER_ASSETS.trend;
}
return HOME_COVER_ASSETS.review;
}

Some files were not shown because too many files have changed in this diff Show More