chore: release v1.24.0
@@ -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`
|
||||
|
||||
|
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 8:Browser Assist 底层合同注入
|
||||
|
||||
状态:进行中。
|
||||
|
||||
本阶段输出:
|
||||
|
||||
1. `browser_control` 先作为底层 Browser Assist 运行合同进入 `harness.browser_assist`,不新增独立 browser task 协议。
|
||||
2. `@浏览器 / @Browser Agent / @Mini Tester` 仍保留原始用户消息,只把 `browser_requirement`、launch URL 与 contract snapshot 交给后端 runtime 决策。
|
||||
3. `browser_control.bound_entries` 暂不扩展;`@` 命令只是入口层,不能成为底层 contract 事实源。
|
||||
4. 后端 `BrowserAssistRuntimeHint` 必须解析并保留 `browser_control` 合同快照,browser tool result metadata 需要携带同一组 contract 字段。
|
||||
5. Evidence Pack 从 browser tool timeline metadata 导出 `browser_control` 的 `modalityRuntimeContracts`,使浏览器动作也进入 replay 可用的事实源。
|
||||
6. Replay / grader 必须把 `browser_control` 识别成 Browser Assist 回归样本,检查 browser action trace、禁止 WebSearch 替代真实浏览器动作。
|
||||
|
||||
### Phase 9:PDF Extract 底层合同注入
|
||||
|
||||
状态:进行中。
|
||||
|
||||
本阶段输出:
|
||||
|
||||
1. `pdf_extract` 作为文档型底层运行合同进入 `harness.pdf_read_skill_launch.pdf_read_request`,`@读PDF` 仅保留为 entry source。
|
||||
2. 前端 `@读PDF` 发送时保留原始消息,并注入 `modality_contract_key=pdf_extract`、`required_capabilities`、`routing_slot=base_model` 与 `runtime_contract` 快照。
|
||||
3. 后端 `prepare_pdf_read_skill_launch_request_metadata` 必须补齐并保留同一组合同字段,避免旧客户端或缺省 metadata 退回普通聊天总结。
|
||||
4. `Skill(pdf_read)` prompt 必须显式要求真实读取文件、保留合同字段,并禁止 `frontend_direct_pdf_parse` / `generic_chat_summary_only` 偏航。
|
||||
5. Evidence Pack 必须能从 `Skill(pdf_read)` timeline metadata 导出 `pdf_extract` 的 `modalityRuntimeContracts`,使 PDF 读取不再只停留在入口 prompt 合同里。
|
||||
6. Replay / grader 必须把 `pdf_extract` 识别成 PDF 读取回归样本,检查 `Skill(pdf_read)` / 文件读取 trace,并禁止 WebSearch、ToolSearch、Grep 或普通聊天替代真实读取。
|
||||
7. 本阶段暂不把 PDF 单独建成 task artifact 协议;如果后续需要 PDF viewer / artifact UI,再从同一份 `pdf_extract` contract 继续下沉,不能新增平行事实源。
|
||||
8. 完成底层闭环后,`@读PDF / @pdf / @read_pdf` 只登记为 `pdf_extract.bound_entries`,前端从 registry 读取 `entry_source`,不再把入口当作运行合同事实源。
|
||||
|
||||
### Phase 10:Voice Generation 服务型合同注入
|
||||
|
||||
状态:进行中。
|
||||
|
||||
本阶段输出:
|
||||
|
||||
1. `voice_generation` 作为音频型底层运行合同进入 `harness.service_scene_launch.service_scene_run`,`@配音` 仅保留为 entry source。
|
||||
2. 前端 `@配音 / @voice / @dubbing` 发送时保留原始消息,并注入 `modality_contract_key=voice_generation`、`required_capabilities`、`routing_slot=voice_generation_model` 与 `runtime_contract` 快照。
|
||||
3. 后端 `prepare_service_scene_launch_request_metadata` 对 `scene_key=voice_runtime` / `entry_source=at_voice_command` 的旧客户端 metadata 补齐同一组合同字段。
|
||||
4. 服务型场景 prompt 必须显式说明当前底层合同,禁止退回 `legacy_tts_test_command` 或伪造“云端已提交”。
|
||||
5. Evidence Pack 必须能从 voice runtime / service scene timeline args 或 metadata 导出 `voice_generation` 的 `modalityRuntimeContracts`,使配音合同进入 replay 可评分事实源。
|
||||
6. Replay / grader 必须把 `voice_generation` 识别成本地 ServiceSkill/voice runtime 回归样本,检查 `service_scene_launch(scene_key=voice_runtime)` 或后续 audio_task/audio_output 证据,并禁止 `legacy_tts_test_command`、伪造云端提交、普通聊天文本或通用文件卡替代。
|
||||
7. 本阶段新增最小 `audio_generate` task artifact 协议,把 `voice_generation` 写入标准 `audio_task/audio_output` 产物;`audio viewer` 与真实音频 worker 继续后置,避免提前拆出第二套音频执行事实源。
|
||||
|
||||
### Phase 11:Web Research 合同注入
|
||||
|
||||
状态:进行中。
|
||||
|
||||
本阶段输出:
|
||||
|
||||
1. `web_research` 作为混合型底层运行合同进入 `harness.research_skill_launch.research_request`、`harness.deep_search_skill_launch.deep_search_request`、`harness.site_search_skill_launch.site_search_request` 与 `harness.report_skill_launch.report_request`。
|
||||
2. `@搜索 / @深搜 / @站点搜索 / @研报 / @竞品` 仅保留为 entry source;前端发送时注入 `modality_contract_key=web_research`、`required_capabilities`、`routing_slot=report_generation_model` 与 `runtime_contract` 快照。
|
||||
3. 后端 `prepare_*_skill_launch_request_metadata` 需要为旧客户端或缺省 metadata 补齐同一组合同字段,避免回退成模型记忆回答、本地文件搜索或通用 WebSearch 旁路。
|
||||
4. research / deep search prompt 必须显式要求首刀 `Skill(research)`,site search prompt 必须说明它是 `web_research` 合同下的站点搜索子入口且首刀 `Skill(site_search)`。
|
||||
5. Evidence Pack 必须能从 `Skill(research)` / `Skill(site_search)` / `Skill(report_generate)` timeline args 或 metadata 导出 `web_research` 的 `modalityRuntimeContracts`,使联网研究进入 replay 可评分事实源。
|
||||
6. Evidence Pack 的 `modalityRuntimeContracts.snapshotIndex` 必须提供可检索摘要:按 contract key、source、routing outcome、expected routing slot 与 tool trace 汇总,使联网研究不只能靠 raw snapshots 人工查找。
|
||||
7. Replay / grader 必须把 `web_research` 识别成联网研究回归样本,检查 `Skill(research)` / `Skill(site_search)` / `Skill(report_generate)`、`search_query` / `lime_site_*` 工具时间线,并禁止模型记忆、本地文件搜索、ToolSearch 或通用 WebSearch 旁路替代真实研究。
|
||||
8. 本阶段不新增独立 `report_generation` contract;`@研报 / @竞品` 先作为 `web_research` 下的报告型子入口,后续只有当 report artifact、执行器或 viewer 形成独立事实源时才拆子合同。
|
||||
|
||||
### Phase 12:Text Transform 合同注入
|
||||
|
||||
状态:进行中。
|
||||
|
||||
本阶段输出:
|
||||
|
||||
1. `text_transform` 作为文档型底层运行合同进入 `harness.summary_skill_launch.summary_request`、`harness.translation_skill_launch.translation_request` 与 `harness.analysis_skill_launch.analysis_request`。
|
||||
2. `@读文件 / @总结 / @翻译 / @分析 / @发布合规 / @Logo拆解` 仅保留为 entry source;前端发送时注入 `modality_contract_key=text_transform`、`required_capabilities`、`routing_slot=base_model` 与 `runtime_contract` 快照。
|
||||
3. 后端 `prepare_*_skill_launch_request_metadata` 需要为旧客户端或缺省 metadata 补齐同一组合同字段,避免回退成前端直出文本、ToolSearch、WebSearch 或普通聊天摘要。
|
||||
4. summary / translation / analysis prompt 必须显式要求首刀 `Skill(summary)` / `Skill(translation)` / `Skill(analysis)`,显式文件路径场景可保留 `list_directory` / `read_file` 证据,但不能绕过底层合同。
|
||||
5. Evidence Pack 必须能从 `Skill(summary)` / `Skill(translation)` / `Skill(analysis)` timeline args 或 metadata 导出 `text_transform` 的 `modalityRuntimeContracts`,使轻量文本/文档转换也进入 replay 可评分事实源。
|
||||
6. Replay / grader 必须把 `text_transform` 识别成文本转换回归样本,检查文本转换 Skill trace 与必要文件读取证据,并禁止 `frontend_direct_text_transform`、ToolSearch、WebSearch 或普通聊天替代。
|
||||
7. 本阶段不拆 `summary_generation`、`translation`、`analysis`、`publish_compliance` 或 `logo_decomposition` 平行 contract;只有当独立 artifact、执行器或 viewer 形成稳定事实源时才拆子合同。
|
||||
|
||||
## 本轮最小闭环
|
||||
|
||||
第一轮已完成治理底座;当前纠偏后的最小闭环必须至少贯通一个真实运行时 vertical slice。
|
||||
@@ -81,12 +162,31 @@ runtime identity
|
||||
3. `lime_create_image_generation_task` 工具 schema、别名归一化与 request builder 接受并补齐 contract 字段。
|
||||
4. `image_generate` task artifact payload 固化同一组 contract 字段,artifact 成为图片生成 contract 的 truth source。
|
||||
5. DevBridge / browser mock 与前端 `CreateImageGenerationTaskArtifactRequest` 类型同步 contract 字段,避免浏览器链路变成假绿。
|
||||
6. 图片工作台 viewer 从 task artifact / 工作台状态读取 `runtimeContract`,展示“按合同路由 / 合同阻止 / registry 能力缺口”。
|
||||
7. `browser_control` 第九刀从 registry 解析 `browser_reasoning_model` 合同,并写入 `harness.browser_assist`,使 Browser Assist 首刀能看到 `modality_contract_key=browser_control`、`required_capabilities`、`routing_slot` 与 `runtime_contract` 快照。
|
||||
8. `browser_control` 第十/十一刀在 Rust 侧解析同一份合同,并把 browser tool timeline metadata 纳入 evidence `modalityRuntimeContracts`,不再只停留在前端 request metadata。
|
||||
9. `browser_control` 第十二刀把 evidence 快照继续写入 replay tags / expected checks / grader,要求回放继续走 Browser Assist、产生 browser action trace,并显式禁止 WebSearch 替代。
|
||||
10. `pdf_extract` 第十三刀把 `@读PDF` 上层入口接到底层 `Skill(pdf_read)` 运行合同,前端和 Rust 侧都会保留同一份 contract snapshot。
|
||||
11. `pdf_extract` 第十四刀把 `Skill(pdf_read)` timeline metadata 纳入 evidence `modalityRuntimeContracts`,并让 replay / grader 继续检查 PDF 读取 trace,形成入口 -> executor -> evidence -> replay 的闭环。
|
||||
12. `pdf_extract` 第十五刀把 `@读PDF / @pdf / @read_pdf` 收为 registry entry binding,前端发送只消费 registry 派生的 `entry_source`,入口层不再拥有底层 contract 常量。
|
||||
13. `voice_generation` 第十六刀把 `@配音` 服务型入口接到底层 `voice_runtime` 运行合同,前端与 Rust prompt 侧都会保留同一份 contract snapshot。
|
||||
14. `voice_generation` 第二十二刀把 voice runtime / service scene timeline args 或 metadata 纳入 evidence `modalityRuntimeContracts`,并让 replay / grader 继续检查 `service_scene_launch(scene_key=voice_runtime)` 或后续 audio_task/audio_output 证据。
|
||||
15. `web_research` 第十七刀把 `@搜索 / @深搜 / @站点搜索` 收为 registry entry binding,并把三条 research launch metadata 与 Rust prompt 收敛到同一份 `web_research` contract snapshot。
|
||||
16. `web_research` 第十八刀把 `Skill(research)` / `Skill(site_search)` tool timeline args 或 metadata 纳入 evidence `modalityRuntimeContracts`,并让 replay / grader 继续检查联网研究 executor trace,形成入口 -> executor -> evidence -> replay 的闭环。
|
||||
17. `web_research` 第十九刀把 evidence `modalityRuntimeContracts` 从 raw snapshots 扩展为 `snapshotIndex`,提供 contract/source/routing/tool trace 汇总,使联网研究合同进入可检索索引层。
|
||||
18. `web_research` 第二十刀把 `@研报 / @竞品` 收为报告型子入口:入口仍走 `report_skill_launch -> Skill(report_generate)`,底层合同、evidence、replay 统一归入 `web_research`。
|
||||
19. `text_transform` 第二十一刀把 `@读文件 / @总结 / @翻译 / @分析 / @发布合规 / @Logo拆解` 收为文本/文档转换子入口:入口仍分别走 `summary_skill_launch`、`translation_skill_launch`、`analysis_skill_launch`,底层合同、evidence、replay 统一归入 `text_transform`。
|
||||
20. `voice_generation` 第二十三刀新增最小 `audio_generate` artifact protocol:`create_audio_generation_task_artifact` / `lime_create_audio_generation_task` 写入标准 `audio_task/audio_output` payload,前端 API、DevBridge mock、task index、evidence pack 与 replay grader 都消费同一份 `voice_generation` 合同快照。
|
||||
|
||||
暂不做:
|
||||
|
||||
1. 暂不把所有 `@`、按钮、Scene 批量登记到 `bound_entries`;仍等 Phase 7。
|
||||
2. 暂不改 viewer 或 workspace UI 视觉面。
|
||||
3. 暂不把 LimeCore 写成默认执行器;LimeCore 仍只作为 catalog / policy / offer / audit 控制面。
|
||||
1. 暂不把所有 `@`、按钮、Scene 批量登记到 `bound_entries`;本轮只绑定 `image_generation` 第一条 vertical slice。
|
||||
2. 暂不把 LimeCore 写成默认执行器;LimeCore 仍只作为 catalog / policy / offer / audit 控制面。
|
||||
3. 暂不批量改造视频、语音、PDF、搜索等其他 viewer;先让 `image_generation` vertical slice 收口。
|
||||
4. 暂不新增 PDF task artifact / reader viewer;PDF 本轮只收 executor trace、evidence 和 replay,避免在底层事实源未完全稳定前再开一套产物协议。
|
||||
5. 暂不新增 audio viewer / 真实音频 worker;voice 本轮只落最小 `audio_generate` artifact protocol,不伪造云端提交,也不把普通聊天文本或通用文件卡当作音频产物。
|
||||
6. 暂不新增独立 `report_generation` 合同;`@研报 / @竞品` 继续走 `report_skill_launch -> Skill(report_generate)` 主链,但其底层能力归属先收敛到 `web_research`,避免把 report artifact 协议提前扩张成第二套事实源。
|
||||
7. 暂不新增独立 `summary_generation`、`translation`、`analysis`、`publish_compliance` 或 `logo_decomposition` 合同;这组轻量文本/文档转换入口先统一收敛到 `text_transform`,避免把上层 `@` 命令提前扩张成平行底层事实源。
|
||||
|
||||
## 分类
|
||||
|
||||
@@ -137,3 +237,31 @@ runtime identity
|
||||
- 2026-04-29:继续 `image_generation` vertical slice 第五刀:图片任务 worker 的模型能力 preflight 从纯字符串启发式推进到 `ModelRegistryState` / `EnhancedModelMetadata`,优先按 `task_families` 与 `output_modalities` 判断 `ImageGeneration` / `ImageEdit` / `Image` 输出能力;只有 registry 找不到模型时才回退到文本模型启发式,避免 `gpt-5.2` 这类普通文本模型误进图片执行器,同时允许 registry 明确声明的图片模型通过。
|
||||
- 2026-04-29:第五刀进一步把 registry 判定结果持久化为 `image_generate` task payload 的 `model_capability_assessment`,并同步进入 evidence `modalityRuntimeContracts.snapshots[].modelCapabilityAssessment` 与 replay 的 `modalityContractChecks`,让后续回归能区分 `model_registry` 判定和 heuristic fallback,而不是只看到最终失败码。
|
||||
- 2026-04-29:第五刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml assess_image_generation_model_capability_should_use_output_modality`、`assess_image_generation_model_capability_should_reject_registered_text_model`、`assess_image_generation_model_capability_should_match_provider_model_id`、`validate_image_generation_task_execution_contract_should_reject_registry_text_model`、`validate_image_generation_task_execution_contract_should_trust_registry_image_model`、`patch_image_task_model_capability_assessment_should_persist_registry_snapshot`、`should_export_modality_runtime_contract_snapshot_from_failed_image_task`、`should_carry_modality_runtime_contract_into_replay_case`、`npm run test:contracts` 与本轮触达文件 `git diff --check`。
|
||||
- 2026-04-29:继续 `image_generation` vertical slice 第六刀:`list_media_task_artifacts` 现在可以按 `modalityContractKey` 与 `routingOutcome` 筛选图片任务,并在返回结果中附带 `modality_runtime_contracts` 索引摘要;索引包含 contract keys、routing outcome 计数、blocked 计数、registry assessment 计数与每个 task 的 routing snapshot,使 task index 成为多模态运行合同的可检索事实源,而不是只能靠单个 task file 或 evidence pack 反查。
|
||||
- 2026-04-29:第六刀同步前端 `ListMediaTaskArtifactsRequest` / `ListMediaTaskArtifactsOutput` 类型与浏览器 fallback mock;图片任务恢复测试继续使用统一媒体任务接口,mock 也返回相同 `modality_runtime_contracts` 结构,避免浏览器链路假绿。
|
||||
- 2026-04-29:第六刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml list_media_task_artifacts_inner_should_index_modality_contract_routing_blocks`、`media_task_artifact_controls_should_share_same_task_file_protocol`、`npm test -- src/lib/api/mediaTasks.test.ts src/lib/tauri-mock/core.test.ts src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`、`npm run typecheck`、`npm run test:contracts` 与本轮触达文件 `git diff --check`。
|
||||
- 2026-04-29:继续 `image_generation` vertical slice 第七刀:前端图片任务恢复 runtime 现在会从 task artifact payload / `last_error` 解析 `runtimeContract` 快照,写入 `ImageWorkbenchTask` 与 `MessageImageWorkbenchPreview`;图片工作台 viewer 增加轻量胶囊标签,区分“运行合同 · 已按 image_generation 路由”“运行合同阻止 · image_generation_model_capability_gap”以及“模型能力来自 model_registry · 不支持图片生成”,让第六刀 task index 的路由事实进入用户可见 viewer。
|
||||
- 2026-04-29:第七刀验证通过 `npm test -- src/components/agent/chat/components/ImageTaskViewer.test.tsx src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。
|
||||
- 2026-04-29:继续 `image_generation` vertical slice 第八刀:`image_generation.bound_entries` 现在登记 `@配图 / @image / /image / @Vision 1`、`@海报 / @Flyer 3`、`@修图`、`@重绘`、`@分镜`、文稿正文配图和图片工作台续做入口;entry binding 只声明 `entry_source`、`launch_metadata_path` 和 input mapping,不拥有 task / artifact / viewer / evidence 事实源。
|
||||
- 2026-04-29:第八刀新增 `src/lib/governance/modalityRuntimeContracts.ts`,前端 `imageSkillLaunch.ts` 改为从 registry 读取 `contractKey / modality / requiredCapabilities / routingSlot / runtimeContract`,避免上层入口继续硬编码底层 contract;守卫同步要求 entry binding 声明 `entry_source` 且 launch metadata 留在 `harness.*`。
|
||||
- 2026-04-29:第八刀验证通过 `npm run governance:modality-contracts`、`npm test -- src/lib/governance/modalityRuntimeContracts.test.ts`、`npm test -- src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。
|
||||
- 2026-04-29:继续第九刀 `browser_control`:泛化 `ModalityRuntimeContract` registry resolver,新增 Browser Assist launch metadata helper;`@浏览器 / @Browser Agent / @Mini Tester` 发送时保留原始消息并关闭 WebSearch,同时把 `browser_control` 合同快照注入 `harness.browser_assist`,`buildHarnessRequestMetadata` 负责保留这些底层合同字段并补齐 profile / backend / auto launch。
|
||||
- 2026-04-29:第九刀验证通过 `npm test -- src/lib/governance/modalityRuntimeContracts.test.ts src/components/agent/chat/utils/harnessRequestMetadata.test.ts src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx`、`npm run governance:modality-contracts`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 whitespace / `git diff --check`。
|
||||
- 2026-04-29:继续第十刀 `browser_control` 后端消费:新增 Rust 侧 `BROWSER_CONTROL_*` contract 常量与 `browser_control_runtime_contract()`,`BrowserAssistRuntimeHint` 现在会从 `harness.browser_assist` 解析 `modality_contract_key=browser_control`、`required_capabilities`、`routing_slot`、`runtime_contract` 与 `entry_source`;`LimeBrowserMcpTool` 成功或失败结果都会把这组字段写入 tool result metadata,让 Browser Assist 动作进入 runtime timeline 事实源。
|
||||
- 2026-04-29:继续第十一刀 `browser_control` evidence:`runtime_evidence_pack_service` 现在会从 browser tool call metadata 导出 `modalityRuntimeContracts` snapshot,`source=browser_action_trace.modality_runtime_contract`、`routingEvent=browser_action_requested`、`routingOutcome=accepted`,使 Browser Assist 不再只能依赖图片 task artifact 的合同导出路径。
|
||||
- 2026-04-29:第十/十一刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml test_extract_browser_assist_runtime_hint_from_harness_metadata`、`test_lime_browser_tool_attaches_modality_contract_metadata`、`should_export_browser_control_contract_snapshot_from_tool_metadata`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。
|
||||
- 2026-04-29:继续第十二刀 `browser_control` replay/grader:`runtime_replay_case_service` 现在会把 `browser_control` 合同识别为 Browser Assist 回归样本,补 `browser-control`、`browser-assist`、`browser-action-trace` suite tags;`expected.json` / `grader.md` 会要求继续使用 `mcp__lime-browser__*`,禁止 WebSearch / 普通聊天替代真实浏览器动作,并检查 `browser_action_trace` / `browser_action_requested` 证据。
|
||||
- 2026-04-29:第十二刀验证通过 `CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml should_carry_browser_control_contract_into_replay_grader_checks`、`should_carry_modality_runtime_contract_into_replay_case`、`npm run test:contracts`、`npm run verify:gui-smoke` 与本轮触达文件 `git diff --check`。
|
||||
- 2026-04-29:继续第十三刀 `pdf_extract`:新增前端 registry resolver `resolvePdfExtractRuntimeContractBinding()`,`@读PDF` 发送时在 `harness.pdf_read_skill_launch.pdf_read_request` 注入 `pdf_extract` contract 快照;Rust 侧新增 `PDF_EXTRACT_*` 常量、`pdf_extract_runtime_contract()` 与 `insert_pdf_extract_contract_fields()`,`prepare_pdf_read_skill_launch_request_metadata` 会把缺省合同字段补齐到 launch 与 request,系统 prompt 显式要求 Skill(pdf_read) 保留 contract 字段、真实读取 PDF,并禁止 `frontend_direct_pdf_parse` / `generic_chat_summary_only`。
|
||||
- 2026-04-29:第十三刀验证通过 `npm test -- src/lib/governance/modalityRuntimeContracts.test.ts src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx`、`npm run governance:modality-contracts`、`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke`、`cargo fmt --manifest-path src-tauri/Cargo.toml --check`、`CARGO_TARGET_DIR=src-tauri/target/codex-modality-contract cargo test --manifest-path src-tauri/Cargo.toml pdf_read_skill_launch`、`cargo test --manifest-path src-tauri/Cargo.toml` 与本轮触达文件 `git diff --check`;`npm run verify:local` 初跑曾在全量 Rust 阶段暴露 PDF 断言误落到 resource_search 测试,已修复并用全量 `cargo test` 复验通过。
|
||||
- 2026-04-29:继续第十四刀 `pdf_extract` evidence/replay:`runtime_evidence_pack_service` 现在会从 `Skill(pdf_read)` tool timeline metadata 或其 `args.pdf_read_request` 合同快照导出 `pdf_extract` 的 `modalityRuntimeContracts`,`source=pdf_read_skill_trace.modality_runtime_contract`、`routingEvent=executor_invoked`、`routingOutcome=accepted/failed`、`expectedRoutingSlot=base_model`。
|
||||
- 2026-04-29:第十四刀同步 `runtime_replay_case_service`:replay tags 增加 `modality-pdf_extract`、`pdf-extract`、`pdf-read-skill`、`pdf-read-trace`,`expected.json` / `grader.md` 明确要求继续走 `Skill(pdf_read)` 或真实 `list_directory` / `read_file` 文件读取证据,并禁止 `frontend_direct_pdf_parse`、`generic_chat_summary_only`、ToolSearch、WebSearch 或 Grep 替代真实读 PDF。
|
||||
- 2026-04-29:继续第十五刀 `pdf_extract` entry binding:`modalityRuntimeContracts.json` 现在登记 `at_pdf_read_command`,覆盖 `@读PDF / @pdf / @read_pdf`,并把 launch path 固定为 `harness.pdf_read_skill_launch.pdf_read_request`;`useWorkspaceSendActions` 从 `resolvePdfExtractRuntimeContractBinding().boundEntrySources` 读取 `entry_source`,只保留 `PDF_EXTRACT_DEFAULT_ENTRY_SOURCE` 作为 registry 缺省 fallback。
|
||||
- 2026-04-29:继续第十六刀 `voice_generation`:新增前端 registry resolver `resolveVoiceGenerationRuntimeContractBinding()`,`voice_generation.bound_entries` 登记 `at_voice_command`;`@配音` 发送时在 `harness.service_scene_launch.service_scene_run` 注入 `voice_generation` contract 快照,后端 `prepare_service_scene_launch_request_metadata` 也会为 `voice_runtime` 旧 metadata 补齐合同字段,服务型场景 prompt 会输出底层合同、所需能力与 `runtime_contract(JSON)`,并禁止退回 `legacy_tts_test_command`。
|
||||
- 2026-04-29:继续第十七刀 `web_research`:新增前端 registry resolver `resolveWebResearchRuntimeContractBinding()`,`web_research.bound_entries` 登记 `at_search_command`、`at_deep_search_command` 与 `at_site_search_command`;`@搜索 / @深搜 / @站点搜索` 发送时分别在 research / deep search / site search request 中注入 `web_research` contract 快照,Rust 侧 prepare 函数也会把缺省合同字段补齐到 launch 与 request,prompt 显式输出底层合同、所需能力与 `runtime_contract(JSON)`,并禁止退回模型记忆、本地文件搜索、通用 WebSearch 或 ToolSearch 偏航。
|
||||
- 2026-04-30:继续第十八刀 `web_research` evidence/replay:`runtime_evidence_pack_service` 现在会从 `Skill(research)` / `Skill(site_search)` tool timeline 的 args 或 metadata 导出 `web_research` 的 `modalityRuntimeContracts`,`source=web_research_skill_trace.modality_runtime_contract`、`routingEvent=executor_invoked`、`routingOutcome=accepted/failed`、`expectedRoutingSlot=report_generation_model`;`runtime_replay_case_service` 同步增加 `web-research`、`research-skill`、`web-research-trace` tags,并在 expected / grader 中要求继续走 `Skill(research)` / `Skill(site_search)` 与真实 search_query / `lime_site_*` 证据,禁止 `model_memory_only_answer`、`local_file_search_before_research_skill`、ToolSearch 或普通聊天替代。
|
||||
- 2026-04-30:继续第十九刀 `web_research` 索引化:`runtime_evidence_pack_service` 的 `modalityRuntimeContracts` 现在输出 `snapshotIndex`,汇总 `contractKeys`、`sourceCounts`、`routingOutcomeCounts`、`expectedRoutingSlots` 与 `toolTraceIndex.items`;`web_research` replay input 会携带同一份索引,使回归样本能直接定位 `executorBindingKey=research`、`entrySource=at_search_command` 与 `web_research_skill_trace`,不再只能扫描 raw snapshots。
|
||||
- 2026-04-30:继续第二十刀 `web_research` 报告型子入口:`web_research.bound_entries` 纳入 `at_report_command` 与 `at_competitor_command`,前端 `@研报 / @竞品` 发送会在 `harness.report_skill_launch.report_request` 注入同一份 `web_research` contract;Rust `report_skill_launch` prepare / prompt 也补齐合同字段,同时保留首刀 `Skill(report_generate)`,并把 evidence / replay 扩展到 `report_generate` trace,避免另开 `report_generation` 平行事实源。
|
||||
- 2026-04-30:继续第二十一刀 `text_transform`:新增 `text_transform` contract 并登记 `at_file_read_command`、`at_summary_command`、`at_translation_command`、`at_analysis_command`、`at_publish_compliance_command`、`at_logo_decomposition_command`;前端 `@读文件 / @总结 / @翻译 / @分析 / @发布合规 / @Logo拆解`、Rust summary/translation/analysis prepare 与 prompt、evidence `text_transform_skill_trace`、replay tags / expected / grader 已统一收敛到同一份底层合同,入口层不再新增 summary / translation / analysis 平行事实源。
|
||||
- 2026-04-30:继续第二十二刀 `voice_generation` evidence/replay:`runtime_evidence_pack_service` 现在会从 voice runtime / service scene timeline args 或 metadata 导出 `voice_generation` 的 `modalityRuntimeContracts`,`source=voice_generation_service_scene_trace.modality_runtime_contract`、`routingEvent=executor_invoked`、`routingOutcome=accepted/failed`、`expectedRoutingSlot=voice_generation_model`;`runtime_replay_case_service` 同步增加 `voice-generation`、`voice-runtime`、`voice-generation-trace` tags,并在 expected / grader 中要求继续走 `service_scene_launch(scene_key=voice_runtime)` / 本地 ServiceSkill runtime 或后续 audio_task/audio_output 证据,禁止 `legacy_tts_test_command`、伪造云端提交、普通聊天文本或通用文件卡替代。
|
||||
- 2026-04-30:继续第二十三刀 `voice_generation` audio artifact protocol:新增 `audio_generate` task type、`create_audio_generation_task_artifact` Tauri 命令与 `lime_create_audio_generation_task` Agent tool,只写入标准 `audio_task/audio_output` 产物,不生成真实音频、不提交云端;前端 API、DevBridge mock、`list_media_task_artifacts` contract index、evidence `audio_task.modality_runtime_contract` 与 replay grader 已同步消费同一份 `voice_generation` 合同快照,下一刀再补 audio viewer / 真实 worker。
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
- `P2` 已推进:加载更多历史已从递增 tail window 改为分页加载;首屏返回最近 `40` 条并带 `history_cursor.oldest_message_id`,加载更早历史时优先用 `historyBeforeMessageId` cursor,每次请求 `50` 条,`historyOffset` 保留为兼容 fallback;历史会话的 MessageList 首帧先渲染文本,timeline 在 idle 后补齐;timeline turn 绑定减少中间数组分配;折叠的 `AgentThreadTimeline` 明细改为展开时再物化;旧历史中已完成的单步 timeline 明细默认只渲染摘要,展开时再物化;已排序的 turn/thread item 不再重复复制排序。
|
||||
- `2026-04-29` 追加收口:过大的旧会话 transient / persisted snapshot 在恢复前直接丢弃;读取命中的会话快照不再同步刷新 `lastAccessedAt` 以避免点击时重写整张 snapshot map;历史消息与流式 text_delta 统一做 overlap 合并,修复累计快照式 delta 导致的重复吐字;完成态旧消息的执行过程与正文视觉分离,历史 timeline 默认折叠明细;浏览器 DevBridge 模式下跳过低优先级 metadata backfill,并且仅在活跃运行时订阅 team SSE,避免旧会话点击后抢占 bridge 连接。
|
||||
|
||||
- `2026-04-29` 追加 GUI 收口:复现旧会话顶部 `+` 新建后新 tab 仍显示旧消息的问题;`createFreshSession` 现在显式清空新建会话消息快照,同时 `ensureSession` 的首轮发送保留本地草稿,避免发送链路回归;旧会话 MessageList 仅在活跃发送/等待态底部锚定,已完成历史默认吸顶。
|
||||
- `2026-04-29` 追加任务中心收口:复现旧会话中点击侧栏会话/顶部标签后被 `initialSessionId` 路由抢回的问题;任务中心本地切换现在在 `switchTopic` 前先标记 local override,失败时再回滚,避免异步切换期间显示旧 tab 或空态。
|
||||
- `2026-04-29` 追加发送中吸顶收口:复现用户首条消息发送中仍贴近输入区、完成后才跳到上方的问题;MessageList 现在无论发送中、排队中还是完成态都保持 `justify-start`,避免完成前后布局跳动。
|
||||
- `2026-04-29` 追加一级新建任务收口:任务中心内点击侧栏一级 `新建任务` 现在与对话架 `新建对话` 复用同一个可取消草稿事件;只有任务中心真实接管后才阻止路由,避免旧会话 A 新增草稿、切到旧会话 B 后再次点击侧栏新建时误跳回 `new-task-home`。
|
||||
|
||||
## 剩余事项
|
||||
|
||||
- Cursor 分页已完成:`agent_runtime_get_session` 继续作为 current 主链,新增 `historyBeforeMessageId` 请求字段与 `history_cursor` 响应字段;无 cursor 或缓存恢复缺少 cursor 时继续 fallback 到 `historyOffset`。
|
||||
@@ -61,3 +66,68 @@
|
||||
- `npx eslint "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.ts" "src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" "src/components/agent/chat/hooks/agentChatHistory.ts" "src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts" "src/components/agent/chat/hooks/skillCommand.ts" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/AgentThreadTimeline.tsx" "src/components/agent/chat/hooks/agentChatStorage.ts" "src/components/agent/chat/hooks/agentSessionScopedStorage.ts" "src/components/agent/chat/hooks/agentChatHistory.test.ts" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/components/AgentThreadTimeline.test.tsx" "src/components/agent/chat/hooks/agentChatStorage.test.ts" "src/components/agent/chat/hooks/agentSessionScopedStorage.test.ts" --max-warnings 0` 通过。
|
||||
- `npm run bridge:health -- --timeout-ms 120000` 通过,DevBridge `/health` 约 `20ms` 就绪。
|
||||
- Playwright E2E(`http://127.0.0.1:1420/`):连续切换 `E2E layout anchor -> 你好!👋 很高兴见到你! -> 你好!有什么我可以帮你的吗?😊 -> E2E layout anchor`,每次 `restoring=0`,耗时约 `109-425ms`,`body/html cursor=auto`,最终 `heap≈121MB`;`assistant-primary-timeline-shell` 高度 `94px`,`openDetails=0`,最终正文不在执行过程 shell 内,未检测到 assistant greeting 重复;等待 `9s` 后 `eventRequests=0`,控制台 `0 error / 1 warning`(仅浏览器模式 i18n 默认语言提示)。
|
||||
- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"` 通过(2 files / 230 tests),覆盖旧会话吸顶、新建任务清空旧消息与首轮发送草稿保留。
|
||||
- `npx eslint "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" --max-warnings 0` 通过。
|
||||
- Playwright E2E(`http://127.0.0.1:1420/`):从新建空态点击侧边栏 `E2E layout anchor`,旧会话约 `237-331ms` 可见且 `message-list-column` 为 `justify-start`;再点击顶部 `+` 或侧边栏 `新建任务` 均回到 `青柠一下,灵感即来` 空态,旧会话文本不可见,`body cursor=auto`。本轮仅剩 DevBridge 偶发 `get_local_skills_for_app` / `project_memory_get` 1800ms timeout 控制台噪音,主交互未阻塞。
|
||||
- Playwright E2E(`http://127.0.0.1:1420/`):从首页点击侧栏 `E2E layout anchor` 约 `419ms` 可见旧会话;从空态点击顶部旧会话标签约 `304ms` 可见旧会话;旧会话中点击顶部 `+` 约 `124ms` 回到新建空态;旧会话中点击侧栏 `新建任务` 约 `106ms` 回到新建空态;旧会话 `message-list-column` 保持 `justify-start`,`cursor=auto`,控制台 `0 error / 0 warning`。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(7 tests),覆盖任务中心切到非路由会话时不被 `initialSessionId` 抢回。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx"` 通过(3 files / 326 tests)。
|
||||
- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/components/agent/chat/hooks/useAgentSession.ts" "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx" --max-warnings 0` 通过。
|
||||
- `npm run typecheck` 通过。
|
||||
- `npm run verify:gui-smoke` 通过。
|
||||
- `git diff --check -- src/components/agent/chat/AgentChatWorkspace.tsx src/components/agent/chat/index.test.tsx src/components/agent/chat/components/MessageList.tsx src/components/agent/chat/components/MessageList.test.tsx src/components/agent/chat/hooks/useAgentSession.ts src/components/agent/chat/hooks/useAsterAgentChat.test.tsx docs/exec-plans/old-session-open-performance-plan.md` 通过。
|
||||
- `npm run verify:local` 通过;覆盖 `verify:app-version`、`lint`、`typecheck`、全量前端测试、`test:contracts`、全量 Rust 测试与内嵌 `verify:gui-smoke`。
|
||||
- Playwright E2E 复测(`http://127.0.0.1:1420/`):刷新后控制台 `0 error / 1 warning`(仅浏览器模式 i18n 默认语言提示);点击侧栏 `E2E layout anchor` 约 `133ms` 可见旧会话且 `message-list-column` 为 `justify-start`;随后点击侧栏 `新建任务` 约 `118ms` 回到 `青柠一下,灵感即来` 空态,旧消息不可见,`body/html cursor=auto`。
|
||||
- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" -t "短对话发送首帧|任务中心发送首帧|旧会话短消息"` 通过,覆盖发送中短消息与任务中心发送首帧均吸顶。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(7 tests),并将顶部加号测试改为等待按钮就绪后点击,降低慢机环境下的偶发失败。
|
||||
- Playwright E2E 复测(`http://127.0.0.1:1420/`):发送 `吸顶测试,请只回复 OK。` 后立即采样、`250ms`、`1250ms` 均为 `message-list-column` `justify-start`,首条消息距滚动视口顶部约 `22px`,`scrollTop=0`,`body/html cursor=auto`,确认发送中首帧不再贴底;旧会话 `E2E layout anchor` 打开后 `message-list-column` 同样为 `justify-start`;侧栏 `新建任务` 约 `97ms` 回到 `青柠一下,灵感即来` 空态且旧消息不可见。刷新后控制台保持 `0 error / 1 warning`(仅浏览器模式 i18n 默认语言提示)。
|
||||
- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx" "src/lib/api/memory.test.ts" -t "短对话发送首帧|任务中心发送首帧|旧会话短消息|project_memory_get|项目记忆|短时间重复|并发读取"` 通过(2 files / 6 tests)。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(7 tests)。
|
||||
- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/lib/api/memory.ts" "src/lib/api/memory.test.ts" --max-warnings 0` 通过。
|
||||
- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace / browser-runtime / site-adapters / service-skill-entry / runtime tool-surface page smoke 均通过。
|
||||
- `2026-04-29` 追加浏览器式 Tab 收口:任务中心顶部 `+` 改为本地 `task-draft-*` 草稿 tab,点击阶段不再创建真实 session;`CustomEvent` 新建请求改为可取消事件,只有任务中心工作区实际接管后侧栏才停止路由跳转,避免侧栏 `新建任务` 在任务中心内误跳回 `new-task-home`。
|
||||
- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/taskCenterDraftTaskEvents.ts" "src/components/AppSidebar.tsx" "src/components/agent/chat/components/EmptyState.tsx" --max-warnings 0` 通过。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(8 tests),覆盖顶部加号和侧栏事件新建草稿 tab,不触发 `createFreshSession` / 路由跳出。
|
||||
- `npx tsc --noEmit --pretty false` 通过;同时修复本轮 E2E 中暴露的 `EmptyState` 首页组件 HMR 崩溃,刷新后控制台恢复到 `0 error / 1 warning`(浏览器模式 i18n 默认语言提示)。
|
||||
- Playwright E2E(`http://127.0.0.1:1420/`):从旧会话点击顶部 `+`,`task-center-tab-task-draft-*` 约 `77ms` active,`青柠一下,灵感即来` 约 `95ms` 可见;旧 `message-list-column` 不再渲染,旧消息不可见;invoke trace `10` 条,`createSessionCalls=0`,无 invoke error。
|
||||
- 本轮 GUI smoke 未完成:`npm run bridge:health -- --timeout-ms 120000` 因当前工作区并行 Rust 改动导致 DevBridge 未就绪而等待,`tauri:dev:headless` 日志显示 `src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs` 存在非本轮编译错误(`ServiceSceneLaunchContext` 缺字段、mutable borrow 冲突),需先收口该 Rust 改动后再重跑 `npm run verify:gui-smoke`。
|
||||
- `2026-04-29` 追加多旧会话切换复现:新增回归覆盖 `旧会话 A -> 顶部 + 新增草稿 -> 路由切到旧会话 B -> 再次顶部 +`。根因是路由 `initialSessionId` 切到真实旧会话时只替换真实 tab,没有清掉仍 active 的本地 draft id,导致旧会话 B tab 不是 active,后续新增状态被旧 draft 干扰;现在切到真实路由会话时仅取消 active draft,保留草稿 tab 列表。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "切到另一条旧会话后仍应继续新增本地草稿标签"` 通过。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(9 tests),覆盖顶部 +、侧栏事件、旧会话 A/B 切换后再次新增、路由抢回防护。
|
||||
- `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/taskCenterDraftTaskEvents.ts" "src/components/AppSidebar.tsx" --max-warnings 0` 通过。
|
||||
- `npx tsc --noEmit --pretty false` 通过。
|
||||
- Playwright 真实旧会话 A/B 复测当前仍被 DevBridge 阻塞:页面控制台为 bridge 连接错误,`npm run bridge:health -- --timeout-ms 120000` 超时;阻塞仍来自非本轮 Rust 编译错误,需先恢复 `src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs` 后重测。
|
||||
- `npx vitest run "src/components/AppSidebar.test.tsx" -t "任务中心内点击一级新建任务"` 通过,覆盖任务中心内一级主导航 `新建任务` 被草稿事件接管且不触发路由跳转。
|
||||
- `npx vitest run "src/components/AppSidebar.test.tsx"` 通过(34 tests)。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(9 tests);首次与 AppSidebar 全量测试并行时出现一次顶部 `+` 慢机 transition 偶发断言,单独重跑与整组重跑均通过。
|
||||
- `npx eslint "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx" --max-warnings 0` 通过。
|
||||
- `npx tsc --noEmit --pretty false` 通过。
|
||||
- `git diff --check -- src/components/AppSidebar.tsx src/components/AppSidebar.test.tsx src/components/agent/chat/AgentChatWorkspace.tsx src/components/agent/chat/index.test.tsx src/components/agent/chat/taskCenterDraftTaskEvents.ts` 通过。
|
||||
- `npm run bridge:health -- --timeout-ms 120000` 重新通过;本轮先清理了当前仓库残留的并发 Tauri/Cargo dev 进程,随后 `DevBridge` 在 `http://127.0.0.1:3030/health` 返回 `status=ok`。
|
||||
- Playwright E2E(`http://127.0.0.1:1420/`):刷新首页后执行 `旧会话 A(E2E layout anchor) -> 侧栏一级新建任务 -> 旧会话 B(Lime 助手) -> 侧栏一级新建任务`;打开 A 约 `669ms`,A 后新建草稿约 `63ms`,打开 B 约 `213ms`,B 后新建草稿约 `50ms`。两次新建均激活 `task-center-tab-task-draft-*`,不会出现 active `task-center-tab-new-task-home`;第二次后保留 `2` 个 draft tab,旧会话 tab inactive,首页空态可见,`body/html cursor=auto`。
|
||||
- 同轮 Playwright invoke trace:总 `/invoke` `16` 次,`agent_runtime_create_session=0`;两次 `agent_runtime_get_session` 均携带 `historyLimit: 40`;侧栏列表仅一次 `agent_runtime_list_sessions`,`limit=37`;无 invoke error。控制台 `0 error / 2 warning`,warning 为浏览器模式事件流中止与 i18n 默认语言提示,均非主链阻塞。
|
||||
- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、service-skill-entry、runtime tool-surface 与 runtime tool-surface page smoke 均通过。
|
||||
- `npm run verify:local` 已执行到前端全量测试第 `26/43` 批后失败,失败集中在 `src/components/agent/chat/components/EmptyState.test.tsx` 的 `18` 条旧首页断言;实际渲染已切到新的 `HomeStartSurface` / `HomeSkillGallery` 首页文案与结构,属于本轮侧栏 tab 修复之外的首页改版测试漂移,需后续单独收口 EmptyState 测试或恢复对应首页能力。
|
||||
- `2026-04-30` 追加修复“无法打开两个历史对话”:根因是外层 `AppSidebar` 点击历史会话只做路由跳转,任务中心没有本地打开意图;随后 `AgentChatWorkspace` 的 `initialSessionId` 同步 effect 将 open tab map 替换为单个目标会话。现在侧栏点击历史会话会先发送 `lime:task-center:open-task` 事件,任务中心先乐观 upsert 目标 tab 并记录本地路由追平意图;路由追平时仅激活/前置目标 tab,不再替换掉已有历史 tab。
|
||||
- 同轮收口:任务中心内部 `handleOpenSidebarTaskTopic` / `handleResumeSidebarTask` / `handleResumeRecentSession` / fallback restore 默认走追加/激活语义,只有外部直达会话仍保留 replace 语义,避免旧缓存污染首开深链。
|
||||
- `2026-04-30` 追加旧会话卡顿收口:MessageList 对恢复中或已分页旧会话使用更小的首屏批次(20 条)和 idle 小批量补齐;已完成的旧会话最后一个 turn 不再按 current turn 处理,避免历史 timeline 在首帧被当作活跃执行过程同步展开。
|
||||
- `2026-04-30` 追加 invoke 降噪:`workspace_get` 增加 1s 短 TTL 与同 id in-flight 合并,避免旧会话切换期间工作区详情重复抢占 bridge;打开旧会话时自动回填的 `recent_preferences` / `recent_team_selection` 改为同 session 后台合并队列,12s 后 idle 再写,若期间已切走则直接丢弃,手动偏好修改仍保持即时低延迟同步。
|
||||
- `npx vitest run "src/components/agent/chat/index.test.tsx" -t "任务中心初始会话标签"` 通过(11 tests),新增覆盖任务中心内部连续打开旧会话、外层侧栏事件 + 路由追平后保留两个历史 tab。
|
||||
- `npx vitest run "src/components/AppSidebar.test.tsx"` 通过(35 tests),新增覆盖任务中心内点击已有会话会先通知本地标签栏,再继续导航。
|
||||
- `npx eslint "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx" "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/index.test.tsx" "src/components/agent/chat/taskCenterDraftTaskEvents.ts" --max-warnings 0` 通过。
|
||||
- `npx tsc --noEmit --pretty false` 通过。
|
||||
- `npm run bridge:health -- --timeout-ms 120000` 通过,DevBridge `/health` 约 `22ms` 就绪。
|
||||
- Playwright E2E(`http://127.0.0.1:1420/`):刷新后点击侧栏 `E2E layout anchor`,顶部仅显示并激活该旧会话;随后点击侧栏 `你好!👋 我是 Lime 助手,很高...`,顶部同时保留 `你好!👋 ...` 与 `E2E layout anchor` 两个历史 tab,目标会话 active,旧会话 inactive;页面停留 `http://127.0.0.1:1420/`,标题 `Lime`。
|
||||
- 同轮 Playwright invoke trace:两次 `agent_runtime_get_session` 均携带 `historyLimit: 40`;侧栏 list 请求为 `agent_runtime_list_sessions` `limit=37/60`;全部 `/invoke` 为 `200 OK`,控制台 `0 error / 0 warning`。
|
||||
- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、service-skill-entry、runtime tool-surface 与 runtime tool-surface page smoke 均通过。
|
||||
- `npx vitest run "src/components/agent/chat/components/MessageList.test.tsx"` 通过(77 tests);新增覆盖已分页旧会话首帧只挂载更小尾部批次、已完成旧 turn 不再作为 active current turn 展开历史 timeline。
|
||||
- `npx eslint "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" --max-warnings 0` 通过。
|
||||
- `npx tsc --noEmit --pretty false` 通过。
|
||||
- Playwright 性能复测(`http://127.0.0.1:1420/`):打开 `AI Trends Task`(188 总消息 / 最近 40)约 `567ms`,`longTasks=[]`,DOM 节点约 `637`;打开 `Slow typing E2E`(170 总消息 / 最近 40)约 `706ms`,`longTasks=[]`,DOM 节点约 `644`,控制台 `0 error / 0 warning`。
|
||||
- `npm run verify:gui-smoke` 通过;复用现有 headless Tauri 与 DevBridge,workspace-ready、browser-runtime、site-adapters、service-skill-entry、runtime tool-surface 与 runtime tool-surface page smoke 均通过。
|
||||
- `npm run verify:local` 本轮执行到 `lint` 阶段失败,失败点为非本轮文件 `src/components/agent/chat/home/HomeSceneSkillManagerDialog.tsx:388` 的 `react-hooks/exhaustive-deps` warning(`open` 为不必要依赖),本轮 touched 文件的定向 eslint 已通过。
|
||||
- Playwright 预优化采样(`http://127.0.0.1:1420/`):打开 `Slow typing E2E` 约 `393ms` 可见,早期 invoke 为 `agent_runtime_update_session` ×2 + `agent_runtime_get_session` ×1,且两次 metadata 回填排在 `get_session` 前;本轮据此将自动 metadata 回填降为后台合并。
|
||||
- `npx vitest run "src/lib/api/project.test.ts"` 通过(59 tests),覆盖 `workspace_get` 短缓存与并发去重。
|
||||
- `npx vitest run "src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx"` 通过(13 tests),覆盖自动 fallback Team 回填走后台同步、手动切换仍即时回写。
|
||||
- `npx eslint "src/lib/api/project.ts" "src/lib/api/project.test.ts" "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/hooks/useSelectedTeamPreference.ts" "src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx" --max-warnings 0` 通过。
|
||||
- GUI smoke / Playwright 后置复测暂被本地并发 Cargo/Tauri 构建阻塞:`tauri:dev:headless` 首次暴露非本轮 `creation_tools.rs` 大型 `serde_json::json!` 递归限制,已在 `src-tauri/src/lib.rs` 补 `recursion_limit = "256"` 解除;随后仍有多个非本轮 `cargo test` / `cargo check` 并发进程占用 target/package lock,需环境空闲后重跑 `npm run bridge:health -- --timeout-ms 120000`、`npm run verify:gui-smoke` 与 Playwright 旧会话性能采样。
|
||||
|
||||
@@ -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.*`。
|
||||
|
||||
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 293 KiB |
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}\"。"),
|
||||
|
||||
@@ -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": "会话 ID(snake_case 兼容,可选)。" },
|
||||
"projectId": { "type": "string", "description": "项目 ID(可选)。" },
|
||||
"project_id": { "type": "string", "description": "项目 ID(snake_case 兼容,可选)。" },
|
||||
"contentId": { "type": "string", "description": "内容 ID(可选)。" },
|
||||
"content_id": { "type": "string", "description": "内容 ID(snake_case 兼容,可选)。" },
|
||||
"entrySource": { "type": "string", "description": "入口来源(可选,默认 at_voice_command)。" },
|
||||
"entry_source": { "type": "string", "description": "入口来源(snake_case 兼容,可选)。" },
|
||||
"modalityContractKey": { "type": "string", "description": "兼容字段,默认 voice_generation。" },
|
||||
"modality_contract_key": { "type": "string", "description": "兼容字段,默认 voice_generation。" },
|
||||
"modality": { "type": "string", "description": "兼容字段,默认 audio。" },
|
||||
"requiredCapabilities": { "type": "array", "items": { "type": "string" }, "description": "兼容字段,运行时会规范化为 voice_generation 所需能力。" },
|
||||
"required_capabilities": { "type": "array", "items": { "type": "string" }, "description": "兼容字段,运行时会规范化为 voice_generation 所需能力。" },
|
||||
"routingSlot": { "type": "string", "description": "兼容字段,默认 voice_generation_model。" },
|
||||
"routing_slot": { "type": "string", "description": "兼容字段,默认 voice_generation_model。" },
|
||||
"runtimeContract": { "type": "object", "description": "运行合同快照(可选)。", "additionalProperties": true },
|
||||
"runtime_contract": { "type": "object", "description": "运行合同快照(可选)。", "additionalProperties": true },
|
||||
"requestedTarget": { "type": "string", "description": "请求目标(可选,默认 voice)。" },
|
||||
"requested_target": { "type": "string", "description": "请求目标(snake_case 兼容,可选)。" },
|
||||
"outputPath": { "type": "string", "description": "可选 task JSON 输出路径。" },
|
||||
"output_path": { "type": "string", "description": "可选 task JSON 输出路径(snake_case 兼容)。" }
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"x-lime": {
|
||||
"always_visible": true,
|
||||
"tags": ["audio", "voice", "task", "generation"],
|
||||
"allowed_callers": ["assistant", "skill"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_context_session_id(context: &ToolContext, requested: Option<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}\"。"),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! - ✅ lime-providers crate(providers, converter, streaming, translator, stream, session 部分)
|
||||
|
||||
#![allow(clippy::all)]
|
||||
#![recursion_limit = "256"]
|
||||
//! - 主 crate 保留 Tauri 相关业务逻辑
|
||||
|
||||
// 抑制 objc crate 宏内部的 unexpected_cfgs 警告
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
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;
|
||||
}
|
||||