release: v1.4.0

This commit is contained in:
coso
2026-04-06 22:50:50 +08:00
parent acc573818d
commit 7c06f84e16
467 changed files with 23371 additions and 15151 deletions
+30 -25
View File
@@ -1,47 +1,52 @@
## Lime v1.3.0
## Lime v1.4.0
### ✨ 主要更新
- **命令运行时继续收口到 Agent 主链**:`@封面`、`@视频`、`@转写`、`@链接解析` 统一保留原始用户消息进入 Agent turn,通过 `cover_skill_launch`、`video_skill_launch`、`transcription_skill_launch`、`url_parse_skill_launch` metadata 驱动首刀 `Skill(...)`,CLI / task file / viewer 的状态语义保持一致,避免前端预翻命令或伪造“已完成”结果
- **创作工作台与首页入口重做**:Agent 空态、推荐入口与工作区启动边界继续收口,新增统一 `workspaceEntry` 启动层、独立 `VideoPage` 与 `ImageTaskViewer`,图片任务支持围绕真实任务结果继续 `@修图` / `@重绘`,旧 `Claw Home`、旧首页 prompt composer、旧图片画布壳与一批 Inputbar compat 表面继续退出
- **内容主稿技能标准化**:默认社媒主稿能力统一收口到 `content_post_with_cover`,输出目录固定为 `content-posts/`,运行时会补齐主稿、封面元数据和 publish-pack artifact 事件;旧 `social_post_with_cover` 命名与历史引用继续清退
- **设置中心与治理目录继续瘦身**:设置首页升级为总览入口,渠道能力收口到 `ChannelsDebugWorkbench` / 独立 IM 配置页,旧 `settings-v2` 里的 channels wrapper、proxy 页、chat-appearance 页、通用 header 等兼容入口转入 dead-candidate;命令运行时规则正式沉淀到 `docs/aiprompts/command-runtime.md`
- **版本与发布面同步**:Lime 应用与 `@limecloud/lime-cli` 升级到 `1.3.0`,Rust workspace crate 版本快照、Tauri 配置与 CLI README 示例同步收口
- **通用工作台成为当前主壳**:Agent 聊天工作区继续从旧 `ThemeWorkbench*` 表面收口到 `GeneralWorkbench*` 主链,侧边栏、上下文面板、执行日志、工作流面板、输入区启动边界与任务预览统一落到新的通用工作台运行时;一批旧 `ThemeWorkbench` 组件、hook 与 compat 状态层退出 current 主路径
- **服务技能与命令运行时扩容**:前后端围绕 `analysis`、`summary`、`translation`、`pdf_read`、`report_generate`、`research`、`site_search`、`broadcast_generate`、`typesetting`、`modal_resource_search` 等技能补齐 `*_skill_launch` 主链,原始用户输入继续保留进入 Agent turn,启动 metadata、prompt context、runtime turn 和 tool runtime 的事实源进一步收口
- **站点技能与工作台协议继续收敛**:`service_skill_launch` 继续统一站点技能启动语义,站点技能预执行结果、站点适配器上下文、Team 运行时偏好、artifact metadata 与工作台上下文同步链路一起补齐;浏览器 compat 工具前缀在相关场景下继续被隔离,避免回流旧边界
- **文档与治理目录同步更新**:`docs/aiprompts/command-runtime.md`、`commands.md`、`quality-workflow.md`、`playwright-e2e.md`、`overview.md` 等工程文档已围绕当前服务技能主链与通用工作台事实源完成更新,默认技能目录新增并收口 `analysis / pdf_read / report_generate / summary / translation`
- **版本与依赖面同步发版**:Lime 应用与 `@limecloud/lime-cli` 升级到 `1.4.0`,Rust workspace crate 版本快照、Tauri 配置与 CLI README 示例同步更新;`aster-rust` Git tag 升级到 `v0.27.0`
### ⚠️ 发布与兼容性说明
- 本次发布 tag 为 `v1.3.0`,应用内版本号保持为 `1.3.0`
- `@limecloud/lime-cli@1.3.0` 要求 `Node >= 18`,支持 `darwin / linux / win32` 与 `x64 / arm64`
- 当前内置输入命令主链包含 `@配图`、`@封面`、`@修图`、`@重绘`、`@视频`、`@转写`、`@链接解析`
- `content_post_with_cover` 是当前内容主稿 + 封面一体化技能真相;旧 `social_post_with_cover` 不再作为 current surface 继续扩展
- 旧 Claw 首页壳、旧图片工作台壳、旧设置兼容页和一批 Inputbar compat 组件已继续退出 current 主路径,后续交互与回归请以新的工作台入口和治理目录册为准
- 本次发布 tag 为 `v1.4.0`,应用内版本号保持为 `1.4.0`
- `@limecloud/lime-cli@1.4.0` 要求 `Node >= 18`,支持 `darwin / linux / win32` 与 `x64 / arm64`
- 当前 Agent GUI 主路径以 `GeneralWorkbench*` 为准;旧 `ThemeWorkbench*` 相关组件、壳层和一批 compat hook 已继续退出,不应再作为 current surface 扩展
- 当前默认技能目录已包含 `analysis`、`broadcast_generate`、`modal_resource_search`、`pdf_read`、`report_generate`、`research`、`site_search`、`summary`、`translation`、`typesetting` 等服务技能主链
- `aster-rust` 依赖已固定到远程 tag `v0.27.0`;本地 `.cargo/config.toml` patch override 仍仅作为开发联调手段,不属于发布事实源
### 🔗 依赖与版本同步
- 应用版本已同步提升到 `1.3.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `packages/lime-cli-npm/package.json` 与 README 发布示例已同步更新到 `1.3.0`
- `src-tauri/Cargo.lock` 已刷新,工作区内部 crate 版本快照已对齐到 `1.3.0`
- 命令运行时文档已新增 `docs/aiprompts/command-runtime.md`,并同步更新命令边界、质量工作流和 Playwright 续测文档
- 应用版本已同步提升到 `1.4.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`
- `packages/lime-cli-npm/package.json` 与 README 发布示例已同步更新到 `1.4.0`
- `src-tauri/Cargo.lock` 已刷新:工作区内部 crate 版本快照已对齐到 `1.4.0`,`aster-core` / `aster-models` 已对齐到 `0.27.0`
- `package-lock.json` 已同步根应用版本号到 `1.4.0`
### 🧪 发布前校验
- `npm run verify:app-version`
- `npm run test:contracts`
- `npm run verify:gui-smoke`
- `cargo fmt --manifest-path src-tauri/Cargo.toml --all`
- `npm run lint`
- `cargo fmt --manifest-path src-tauri/Cargo.toml --all --check`
- `cargo test --manifest-path src-tauri/Cargo.toml`
- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`
- `npm run test:contracts`
- `CARGO_TARGET_DIR=src-tauri/target/codex-verify CARGO_INCREMENTAL=0 cargo test --manifest-path src-tauri/Cargo.toml`
- `CARGO_TARGET_DIR=src-tauri/target/codex-verify CARGO_INCREMENTAL=0 cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`
- `CARGO_TARGET_DIR=src-tauri/target/codex-verify npm run verify:gui-smoke`
- 当前结果:
- `npm run verify:app-version`:通过
- 其余发布前校验:本轮尚未执行,正式发版前需要补齐
- `cargo fmt --manifest-path src-tauri/Cargo.toml --all`:通过
- `npm run lint`:通过
- `npm run test:contracts`:通过
- `cargo test --manifest-path src-tauri/Cargo.toml`:通过
- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`:通过
- `npm run verify:gui-smoke`:通过
### 📝 文档同步
- 发布说明已切换到当前这次 `v1.3.0` 稳定版发布内容,供 GitHub Release 直接读取
- 命令运行时、命令边界、工程质量与 GUI 续测文档已围绕当前主链更新
- 内容主稿技能、工作台任务协议与治理目录册的命名事实源已与当前实现对齐
- 发布说明已切换到当前这次 `v1.4.0` 稳定版发布内容,可直接作为 GitHub Release note 使用
- 服务技能主链、命令运行时、GUI 续测与工程质量文档已与当前实现同步
- 通用工作台命名、默认技能目录与命令运行时事实源已围绕当前实现完成收口
---
**完整变更**: `v1.2.0` -> `v1.3.0`
**完整变更**: `v1.3.0` -> `v1.4.0`
+89 -7
View File
@@ -69,7 +69,7 @@ Lime 的命令体系固定按以下关系理解:
对图片任务再补一条固定约束:
`@配图/@修图` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;不要把 current 主链重新改回前端预翻 slash skill 或前端直建任务。
`@配图/@修图/@重绘` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;文稿 inline 配图、封面位、图片工作台编辑/变体这类显式图片动作也一样,必须先组装 `image_task` 上下文后再复用统一发送主线。不要把 current 主链重新改回前端预翻 slash skill、前端直建任务或“按钮直调 task API”。
不要再把命令能力直接叙述成:
@@ -102,9 +102,10 @@ Lime 的命令体系固定按以下关系理解:
当前 `scene` slash 的第一刀执行也固定如下:
- `useWorkspaceSendActions` 先识别 `/scene-key ...`
- 再通过 `useWorkspaceServiceSkillEntryActions.handleRuntimeSceneLaunch(...)` 从本地缓存 `SkillCatalog.entries` 里解析 `scene`
- 客户端按 `linkedSkillId -> ServiceSkillHomeItem` 复用已有 `ServiceSkill` 启动链,而不是新增一套 scene 执行器
- 若云端 `cloud_scene` 在创建 run 之前就失败,例如缺少会话、服务端暂不可达,客户端要自动回退到本地工作区 prompt 主链,不能让 `/scene-key` 直接失能
- 从统一 `SkillCatalog.entries` 里解析 `scene -> linkedSkillId -> ServiceSkillHomeItem`
- 前端只负责把结构化 `service_scene_launch` 写进当前 turn metadata,不负责前端直建云端 run
- Rust 侧会把该 turn 收口到 `workbench`,并通过系统提示强约束 Agent 首刀优先调用 `lime_run_service_skill`
- `lime_run_service_skill` 再根据当前 turn 绑定的 `serviceSkillId + OEM runtime` 发起服务端 run / 短轮询,保证 slash scene 也走 `Agent -> tool -> timeline` 主链
- 未命中统一目录的 slash 文本必须继续回到普通 slash 流程,不能被错误吞成“未找到本地 Skill”
一句话:
@@ -123,7 +124,10 @@ Lime 的命令体系固定按以下关系理解:
- `@修图`
- `@重绘`
- `@视频`
- `@播报`
- `@素材`
- `@转写`
- `@排版`
特点:
@@ -135,10 +139,19 @@ Lime 的命令体系固定按以下关系理解:
其中图片类能力当前已经有额外运行时纪律:
- `@配图` / `@修图` / `@重绘` 的 current 主链必须保留原始用户消息进入 Agent
- 前端只负责补 `harness.image_skill_launch` 这类结构化上下文,不负责预翻成 slash skill
- 文稿 inline 配图、封面位、图片工作台编辑/变体等显式动作也必须补成同构的 `harness.image_skill_launch`,而不是绕过 Agent 直建任务
- 前端只负责补 `harness.image_skill_launch` 这类结构化上下文,不负责预翻成 slash skill 或偷偷发起 task
- Agent 首刀优先调用 `Skill(image_generate)`,再由 skill / CLI / task file 链路继续执行
- 聊天区轻卡与 viewer 只消费后端真实运行态,不伪造“已完成”
`@素材` 在这个分型里是一个混合分流特例:
- 命令仍必须先进入 `Agent -> Skill(modal_resource_search)` 主链
- 当 `resource_type=image` 且关键词明确时,skill 应优先调用 `lime_search_web_images`,直接复用现有 `Pexels API Key` 设置返回候选
- `lime_search_web_images` 命中后,聊天区应直接展示真实 tool result 生成的素材轻卡与缩略图,点击后在右侧打开同回合 artifact document,而不是只留一段文本总结
- 当资源类型是 `bgm / sfx / video`,或图片直搜失败时,再回退 `Bash -> lime task create resource-search --json` / `lime_create_modal_resource_search_task`
- 无论走直搜还是 task,都必须保留真实 `tool_timeline`,不能回到前端直连图库或隐藏底层 tools
### 2. `Agent + ServiceSkill`
适合:
@@ -157,8 +170,9 @@ Lime 的命令体系固定按以下关系理解:
- `/scene-key` 不再直接落回本地 slash skill 预处理
- 先按统一目录找到 `scene` 与其 `linkedSkillId`
- 复用现有 `ServiceSkill` 启动主链
- 云端首提失败时自动回退本地工作区,保证 seeded/fallback 仍可推进
- 把 `service_scene_launch` 作为当前 turn 的 binding 上下文,而不是前端直接调用云端 run
- 由 Agent 首刀调用 `lime_run_service_skill` 执行服务型技能 run
- 服务端目录失联或 scene 未命中时,客户端 seeded/fallback 仍要保证 slash 输入能回到普通工作区主链
### 3. `Agent + Workflow`
@@ -177,6 +191,11 @@ Lime 的命令体系固定按以下关系理解:
适合:
- `@搜索`
- `@深搜`
- `@研报`
- `@站点搜索`
- `@读PDF`
- `@总结`
- `@翻译`
- `@分析`
@@ -184,9 +203,66 @@ Lime 的命令体系固定按以下关系理解:
特点:
- 首期轻量
- 保留真实 skills / tools timeline
- 可以先不独立恢复
- 后续可升级为更重的形态
当前 `@搜索` 已按这条主链收口:
- 前端只补 `harness.research_skill_launch`
- Agent 首刀优先调用 `Skill(research)`
- `research` skill 再驱动 `search_query`
- 不走 task file,也不允许前端伪造“已搜索完成”
当前 `@深搜` 也已按这条主链收口:
- 前端只补 `harness.deep_search_skill_launch`
- Agent 首刀优先调用 `Skill(research)`
- `research` skill 继续驱动 `search_query`,但系统提示强约束至少多轮扩搜
- 不走 task file,也不允许前端把深搜伪装成“普通搜索加强版”
当前 `@研报` 也已按这条主链收口:
- 前端只补 `harness.report_skill_launch`
- Agent 首刀优先调用 `Skill(report_generate)`
- `report_generate` skill 再驱动 `search_query`,并把结果写成结构化研究报告
- 不走 task file,也不允许前端本地先拼报告再伪装成 skill 结果
当前 `@站点搜索` 也已按这条主链收口:
- 前端只补 `harness.site_search_skill_launch`
- Agent 首刀优先调用 `Skill(site_search)`
- `site_search` skill 再驱动 `lime_site_info / lime_site_run / lime_site_search`
- 不走 task file,也不允许前端先退回 `research / WebSearch`
当前 `@读PDF` 也应按这条主链收口:
- 前端只补 `harness.pdf_read_skill_launch`
- Agent 首刀优先调用 `Skill(pdf_read)`
- `pdf_read` skill 再最小化驱动 `list_directory / read_file`
- 不走 task file,也不允许前端本地直接解析 PDF 或伪造“已读结果”
当前 `@总结` 也已按这条主链收口:
- 前端只补 `harness.summary_skill_launch`
- Agent 首刀优先调用 `Skill(summary)`
- `summary` skill 默认直接总结 `summary_request.content` 或当前对话上下文;当用户显式给出本地路径时,才最小化使用 `list_directory / read_file`
- 不走 task file,也不允许前端本地直接总结后再伪装成 skill 结果
当前 `@翻译` 也已按这条主链收口:
- 前端只补 `harness.translation_skill_launch`
- Agent 首刀优先调用 `Skill(translation)`
- `translation` skill 默认直接翻译 `translation_request.content` 或当前对话上下文;当用户显式给出本地路径时,才最小化使用 `list_directory / read_file`
- 不走 task file,也不允许前端本地直接翻译后再伪装成 skill 结果
当前 `@分析` 也已按这条主链收口:
- 前端只补 `harness.analysis_skill_launch`
- Agent 首刀优先调用 `Skill(analysis)`
- `analysis` skill 默认直接分析 `analysis_request.content` 或当前对话上下文;当用户显式给出本地路径时,才最小化使用 `list_directory / read_file`
- 不走 task file,也不允许前端本地直接分析后再伪装成 skill 结果
## 公共设计包在哪里
命令运行时的公共实施设计包统一在:
@@ -229,6 +305,12 @@ Lime 的命令体系固定按以下关系理解:
- `docs/prd/gongneng/peitu/`
- `docs/prd/gongneng/xiutu/`
- `docs/prd/gongneng/sousuo/`
- `docs/prd/gongneng/shensou/`
- `docs/prd/gongneng/zhandiansousuo/`
- `docs/prd/gongneng/zongjie/`
- `docs/prd/gongneng/fanyi/`
- `docs/prd/gongneng/fenxi/`
旧平铺文档如果仍保留,只能作为 compat 索引,不再作为 current 主文档。
+56 -11
View File
@@ -88,17 +88,17 @@
- `CharacterMention`、`builtinCommands`、场景 slash 补全不得再各自维护一套业务命令静态常量
- 服务端尚未返回 `entries` 时,允许网关层从 legacy `items` 兼容投影出 `entries`
- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@转写` 这类主链入口失能
- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@搜索`、`@深搜`、`@研报`、`@站点搜索`、`@读PDF`、`@总结`、`@翻译`、`@分析`、`@转写` 这类主链入口失能
- `src/components/agent/chat/commands/catalog.ts` 只继续承接 Lime 本地 / Codex 原生命令;产品型 `/` 场景不应再长期硬编码在这里
- 若服务端下发的 `renderContract` 超出 Lime 当前支持范围,优先由服务端回退到已支持类型,客户端也必须退化到通用 timeline / artifact 展示
当前 `/scene-key` 的发送主链也已经固定:
- 发送前由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 统一拦截 slash 场景
- 再委托 `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts` 的 `handleRuntimeSceneLaunch(...)`
- 运行时只从统一 catalog 解析 `scene -> linkedSkillId -> ServiceSkillHomeItem`
- 对 `cloud_scene`,优先复用现有 `createServiceSkillRun(...)` 云端运行链
- 若云端 run 在创建前就失败,客户端必须自动回退到本地工作区 prompt 主链,不能把 slash scene 直接判死
- 运行时只从统一 catalog 解析 `scene -> linkedSkillId -> ServiceSkillHomeItem`,并把结构化上下文写入 `request_metadata.harness.service_scene_launch`
- Rust 侧 `runtime_turn` 会把这类 turn 统一切到 `workbench`,并通过 `prompt_context` 强约束首刀优先调用 `lime_run_service_skill`
- `lime_run_service_skill` 负责基于当前 session / turn 上下文读取已绑定的 `serviceSkillId + OEM runtime`,再向 OEM Scene Runtime 发起 run / poll
- slash scene 不应再在前端直接调用 `createServiceSkillRun(...)` 或其它云端 run API;客户端当前职责只剩 catalog 解析、metadata 注入与 seeded/fallback 托底
- 未命中统一 scene 目录的 slash 文本必须继续回到普通 slash / Codex 命令流,不能误报本地 Skill 不存在
如果这轮改动触达了 `client/skills` 协议,不仅要改 Lime 前端 selector,还要同步检查 `limecore` 的:
@@ -116,17 +116,17 @@
这些命令统一产出 `.lime/tasks/<task_type>/*.json` artifact 与稳定 JSON 输出。仓库内现有 `lime_create_*_generation_task`、`social_generate_cover_image` 与相关 Tauri / agent tool 入口在兼容期内允许保留,但应继续委托同一套任务文件与输出契约,不要再长出第三套“媒体任务协议”。
`Claw` 的图片任务当前需要分成两条已收敛主链:
`Claw` 的图片任务当前已经收敛到同一条 current 主链:
- Agent 驱动的图片命令:`@配图` / `@修图` / `@重绘` / `@image` / `/image` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送,不再预翻译为 `/image_generate ...`。聊天发送边界会把结构化 `image_task` 写入 `request_metadata.harness.image_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs` 会物化 `skill-input-image://N` 引用,并给当前 turn 注入只允许首刀优先调用 `Skill(image_generate)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime media image generate --json`,CLI 不可用时再回退 `lime_create_image_generation_task`,最终仍只落到标准 task file。
- 显式 task 动作:文稿 inline 配图、封面位、图片工作台编辑/变体、带引用图或带参考图的动作,继续通过 `src/lib/api/mediaTasks.ts` 承接:
- Agent 驱动的图片命令与显式图片动作:`@配图` / `@修图` / `@重绘` / `@image` / `/image`,以及文稿 inline 配图、封面位、图片工作台编辑/变体、带引用图或带参考图的动作,都必须先进入 Agent turn。纯文本入口由 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 保留原始用户文本发送;显式动作则由 `src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts` 组装同构的 `image_task` 上下文后,再复用统一发送主线。两类入口都会把结构化 `image_task` 写入 `request_metadata.harness.image_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs` 会物化 `skill-input-image://N` 引用,并给当前 turn 注入只允许首刀优先调用 `Skill(image_generate)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime media image generate --json`,CLI 不可用时再回退 `lime_create_image_generation_task`,最终仍只落到标准 task file。
- 图片 task 控制面:`src/lib/api/mediaTasks.ts` 继续承接 task control / replay / recovery,而不是首发入口:
- `create_image_generation_task_artifact`
- `get_media_task_artifact`
- `list_media_task_artifacts`
- `cancel_media_task_artifact`
无论入口来自 slash skill 还是显式 task action,最终都只允许写入当前项目根目录下的标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。若当前来源是文稿 inline 配图,还会继续写入 `usage=document-inline`,并以 `relationships.slot_id` 作为正文占位块与后续任务回填的正式绑定字段;payload 中的 `slot_id` 仅保留兼容读取。若前端已经能推断目标小节,还应继续把 `anchor_section_title` 写入 task payload;若还能识别用户当前选中的具体段落,还应继续把裁剪后的 `anchor_text` 一并写入,用于正文占位图与最终图片的 paragraph 级原位落位。聊天区动态占位、正文占位替换、结果回填、刷新恢复都必须继续以 `.lime/tasks` 为唯一事实源,不允许重新回到前端直连图片服务。
无论入口来自纯文本命令、slash scene 组合还是显式图片动作,最终都只允许写入当前项目根目录下的标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。若当前来源是文稿 inline 配图,还会继续写入 `usage=document-inline`,并以 `relationships.slot_id` 作为正文占位块与后续任务回填的正式绑定字段;payload 中的 `slot_id` 仅保留兼容读取。若前端已经能推断目标小节,还应继续把 `anchor_section_title` 写入 task payload;若还能识别用户当前选中的具体段落,还应继续把裁剪后的 `anchor_text` 一并写入,用于正文占位图与最终图片的 paragraph 级原位落位。聊天区动态占位、正文占位替换、结果回填、刷新恢复都必须继续以 `.lime/tasks` 为唯一事实源,不允许重新回到前端直连图片服务。
Workspace `Bash` 运行时在当前主链中应优先解析同名 `lime` 入口:开发态优先回落到 `cargo run -p lime-cli`,打包态优先使用随应用提供的 CLI 二进制。默认 skill 若已经切到 `Bash -> lime media ...`,仍应保留 compat tool 作为兜底,避免在 CLI 暂不可用时把用户流量打断。
@@ -145,6 +145,47 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li
`Claw` 的纯文本视频命令也应沿相同心智收敛:
- Agent 驱动的视频命令:`@视频` / `@video` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `video_task` 写入 `request_metadata.harness.video_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/video_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(video_generate)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime media video generate --json`,CLI 不可用时再回退 `lime_create_video_generation_task` / `create_video_generation_task`,最终仍只允许落到标准 `video_generate` 任务主链。
- 前端消费层不再把 `@视频` 当成图片任务特判。当前聊天区通过统一 `taskPreview` 消费 `video_generate` 任务摘要,点击结果卡后直接复用现有 `VideoCanvas / VideoWorkspace` 打开右侧 viewer;运行中的视频任务则由 `useWorkspaceVideoTaskPreviewRuntime` 基于 `videoGenerationApi.getTask(...)` 轮询回流状态与结果 URL。
`Claw` 的纯文本播报命令也应沿同一条 current 主链收敛:
- Agent 驱动的播报命令:`@播报` / `@播客` / `@broadcast` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `broadcast_task` 写入 `request_metadata.harness.broadcast_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/broadcast_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(broadcast_generate)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime task create broadcast --json`,CLI 不可用时再回退 `lime_create_broadcast_generation_task`,最终仍只允许落到标准 `broadcast_generate` task file;若当前上下文缺少待整理原文,允许 Agent 最多追问 1 个关键问题,但不能伪造“播报已完成”。
`Claw` 的纯文本素材命令也应沿同一条 current 主链收敛:
- Agent 驱动的素材命令:`@素材` / `@资源` / `@resource` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `resource_search_task` 写入 `request_metadata.harness.resource_search_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/resource_search_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(modal_resource_search)` 的系统提示。若 `resource_type=image` 且 query 明确,默认 skill 必须优先调用 `lime_search_web_images`,直接复用现有“设置 -> 系统 -> 网络搜索 -> Pexels API Key”返回候选,并保留真实 tool timeline;只有 `Pexels API Key` 未配置、无结果,或用户明确要求继续异步追踪时,才回退 `Bash -> lime task create resource-search --json`。对 `bgm / sfx / video` 等非图片素材,仍优先走 `Bash -> lime task create resource-search --json`,CLI 不可用时再回退 `lime_create_modal_resource_search_task`,最终落到标准 `modal_resource_search` task file;若当前上下文缺少明确资源类型或检索关键词,允许 Agent 最多追问 1 个关键问题,但不能伪造“素材已检索完成”。
`Claw` 的纯文本搜索命令也应沿同一条 current 主链收敛:
- Agent 驱动的搜索命令:`@搜索` / `@search` / `@research` / `@调研` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `research_request` 写入 `request_metadata.harness.research_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(research)` 的系统提示。后续默认 skill 必须沿 `research` prompt skill -> `search_query` 主链先真实联网检索,再输出结论、来源与建议;当前上下文缺少明确搜索主题时,允许 Agent 最多追问 1 个关键问题,但不能伪造“已完成搜索”,也不能直接凭记忆跳过检索。
`Claw` 的纯文本深搜命令也应沿同一条 current 主链收敛:
- Agent 驱动的深搜命令:`@深搜` / `@deep` / `@deepsearch` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `deep_search_request` 写入 `request_metadata.harness.deep_search_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(research)`、且至少执行多轮扩搜的系统提示。后续默认 skill 仍必须沿 `research` prompt skill -> `search_query` 主链先真实联网检索,再输出事实、推断与待确认项;当前上下文缺少明确搜索主题时,允许 Agent 最多追问 1 个关键问题,但不能伪造“已完成深搜”,也不能退化成只搜一次的普通搜索。
`Claw` 的纯文本研报命令也应沿同一条 current 主链收敛:
- Agent 驱动的研报命令:`@研报` / `@report` / `@research_report` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `report_request` 写入 `request_metadata.harness.report_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(report_generate)` 的系统提示。后续默认 skill 必须沿 `report_generate` prompt skill -> `search_query` 主链先真实联网检索,再写出结构化研究报告;当前上下文缺少明确研报主题时,允许 Agent 最多追问 1 个关键问题,但不能伪造“研报已完成”,也不能直接退回普通聊天长文。
`Claw` 的纯文本站点搜索命令也应沿同一条 current 主链收敛:
- Agent 驱动的站点搜索命令:`@站点搜索` / `@站点` / `@site_search` / `@site` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `site_search_request` 写入 `request_metadata.harness.site_search_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(site_search)` 的系统提示。后续默认 skill 必须沿 `site_search` prompt skill -> `lime_site_info / lime_site_run / lime_site_search` 主链先执行真实站点适配器,再输出摘要与来源;当前上下文缺少明确站点或检索关键词时,允许 Agent 最多追问 1 个关键问题,但不能伪造“已完成站点搜索”,也不能先退回 `research / WebSearch`。
`Claw` 的纯文本读 PDF 命令也应沿同一条 current 主链收敛:
- Agent 驱动的读 PDF 命令:`@读PDF` / `@pdf` / `@read_pdf` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `pdf_read_request` 写入 `request_metadata.harness.pdf_read_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(pdf_read)` 的系统提示。后续默认 skill 必须沿 `pdf_read` prompt skill -> `list_directory / read_file` 主链先真实读取本地或工作区 PDF,再输出结构化解读结果;当前上下文只有远程 PDF URL 或缺少明确 PDF 来源时,允许 Agent 最多追问 1 个关键问题请求本地路径或导入路径,但不能伪造“PDF 已读完”,也不能退回普通聊天总结。
`Claw` 的纯文本总结命令也应沿同一条 current 主链收敛:
- Agent 驱动的总结命令:`@总结` / `@summary` / `@summarize` / `@摘要` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `summary_request` 写入 `request_metadata.harness.summary_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(summary)` 的系统提示。后续默认 skill 必须沿 `summary` prompt skill 主链先总结显式正文或当前对话相关上下文;只有当用户显式给出本地路径或目录时,才允许最小化使用 `list_directory / read_file` 读取必要内容并保留真实 tool timeline。当前上下文缺少显式正文时,允许 Agent 优先总结当前对话;只有在显式正文和对话上下文都不足时,才最多追问 1 个关键问题,但不能伪造“已完成总结”,也不能在前端直接生成摘要绕过 skill。
`Claw` 的纯文本翻译命令也应沿同一条 current 主链收敛:
- Agent 驱动的翻译命令:`@翻译` / `@translate` / `@translation` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `translation_request` 写入 `request_metadata.harness.translation_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(translation)` 的系统提示。后续默认 skill 必须沿 `translation` prompt skill 主链先翻译显式正文或当前对话相关上下文;只有当用户显式给出本地路径或目录时,才允许最小化使用 `list_directory / read_file` 读取必要内容并保留真实 tool timeline。当前上下文缺少显式正文时,允许 Agent 优先翻译当前对话;只有在显式正文和对话上下文都不足时,才最多追问 1 个关键问题,但不能伪造“已完成翻译”,也不能在前端直接生成译文绕过 skill。
`Claw` 的纯文本分析命令也应沿同一条 current 主链收敛:
- Agent 驱动的分析命令:`@分析` / `@analysis` / `@analyze` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `analysis_request` 写入 `request_metadata.harness.analysis_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(analysis)` 的系统提示。后续默认 skill 必须沿 `analysis` prompt skill 主链先分析显式正文或当前对话相关上下文;只有当用户显式给出本地路径或目录时,才允许最小化使用 `list_directory / read_file` 读取必要内容并保留真实 tool timeline。当前上下文缺少显式正文时,允许 Agent 优先分析当前对话;只有在显式正文和对话上下文都不足时,才最多追问 1 个关键问题,但不能伪造“已完成分析”,也不能在前端直接生成分析结论绕过 skill。
`Claw` 的纯文本转写命令也应沿同一条 current 主链收敛:
@@ -154,7 +195,11 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li
- Agent 驱动的链接解析命令:`@链接解析` / `@链接` / `@url_parse` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `url_parse_task` 写入 `request_metadata.harness.url_parse_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/url_parse_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(url_parse)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime task create url-parse --json`,CLI 不可用时再回退 `lime_create_url_parse_task`,最终仍只允许落到标准 `url_parse` task file;若当前上下文缺少 URL,允许 Agent 最多追问 1 个关键问题,但不能伪造“链接已解析完成”。
这五条命令除了 Tauri `generate_handler!` 之外,也必须继续保持 DevBridge dispatcher 已桥接,避免浏览器模式、headless smoke 或 Playwright 续测时回退成 unknown command。
`Claw` 的纯文本排版命令也应沿同一条 current 主链收敛:
- Agent 驱动的排版命令:`@排版` / `@typesetting` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `typesetting_task` 写入 `request_metadata.harness.typesetting_skill_launch`,同时打开 `request_metadata.harness.allow_model_skills = true`。Rust 侧 `src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs` 与 `src-tauri/src/commands/aster_agent_cmd/typesetting_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(typesetting)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime task create typesetting --json`,CLI 不可用时再回退 `lime_create_typesetting_task`,最终仍只允许落到标准 `typesetting` task file;若当前上下文缺少待排版正文,允许 Agent 最多追问 1 个关键问题,但不能伪造“排版已完成”。
这些命令除了 Tauri `generate_handler!` 之外,也必须继续保持 DevBridge dispatcher 已桥接,避免浏览器模式、headless smoke 或 Playwright 续测时回退成 unknown command。
自动化设置链路同样遵循这条路径。当前主入口为 `src/lib/api/automation.ts`,统一承接:
@@ -379,7 +424,7 @@ npm run verify:local
- **运行时外部分析交接主链**:继续收敛到 `agent_runtime_export_analysis_handoff`,复用 handoff bundle + evidence pack + replay case 生成 `analysis-brief.md / analysis-context.json / copy_prompt`,供外部诊断代理直接诊断与最小修复;当前 GUI 入口位于 `HarnessStatusPanel`
- **运行时人工审核记录主链**:继续收敛到 `agent_runtime_export_review_decision_template` + `agent_runtime_save_review_decision`;前者复用 `analysis handoff` 生成 `review-decision.md / review-decision.json` 模板,后者把开发者的接受 / 延后 / 拒绝与回归要求回写到同一份工作区制品;当前 GUI 入口位于 `HarnessStatusPanel`
- **会话主题上下文主链**:`getSession` 返回的 `execution_runtime.recent_theme / recent_session_mode` 负责承接最近一次运行态主题上下文;当前端已命中同一 steady-state theme/workbench mode 时,不应继续每回合重复携带 `harness.theme / harness.session_mode`
- **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次 Theme Workbench 运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title`
- **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次通用工作区运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title`
- **会话内容上下文主链**:`getSession` 返回的 `execution_runtime.recent_content_id` 负责承接最近一次运行态 `content_id`;当前端已命中同一 steady-state 内容时,不应继续每回合重复携带 `harness.content_id`
- **运行态摘要主链**:Aster `runtime_status` item -> timeline `turn_summary`
- **上下文压缩策略主链**:`workspace.settings.auto_compact` 是运行时自动压缩的唯一 workspace 级开关;`agent_runtime_submit_turn` 与 `agent_runtime_respond_action` 都会把该设置注入 turn context。值为 `false` 时,Lime 不会做发起前自动压缩,并会显式告诉 Aster 关闭当前回合的内部自动压缩 / overflow recovery 自动压缩;此时只允许用户通过 `agent_runtime_compact_session` 手动压缩。
+1
View File
@@ -126,6 +126,7 @@ Lime 的整体界面应当接近以下气质:
- 一个页面只保留一个主标题中心
- 子页面不要重复出现“标题 + 同标题卡片标题”双重堆叠
- 标题负责定性,说明负责补充,不要写两句意思相同的话
- 首屏静态解释文案默认不要整段铺开;标题旁优先放 help / tips 入口,把写法建议、卡片 hint、快捷键说明等收进悬浮提示,避免说明文字压过主操作
### 3. 统计卡文字
+3 -3
View File
@@ -6,7 +6,7 @@ Lime 是一个以创作为中心的本地优先 AI Agent 交互工作台,基
可以把它理解为三层结构:
1. **产品层**:Workspace、主题工作台、Agent 对话、Skills、Artifact/Canvas、记忆与风格
1. **产品层**:Workspace、通用工作区 / Harness、Agent 对话、Skills、Artifact/Canvas、记忆与风格
2. **能力层**:MCP、浏览器运行时、终端、插件、批量/心跳、Claw 渠道
3. **基础设施层**:Aster Agent、Provider 凭证池、协议兼容、路由、服务器、数据库与监控
@@ -60,7 +60,7 @@ lime/
|------|------|
| `workspace/` | 工作区与项目边界,承载文件、会话与配置上下文 |
| `components/agent/` | Agent 对话主入口,负责会话、流式事件与交互 |
| `components/workspace/` + `lib/workspace/` | 主题工作台、画布联动与共享工作区能力 |
| `components/workspace/` + `lib/workspace/` | 共享工作区、画布联动、上下文 Harness 与编排兼容能力 |
| `skills/` | 技能加载、标准校验与经验编排能力;统一遵循 `skill-standard.md` |
| `lib/artifact/` | Artifact 解析、状态与轻量渲染器 |
| `memory / personas` | 项目记忆与人设沉淀 |
@@ -110,7 +110,7 @@ lime/
| 模块 | 说明 |
|------|------|
| `components/` | 主 UI 组件与主题工作台 |
| `components/` | 主 UI 组件与共享工作区 |
| `features/` | 浏览器运行时等较独立特性域 |
| `hooks/` | 业务逻辑 Hooks |
| `lib/api/` | Tauri API 与运行时封装 |
+35 -9
View File
@@ -143,7 +143,7 @@ npm run test:contracts
4. 点击 `新建文稿`
5. 选择 `新开帖子(创建新文稿)`
6. 点击 `确认生成`
7. 验证页面出现 `Theme Workbench` 或相关工作台内容
7. 验证页面出现通用工作区相关内容
8. 再次检查控制台 error
9. 如能查看运行时摘要,继续确认当前 gate 与任务标题恢复自该话题最近一次 `execution_runtime.recent_gate_key / recent_run_title`
10. 当前项目管理与工作台侧已下线“项目风格 / 风格策略”旧入口,不再对其做存在性验证;如页面仍出现相关入口,应判定为回流
@@ -189,10 +189,11 @@ npm run test:contracts
5. 刷新页面或切换会话再返回原话题,确认最近图片任务会从 `.lime/tasks` 恢复
6. 如手动打开右侧查看器,确认任务卡状态与聊天区一致,且不会自动展开独立图片画布
7. 如当前界面已暴露任务控制入口,确认 `get/list/retry/cancel` 仍然只经由 task file 主链,不会回流前端直连图片服务
8. 如果任务来自文稿工具栏的 inline 配图,确认正文先出现占位图块,task file 成功回填后同一位置被真实图片替换,而不是在正文末尾额外追加第二张图
9. 刷新页面后再次返回该文稿,确认 inline 配图仍能通过 task file 中的 `relationships.slot_id` 恢复并原位替换,不依赖前端内存状态
10. 如果当前文稿已有明确小节并且用户在某一节内发起配图,确认占位图与最终图片会优先落到 `anchor_section_title` 指向的小节,而不是默认追加到全文末尾
11. 如果用户是在某个具体段落上发起配图,确认占位图与最终图片会优先落到 `anchor_text` 对应段落之后,而不是只落到该小节顶部
8. 如果任务来自文稿工具栏的 inline 配图、封面位或图片工作台动作,确认聊天区也会出现一条对应的用户消息与 `image_generate` 工具轨迹,而不是只有 task 卡突然出现
9. 如果任务来自文稿工具栏的 inline 配图,确认正文先出现占位图块,task file 成功回填后同一位置被真实图片替换,而不是在正文末尾额外追加第二张图
10. 刷新页面后再次返回该文稿,确认 inline 配图仍能通过 task file 中的 `relationships.slot_id` 恢复并原位替换,不依赖前端内存状态
11. 如果当前文稿已有明确小节并且用户在某一节内发起配图,确认占位图与最终图片会优先落到 `anchor_section_title` 指向的小节,而不是默认追加到全文末尾
12. 如果用户是在某个具体段落上发起配图,确认占位图与最终图片会优先落到 `anchor_text` 对应段落之后,而不是只落到该小节顶部
### Claw `@封面` 异步任务验证
@@ -214,6 +215,22 @@ npm run test:contracts
6. 刷新页面或切换会话再返回原话题,确认最近转写任务仍可从 `.lime/tasks` 恢复
7. 如当前界面已暴露任务控制入口,确认 `get/list/retry/cancel` 仍然只经由 task file 主链,不会回流前端旧 ASR 接口
### Claw `@研报` Prompt Skill 验证
1. 在 `Claw` 对话框输入 `@研报 关键词:AI Agent 融资 站点:36Kr 时间:近30天 重点:融资额与代表产品 输出:投资人研报`
2. 确认聊天区先进入 skill 执行态,并能看到 `report_generate` 与 `search_query` 的真实工具轨迹,而不是前端静默直接生成长文
3. 确认首个 skill 调用来自 `report_generate`,而不是退回 `research` 或普通聊天回答
4. 等待结果完成后,确认最终输出包含结论、来源、风险/待确认项与建议动作,而不是一段无来源的纯主观总结
5. 如果输入里没有明确主题,确认 Agent 最多只追问 1 个关键问题,而不是直接伪造研报完成态
### Claw `@读PDF` Prompt Skill 验证
1. 在 `Claw` 对话框输入 `@读PDF /tmp/agent-report.pdf 提炼三点结论并标注关键证据`
2. 确认聊天区先进入 skill 执行态,并能看到 `pdf_read` 与 `list_directory / read_file` 的真实工具轨迹,而不是前端静默直接给出摘要
3. 确认首个 skill 调用来自 `pdf_read`,而不是退回 `summary`、`analysis` 或普通聊天回答
4. 如果输入的是本地路径,确认 Agent 不会再追问“请上传 PDF”,而是直接读取并输出文档信息、核心要点、关键证据
5. 如果输入里只有 PDF URL,确认 Agent 最多只追问 1 个关键问题请求本地路径或导入工作区,而不是伪造“已读 PDF”
### Claw `@链接解析` 异步任务验证
1. 在 `Claw` 对话框输入 `@链接解析 https://example.com/agent 提取要点 并整理成投资人可读摘要`
@@ -231,6 +248,15 @@ npm run test:contracts
4. 打开控制台,确认浏览器模式接通 DevBridge 时不再出现 `execute_skill`、`list_executable_skills` 或 `get_skill_detail` 的 unknown command 报错
5. 如当前 skill 设计为走 `Bash -> lime ...`,继续确认最终反馈的是任务提交摘要或任务状态,而不是前端本地伪造成功态
### Slash Scene / ServiceSkill 验证
1. 进入 `Claw` 对话框,确认当前租户目录里存在一个 `entries.kind=scene` 的场景,例如 `/daily-trend-brief`
2. 输入 `/daily-trend-brief 帮我整理今天的小红书趋势赛题`
3. 确认聊天区先出现正常的用户消息,再进入 Agent 执行态,而不是前端静默直接提交云端 run
4. 打开时间线,确认首个执行器是 `lime_run_service_skill`,而不是前端本地直接产出结果卡
5. 如当前 OEM 会话可用,确认工具结果会回流 run 状态或摘要;若当前会话缺失,确认聊天区明确提示需要登录或注入会话,而不是伪造成功
6. 未命中 scene 目录时,确认 `/unknown-scene ...` 仍回到普通 slash / Codex 流程,不会被误报为本地技能异常
### 开发者页站点来源导入验证
1. 进入 `设置 -> 开发者`
@@ -360,15 +386,15 @@ npm run test:contracts
### 话题主题上下文恢复验证
1. 进入普通对话话题完成一次发送,再切到 `Theme Workbench` 话题完成一次发送
1. 进入普通对话话题完成一次发送,再切到通用工作区话题完成一次发送
2. 在两个话题之间来回切换,必要时新建一个空白话题再切回
3. 验证 UI 恢复的是该话题最近一次主题上下文,而不是页面一次性参数或主题级缓存
4. 如能查看调试面板或运行时摘要,继续确认依据是当前话题最近一次 `execution_runtime.recent_theme / recent_session_mode`
5. 再从普通对话切到新的 `theme_workbench` 后立即发送一次,确认同步窗口内仍命中新 theme / session mode,而不是被旧 runtime 误覆盖
5. 再从普通对话切到新的 `general_workbench` 后立即发送一次,确认同步窗口内仍命中新 theme / session mode,而不是被旧 runtime 误覆盖
### Theme Workbench 运行阶段恢复验证
### 通用工作区运行阶段恢复验证
1. 进入同一个 Theme Workbench 话题,至少完成一次 `write_mode` 或 `publish_confirm` 阶段发送
1. 进入同一个通用工作区话题,至少完成一次 `write_mode` 或 `publish_confirm` 阶段发送
2. 留在同一话题下再次发送,保持当前 gate 和任务标题不变
3. 验证本轮仍衔接当前 gate / 任务标题,而不是掉回旧阶段或空标题
4. 如能查看调试面板或运行时摘要,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_gate_key / recent_run_title`
+96 -4
View File
@@ -183,10 +183,19 @@ npm run bridge:health -- --timeout-ms 120000
- 检查 harness metadata / execution runtime / 后端 request metadata 的关键字段是否漂移
- 检查浏览器桥接 / mock 优先路径是否同步
- 检查 `DevBridge` 是否可用
- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task file` 主链,以及显式 task 动作是否仍然只写 task file,而不是回流前端直连图片服务
- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task file` 主链,以及显式图片动作是否也已经走 `synthetic user message / displayContent -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task file`,而不是回流前端直连图片服务
- 检查纯文本 `Claw @封面` 是否已经走 `原始用户消息 -> harness.cover_skill_launch -> Agent 首刀 Skill(cover_generate) -> task file` 主链,而不是回流成普通图片命令或前端本地伪造结果
- 检查纯文本 `Claw @播报` 是否已经走 `原始用户消息 -> harness.broadcast_skill_launch -> Agent 首刀 Skill(broadcast_generate) -> task file` 主链,而不是退回普通聊天改写
- 检查纯文本 `Claw @素材` 是否已经走 `原始用户消息 -> harness.resource_search_skill_launch -> Agent 首刀 Skill(modal_resource_search) -> task file` 主链,而不是回流到前端本地素材页逻辑
- 检查纯文本 `Claw @搜索` 是否已经走 `原始用户消息 -> harness.research_skill_launch -> Agent 首刀 Skill(research) -> search_query / tool timeline` 主链,而不是直接凭模型记忆回答
- 检查纯文本 `Claw @深搜` 是否已经走 `原始用户消息 -> harness.deep_search_skill_launch -> Agent 首刀 Skill(research) -> 多轮 search_query / tool timeline` 主链,而不是退化成一次普通搜索
- 检查纯文本 `Claw @研报` 是否已经走 `原始用户消息 -> harness.report_skill_launch -> Agent 首刀 Skill(report_generate) -> search_query / tool timeline` 主链,而不是直接退回普通聊天长文
- 检查纯文本 `Claw @站点搜索` 是否已经走 `原始用户消息 -> harness.site_search_skill_launch -> Agent 首刀 Skill(site_search) -> lime_site_* / tool timeline` 主链,而不是先退回 `research / WebSearch`
- 检查纯文本 `Claw @读PDF` 是否已经走 `原始用户消息 -> harness.pdf_read_skill_launch -> Agent 首刀 Skill(pdf_read) -> list_directory / read_file / tool timeline` 主链,而不是退回普通聊天总结或前端本地解析
- 检查纯文本 `Claw @转写` 是否已经走 `原始用户消息 -> harness.transcription_skill_launch -> Agent 首刀 Skill(transcription_generate) -> task file` 主链,而不是回流到前端直连旧 ASR 接口
- 检查纯文本 `Claw @链接解析` 是否已经走 `原始用户消息 -> harness.url_parse_skill_launch -> Agent 首刀 Skill(url_parse) -> task file` 主链,而不是退回普通聊天总结
- 检查纯文本 `Claw @排版` 是否已经走 `原始用户消息 -> harness.typesetting_skill_launch -> Agent 首刀 Skill(typesetting) -> task file` 主链,而不是退回普通聊天润色
- 检查产品型 `/scene-key` 是否已经走 `原始用户消息 -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链,而不是前端直接调用云端 run API
高频场景:
@@ -206,10 +215,22 @@ npm run bridge:health -- --timeout-ms 120000
- 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级
- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接
- 修改 `get_model_registry_provider_ids`、Provider 模型映射或 `src-tauri/resources/models/index.json` 真相源读取语义
- 修改 `create_image_generation_task_artifact`、`get_media_task_artifact`、`list_media_task_artifacts`、`cancel_media_task_artifact`、`src/lib/api/mediaTasks.ts`、`src/lib/api/skill-execution.ts`、`useWorkspaceSendActions`、`runtime_turn`,或调整 `Claw @配图 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task file` 的异步图片任务主链
- 修改 `create_image_generation_task_artifact`、`get_media_task_artifact`、`list_media_task_artifacts`、`cancel_media_task_artifact`、`src/lib/api/mediaTasks.ts`、`src/lib/api/skill-execution.ts`、`useWorkspaceSendActions`、`useWorkspaceImageWorkbenchActionRuntime`、`runtime_turn`,或调整 `Claw @配图 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task file` 的异步图片任务主链
- 修改 `@封面` parser、`useWorkspaceSendActions`、`runtime_turn`、`cover_skill_launch`、`lime task create cover`、`cover_generate` skill 或 `lime_create_cover_generation_task`,尤其是调整 `Claw @封面 -> harness.cover_skill_launch -> Agent 首刀 Skill(cover_generate) -> task file` 主链
- 修改 `@播报` parser、`useWorkspaceSendActions`、`runtime_turn`、`broadcast_skill_launch`、`lime task create broadcast`、`broadcast_generate` skill 或 `lime_create_broadcast_generation_task`,尤其是调整 `Claw @播报 -> harness.broadcast_skill_launch -> Agent 首刀 Skill(broadcast_generate) -> task file` 主链
- 修改 `@素材` parser、`useWorkspaceSendActions`、`runtime_turn`、`resource_search_skill_launch`、`lime task create resource-search`、`modal_resource_search` skill 或 `lime_create_modal_resource_search_task`,尤其是调整 `Claw @素材 -> harness.resource_search_skill_launch -> Agent 首刀 Skill(modal_resource_search) -> task file` 主链
- 修改 `@搜索` parser、`useWorkspaceSendActions`、`runtime_turn`、`research_skill_launch`、`research` 默认 skill 或相关 tool timeline 展示,尤其是调整 `Claw @搜索 -> harness.research_skill_launch -> Agent 首刀 Skill(research) -> search_query / timeline` 主链
- 修改 `@深搜` parser、`useWorkspaceSendActions`、`runtime_turn`、`deep_search_skill_launch`、`research` 默认 skill 或相关 tool timeline 展示,尤其是调整 `Claw @深搜 -> harness.deep_search_skill_launch -> Agent 首刀 Skill(research) -> 多轮 search_query / timeline` 主链
- 修改 `@研报` parser、`useWorkspaceSendActions`、`runtime_turn`、`report_skill_launch`、`report_generate` 默认 skill 或相关 tool timeline 展示,尤其是调整 `Claw @研报 -> harness.report_skill_launch -> Agent 首刀 Skill(report_generate) -> search_query / timeline` 主链
- 修改 `@站点搜索` parser、`useWorkspaceSendActions`、`runtime_turn`、`site_search_skill_launch`、`site_search` 默认 skill 或相关 `lime_site_*` timeline 展示,尤其是调整 `Claw @站点搜索 -> harness.site_search_skill_launch -> Agent 首刀 Skill(site_search) -> lime_site_* / timeline` 主链
- 修改 `@读PDF` parser、`useWorkspaceSendActions`、`runtime_turn`、`pdf_read_skill_launch`、`pdf_read` 默认 skill 或相关 `list_directory / read_file` timeline 展示,尤其是调整 `Claw @读PDF -> harness.pdf_read_skill_launch -> Agent 首刀 Skill(pdf_read) -> list_directory / read_file / timeline` 主链
- 修改 `@总结` parser、`useWorkspaceSendActions`、`runtime_turn`、`summary_skill_launch`、`summary` 默认 skill 或相关 skill / tool timeline 展示,尤其是调整 `Claw @总结 -> harness.summary_skill_launch -> Agent 首刀 Skill(summary) -> 可选 list_directory/read_file / timeline` 主链
- 修改 `@翻译` parser、`useWorkspaceSendActions`、`runtime_turn`、`translation_skill_launch`、`translation` 默认 skill 或相关 skill / tool timeline 展示,尤其是调整 `Claw @翻译 -> harness.translation_skill_launch -> Agent 首刀 Skill(translation) -> 可选 list_directory/read_file / timeline` 主链
- 修改 `@分析` parser、`useWorkspaceSendActions`、`runtime_turn`、`analysis_skill_launch`、`analysis` 默认 skill 或相关 skill / tool timeline 展示,尤其是调整 `Claw @分析 -> harness.analysis_skill_launch -> Agent 首刀 Skill(analysis) -> 可选 list_directory/read_file / timeline` 主链
- 修改 `@转写` parser、`useWorkspaceSendActions`、`runtime_turn`、`transcription_skill_launch`、`lime task create transcription`、`transcription_generate` skill 或 `lime_create_transcription_task`,尤其是调整 `Claw @转写 -> harness.transcription_skill_launch -> Agent 首刀 Skill(transcription_generate) -> task file` 主链
- 修改 `@链接解析` parser、`useWorkspaceSendActions`、`runtime_turn`、`url_parse_skill_launch`、`lime task create url-parse`、`url_parse` skill 或 `lime_create_url_parse_task`,尤其是调整 `Claw @链接解析 -> harness.url_parse_skill_launch -> Agent 首刀 Skill(url_parse) -> task file` 主链
- 修改 `@排版` parser、`useWorkspaceSendActions`、`runtime_turn`、`typesetting_skill_launch`、`lime task create typesetting`、`typesetting` skill 或 `lime_create_typesetting_task`,尤其是调整 `Claw @排版 -> harness.typesetting_skill_launch -> Agent 首刀 Skill(typesetting) -> task file` 主链
- 修改 `/scene-key` 解析、`serviceSkillSceneLaunch`、`useWorkspaceSendActions`、`runtime_turn`、`prompt_context`、`lime_run_service_skill` 或 `client/skills` scene 目录协议,尤其是调整 `Claw /scene-key -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链
- 修改 `src/lib/dev-bridge/`
- 修改 `src/lib/tauri-mock/`
- 修改 `src-tauri/src/app/runner.rs`
@@ -219,6 +240,7 @@ npm run bridge:health -- --timeout-ms 120000
- `npm run test:contracts`
- `imageWorkbenchCommand`、`useWorkspaceSendActions`、受影响 skill / image task Hook 单测,以及 `aster_agent_cmd` 图片主链定向测试
- 如果本轮还改了显式图片动作入口,例如文稿 inline 配图、封面位或图片工作台编辑/重绘,额外覆盖 `useWorkspaceImageWorkbenchActionRuntime` 或对应发送桥接回归
- 若本轮还改了显式 `execute_skill` 的 `images / requestContext` 透传或 compat 续接,额外覆盖 `skillCommand` 回归
- 受影响的 `image task` / `image workbench` Hook 单测
- `npm run verify:gui-smoke`
@@ -238,6 +260,69 @@ npm run bridge:health -- --timeout-ms 120000
- `npm run test:contracts`
- `npm run verify:gui-smoke`
如果本轮修改了 `Claw @播报` 或播报任务协议,最低校验至少包含:
- `broadcastWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 播报主链定向测试
- `lime-cli` 播报任务创建测试、受影响的默认 skill / tool catalog 测试
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @素材` 或素材检索任务协议,最低校验至少包含:
- `resourceSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 素材检索主链定向测试
- `lime-cli` 资源检索任务创建测试、受影响的默认 skill / tool catalog 测试
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @搜索` 或搜索 prompt skill 协议,最低校验至少包含:
- `searchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 搜索主链定向测试
- `research` 默认 skill / tool catalog 相关回归
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @深搜` 或深搜 prompt skill 协议,最低校验至少包含:
- `deepSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 深搜主链定向测试
- `research` 默认 skill / tool catalog 相关回归,且要确认没有退化成只执行一轮浅搜
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @研报` 或研报 prompt skill 协议,最低校验至少包含:
- `reportWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 研报主链定向测试
- `report_generate` 默认 skill / `skillCatalog` 相关回归,且要确认没有退回普通聊天长文或跳过真实 `search_query`
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @站点搜索` 或站点搜索 prompt skill 协议,最低校验至少包含:
- `siteSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 站点搜索主链定向测试
- `site_search` 默认 skill / `lime_site_*` tool catalog 相关回归
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @总结` 或总结 prompt skill 协议,最低校验至少包含:
- `summaryWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 总结主链定向测试
- `summary` 默认 skill / `skillCatalog` 相关回归;若支持文件路径总结,还要确认没有跳过真实 `list_directory / read_file` timeline
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @翻译` 或翻译 prompt skill 协议,最低校验至少包含:
- `translationWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 翻译主链定向测试
- `translation` 默认 skill / `skillCatalog` 相关回归;若支持文件路径翻译,还要确认没有跳过真实 `list_directory / read_file` timeline
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @分析` 或分析 prompt skill 协议,最低校验至少包含:
- `analysisWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 分析主链定向测试
- `analysis` 默认 skill / `skillCatalog` 相关回归;若支持文件路径分析,还要确认没有跳过真实 `list_directory / read_file` timeline
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @转写` 或转写任务协议,最低校验至少包含:
- `transcriptionWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 转写主链定向测试
@@ -252,6 +337,13 @@ npm run bridge:health -- --timeout-ms 120000
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 `Claw @排版` 或排版任务协议,最低校验至少包含:
- `typesettingWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 排版主链定向测试
- `lime-cli` 排版任务创建测试、受影响的默认 skill / tool catalog 测试
- `npm run test:contracts`
- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke`
如果本轮修改了 Provider 模型真相源或设置页中的“支持的模型”展示逻辑,还应额外确认:
- 资源索引损坏时,GUI 会明确提示“模型真相源异常”
@@ -300,7 +392,7 @@ npm run bridge:health -- --timeout-ms 120000
- 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id`
- 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode`
- 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode`
- 切换到新 theme 或 `general_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode`
- 如果这次改动影响 `harness.team_memory_shadow` 这类 repo-scoped Team 协作上下文,除了契约检查之外,还应补:
- 前端发送边界回归,确认 `team_memory_shadow` 能随当前请求进入 `agent_runtime_submit_turn`
- Rust `prompt_context` 定向测试,确认 shadow 只作为低优先级协作参考,不覆盖显式 `selected_team_*` 或 `recent_team_selection`
@@ -315,7 +407,7 @@ npm run bridge:health -- --timeout-ms 120000
- execution_runtime 缺失但本地 shadow 已命中时,前端仍会回填 `recent_access_mode` 到 session
- 如果这次改动把 `gate_key / run_title` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_gate_key / recent_run_title` 时,前端不会重复提交相同 `harness.gate_key / harness.run_title`
- 切换到新的 Theme Workbench gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title`
- 切换到新的通用工作区 gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title`
- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`site_get_adapter_launch_readiness` 门禁、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。
- 如果这次改动影响浏览器资料 / 环境预设的真实来源,还应补一次浏览器模式实测,确认控制台不再出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`。
- 如果这次改动影响设置页“连接器”主路径或 Chrome 扩展导出链路,除了 `test:contracts`,还应补对应设置页回归,并在 GUI smoke 或 Playwright 续测里确认连接器页能打开、目录可选、扩展状态可读。
@@ -1,8 +1,10 @@
# Skills 异步图片任务与动态渲染 PRD
> 说明:本文件已降级为历史背景文档,`@配图` current 主方案请以 [docs/prd/gongneng/peitu/prd.md](/Users/coso/Documents/dev/ai/aiclientproxy/lime/docs/prd/gongneng/peitu/prd.md) 与 [docs/aiprompts/command-runtime.md](/Users/coso/Documents/dev/ai/aiclientproxy/lime/docs/aiprompts/command-runtime.md) 为准。当前首发入口已经统一收敛到 `image_skill_launch -> Agent -> Skill(image_generate) -> task file` 主链,不再保留“前端快路径”作为 current 实现。
> 文档版本:v1.0
> 状态:Draft
> 更新时间:2026-04-03
> 更新时间:2026-04-06
> 适用范围:`image_generate` skill、`lime-cli`、媒体任务协议、Claw 对话框、图片工作台
> 目标读者:产品、前端、Rust/CLI、测试
@@ -585,4 +587,3 @@ skill 输出的语义是:
- 测试可做
- 能力可组合
- 后续可扩展到队列、重试、自动化与多 worker
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.3.0",
"version": "1.4.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+1 -1
View File
@@ -110,7 +110,7 @@ npm run build:release -- \
```bash
npm run build:release -- \
--target-triple "aarch64-apple-darwin" \
--version "1.3.0" \
--version "1.4.0" \
--out-dir "./dist"
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.3.0",
"version": "1.4.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
+21 -21
View File
@@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster-core"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"ahash",
"anyhow",
@@ -470,7 +470,7 @@ dependencies = [
[[package]]
name = "aster-models"
version = "0.26.0"
version = "0.27.0"
dependencies = [
"serde",
"serde_json",
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"anyhow",
"arboard",
@@ -5206,7 +5206,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5235,7 +5235,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"chrono",
"futures",
@@ -5252,7 +5252,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"clap",
"lime-media-runtime",
@@ -5262,7 +5262,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5278,7 +5278,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5318,7 +5318,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5353,7 +5353,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5383,7 +5383,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5403,7 +5403,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5419,7 +5419,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"chrono",
"serde",
@@ -5447,7 +5447,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5466,7 +5466,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5521,7 +5521,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5566,7 +5566,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5581,7 +5581,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5623,7 +5623,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5641,7 +5641,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5668,7 +5668,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.3.0"
version = "1.4.0"
dependencies = [
"axum 0.7.9",
"chrono",
+4 -4
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.3.0"
version = "1.4.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -129,8 +129,8 @@ enigo = "0.3"
# 如需联调本地 aster-rust,请运行:
# npm run setup:local-aster -- /path/to/aster-rust
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.26.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.26.0" }
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.27.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.27.0" }
# MCP (Model Context Protocol)
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
@@ -194,7 +194,7 @@ version = "2.4"
[package]
name = "lime"
version = "1.3.0"
version = "1.4.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -97,6 +97,11 @@ impl CredentialBridge {
}
}
fn resolve_fallback_api_key_id<'a>(&self, uuid: &'a str) -> Option<&'a str> {
uuid.strip_prefix("fallback-")
.filter(|value| !value.is_empty())
}
/// 从凭证池选择凭证并创建 Aster Provider 配置
///
/// # 参数
@@ -370,6 +375,13 @@ impl CredentialBridge {
/// 记录凭证使用
pub fn record_usage(&self, db: &DbConnection, uuid: &str) -> Result<(), CredentialBridgeError> {
if let Some(api_key_id) = self.resolve_fallback_api_key_id(uuid) {
return self
.api_key_service
.record_usage(db, api_key_id)
.map_err(CredentialBridgeError::DatabaseError);
}
self.pool_service
.record_usage(db, uuid)
.map_err(CredentialBridgeError::DatabaseError)
@@ -382,6 +394,10 @@ impl CredentialBridge {
uuid: &str,
model: Option<&str>,
) -> Result<(), CredentialBridgeError> {
if self.resolve_fallback_api_key_id(uuid).is_some() {
return Ok(());
}
self.pool_service
.mark_healthy(db, uuid, model)
.map_err(CredentialBridgeError::DatabaseError)
@@ -394,6 +410,10 @@ impl CredentialBridge {
uuid: &str,
error: Option<&str>,
) -> Result<(), CredentialBridgeError> {
if self.resolve_fallback_api_key_id(uuid).is_some() {
return Ok(());
}
self.pool_service
.mark_unhealthy(db, uuid, error)
.map_err(CredentialBridgeError::DatabaseError)
@@ -511,10 +511,23 @@ fn extract_recent_harness_context_from_metadata(
.and_then(|value| extract_text_from_object(value, keys))
.or_else(|| extract_text_from_metadata(metadata, keys))
};
let normalize_session_mode = |value: Option<String>| -> Option<String> {
match value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some("theme_workbench") | Some("general_workbench") => {
Some("general_workbench".to_string())
}
Some("default") => Some("default".to_string()),
_ => None,
}
};
RecentHarnessContext {
theme: resolve_text(&["theme", "harness_theme", "harnessTheme"]),
session_mode: resolve_text(&["session_mode", "sessionMode"]),
session_mode: normalize_session_mode(resolve_text(&["session_mode", "sessionMode"])),
gate_key: resolve_text(&["gate_key", "gateKey"]),
run_title: resolve_text(&["run_title", "runTitle", "title"]),
content_id: resolve_text(&["content_id", "contentId"]),
@@ -1170,7 +1183,7 @@ mod tests {
"harness".to_string(),
json!({
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"gate_key": "write_mode",
"run_title": "社媒初稿",
"content_id": "content-current"
@@ -1204,7 +1217,7 @@ mod tests {
assert_eq!(runtime.recent_theme.as_deref(), Some("general"));
assert_eq!(
runtime.recent_session_mode.as_deref(),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(runtime.recent_gate_key.as_deref(), Some("write_mode"));
assert_eq!(runtime.recent_run_title.as_deref(), Some("社媒初稿"));
@@ -1291,7 +1304,7 @@ mod tests {
.insert("theme".to_string(), json!("document"));
thread
.metadata
.insert("session_mode".to_string(), json!("theme_workbench"));
.insert("session_mode".to_string(), json!("general_workbench"));
thread
.metadata
.insert("gate_key".to_string(), json!("publish_confirm"));
@@ -1323,7 +1336,7 @@ mod tests {
assert_eq!(runtime.recent_theme.as_deref(), Some("document"));
assert_eq!(
runtime.recent_session_mode.as_deref(),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(runtime.recent_gate_key.as_deref(), Some("publish_confirm"));
assert_eq!(runtime.recent_run_title.as_deref(), Some("发布确认"));
@@ -69,8 +69,19 @@ pub enum TurnPromptAugmentationStageKind {
ImageSkillLaunch,
CoverSkillLaunch,
VideoSkillLaunch,
BroadcastSkillLaunch,
ResourceSearchSkillLaunch,
ResearchSkillLaunch,
ReportSkillLaunch,
DeepSearchSkillLaunch,
SiteSearchSkillLaunch,
PdfReadSkillLaunch,
SummarySkillLaunch,
TranslationSkillLaunch,
AnalysisSkillLaunch,
TranscriptionSkillLaunch,
UrlParseSkillLaunch,
TypesettingSkillLaunch,
ServiceSkillLaunch,
ServiceSkillLaunchPreload,
Elicitation,
+7 -6
View File
@@ -40,11 +40,12 @@ pub use skill_model::{
parse_skill_manifest_from_content, resolve_skill_source_kind, split_skill_frontmatter,
summarize_skill_resources_dir, ParsedSkillManifest, Skill, SkillCatalogSource, SkillMetadata,
SkillPackageInspection, SkillRepo, SkillResourceSummary, SkillSourceKind,
SkillStandardCompliance, SkillState, SkillStates, BROADCAST_GENERATE_SKILL_DIRECTORY,
CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
DEFAULT_LIME_SKILL_DIRECTORIES, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY,
MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY,
TRANSCRIPTION_GENERATE_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
VIDEO_GENERATE_SKILL_DIRECTORY,
SkillStandardCompliance, SkillState, SkillStates, ANALYSIS_SKILL_DIRECTORY,
BROADCAST_GENERATE_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY,
COVER_GENERATE_SKILL_DIRECTORY, DEFAULT_LIME_SKILL_DIRECTORIES, IMAGE_GENERATE_SKILL_DIRECTORY,
LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, PDF_READ_SKILL_DIRECTORY,
REPORT_GENERATE_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY,
SUMMARY_SKILL_DIRECTORY, TRANSCRIPTION_GENERATE_SKILL_DIRECTORY, TRANSLATION_SKILL_DIRECTORY,
TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY, VIDEO_GENERATE_SKILL_DIRECTORY,
};
pub use vertex_model::{VertexApiKeyEntry, VertexModelAlias};
@@ -30,11 +30,16 @@ pub const IMAGE_GENERATE_SKILL_DIRECTORY: &str = "image_generate";
pub const LIBRARY_SKILL_DIRECTORY: &str = "library";
pub const URL_PARSE_SKILL_DIRECTORY: &str = "url_parse";
pub const RESEARCH_SKILL_DIRECTORY: &str = "research";
pub const REPORT_GENERATE_SKILL_DIRECTORY: &str = "report_generate";
pub const SITE_SEARCH_SKILL_DIRECTORY: &str = "site_search";
pub const PDF_READ_SKILL_DIRECTORY: &str = "pdf_read";
pub const SUMMARY_SKILL_DIRECTORY: &str = "summary";
pub const TRANSLATION_SKILL_DIRECTORY: &str = "translation";
pub const ANALYSIS_SKILL_DIRECTORY: &str = "analysis";
pub const TYPESETTING_SKILL_DIRECTORY: &str = "typesetting";
pub const CONTENT_POST_WITH_COVER_SKILL_DIRECTORY: &str = "content_post_with_cover";
pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 12] = [
pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 17] = [
VIDEO_GENERATE_SKILL_DIRECTORY,
TRANSCRIPTION_GENERATE_SKILL_DIRECTORY,
BROADCAST_GENERATE_SKILL_DIRECTORY,
@@ -44,7 +49,12 @@ pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 12] = [
LIBRARY_SKILL_DIRECTORY,
URL_PARSE_SKILL_DIRECTORY,
RESEARCH_SKILL_DIRECTORY,
REPORT_GENERATE_SKILL_DIRECTORY,
SITE_SEARCH_SKILL_DIRECTORY,
PDF_READ_SKILL_DIRECTORY,
SUMMARY_SKILL_DIRECTORY,
TRANSLATION_SKILL_DIRECTORY,
ANALYSIS_SKILL_DIRECTORY,
TYPESETTING_SKILL_DIRECTORY,
CONTENT_POST_WITH_COVER_SKILL_DIRECTORY,
];
@@ -563,7 +573,10 @@ mod tests {
#[test]
fn test_default_lime_skill_directories_include_embedded_defaults() {
assert!(is_default_lime_skill(VIDEO_GENERATE_SKILL_DIRECTORY));
assert!(is_default_lime_skill(REPORT_GENERATE_SKILL_DIRECTORY));
assert!(is_default_lime_skill(SITE_SEARCH_SKILL_DIRECTORY));
assert!(is_default_lime_skill(PDF_READ_SKILL_DIRECTORY));
assert!(is_default_lime_skill(SUMMARY_SKILL_DIRECTORY));
assert!(is_default_lime_skill(
CONTENT_POST_WITH_COVER_SKILL_DIRECTORY
));
@@ -1,7 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 2dad3ab4b62243d81a62ce7df3d361f9a27633cc9ccf50cc78c370c66814f5bc # shrinks to expires_in_secs = 601
@@ -24,18 +24,23 @@ use lime_core::workspace::WorkspaceManager;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex as StdMutex;
/// Lime 的 SessionStore 实现
///
/// 将 aster 的会话数据存储到 Lime 的 SQLite 数据库
pub struct LimeSessionStore {
db: DbConnection,
metadata_cache: StdMutex<HashMap<String, Session>>,
}
impl LimeSessionStore {
/// 创建新的 SessionStore 实例
pub fn new(db: DbConnection) -> Self {
Self { db }
Self {
db,
metadata_cache: StdMutex::new(HashMap::new()),
}
}
pub fn load_extension_data_from_conn(
@@ -72,6 +77,42 @@ impl LimeSessionStore {
raw.and_then(|text| serde_json::from_str(&text).ok())
}
fn parse_timestamp_or_now(raw: &str) -> chrono::DateTime<Utc> {
chrono::DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now())
}
fn cache_session_metadata(&self, session: &Session) {
let mut cached = session.clone();
cached.conversation = None;
if let Ok(mut metadata_cache) = self.metadata_cache.lock() {
metadata_cache.insert(cached.id.clone(), cached);
}
}
fn cached_session_metadata(&self, session_id: &str) -> Option<Session> {
self.metadata_cache
.lock()
.ok()
.and_then(|metadata_cache| metadata_cache.get(session_id).cloned())
}
fn invalidate_cached_session_metadata(&self, session_id: &str) {
if let Ok(mut metadata_cache) = self.metadata_cache.lock() {
metadata_cache.remove(session_id);
}
}
fn update_cached_session_metadata(&self, session_id: &str, updater: impl FnOnce(&mut Session)) {
if let Ok(mut metadata_cache) = self.metadata_cache.lock() {
if let Some(session) = metadata_cache.get_mut(session_id) {
updater(session);
}
}
}
fn resolve_session_type(raw: Option<String>, model: &str) -> SessionType {
let parsed_model = model.parse::<SessionType>().ok();
match raw
@@ -211,7 +252,7 @@ impl SessionStore for LimeSessionStore {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
Self::insert_session_row(&conn, &id, &name, &working_dir, session_type)?;
Ok(Session {
let session = Session {
id,
working_dir,
name,
@@ -233,19 +274,33 @@ impl SessionStore for LimeSessionStore {
message_count: 0,
provider_name: None,
model_config: None,
})
};
self.cache_session_metadata(&session);
Ok(session)
}
async fn get_session(&self, id: &str, include_messages: bool) -> Result<Session> {
tracing::info!(
"[SessionStore] get_session 被调用: id={}, include_messages={}",
if !include_messages {
if let Some(cached) = self.cached_session_metadata(id) {
tracing::debug!(
"[SessionStore] get_session 命中 metadata cache: id={}, include_messages={}",
id,
include_messages
);
return Ok(cached);
}
}
tracing::debug!(
"[SessionStore] get_session 读取数据库: id={}, include_messages={}",
id,
include_messages
);
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
Self::ensure_session_row(&conn, id)?;
tracing::info!("[SessionStore] get_session 已确保会话存在: {}", id);
tracing::debug!("[SessionStore] get_session 已确保会话存在: {}", id);
let mut stmt = conn
.prepare(
@@ -311,12 +366,8 @@ impl SessionStore for LimeSessionStore {
model_config_json,
) = session_row;
let created_at = chrono::DateTime::parse_from_rfc3339(&created_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let created_at = Self::parse_timestamp_or_now(&created_at);
let updated_at = Self::parse_timestamp_or_now(&updated_at);
let session_type = Self::resolve_session_type(session_type_raw, &model);
let working_dir = Self::parse_session_working_dir(&conn, db_working_dir);
@@ -329,7 +380,7 @@ impl SessionStore for LimeSessionStore {
let message_count = self.count_messages(&conn, &id)?;
Ok(Session {
let session = Session {
id: id.to_string(),
working_dir,
name: title.unwrap_or_else(|| "未命名会话".to_string()),
@@ -356,11 +407,14 @@ impl SessionStore for LimeSessionStore {
normalized => ModelConfig::new(normalized).ok(),
}
}),
})
};
self.cache_session_metadata(&session);
Ok(session)
}
async fn add_message(&self, session_id: &str, message: &Message) -> Result<()> {
tracing::info!(
tracing::debug!(
"[SessionStore] add_message 被调用: session_id={}",
session_id
);
@@ -414,6 +468,12 @@ impl SessionStore for LimeSessionStore {
)
.map_err(|e| anyhow!("更新会话时间失败: {e}"))?;
let updated_at = Self::parse_timestamp_or_now(&timestamp);
self.update_cached_session_metadata(session_id, |session| {
session.updated_at = updated_at;
session.message_count = session.message_count.saturating_add(1);
});
Ok(())
}
@@ -474,6 +534,12 @@ impl SessionStore for LimeSessionStore {
rusqlite::params![now, session_id],
)?;
let updated_at = Self::parse_timestamp_or_now(&now);
self.update_cached_session_metadata(session_id, |session| {
session.updated_at = updated_at;
session.message_count = conversation.messages().len();
});
Ok(())
}
@@ -619,6 +685,7 @@ impl SessionStore for LimeSessionStore {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
conn.execute("DELETE FROM agent_sessions WHERE id = ?", [id])?;
}
self.invalidate_cached_session_metadata(id);
Ok(())
}
@@ -757,6 +824,7 @@ impl SessionStore for LimeSessionStore {
"DELETE FROM agent_messages WHERE session_id = ? AND timestamp > ?",
rusqlite::params![session_id, timestamp_str],
)?;
self.invalidate_cached_session_metadata(session_id);
Ok(())
}
@@ -769,10 +837,17 @@ impl SessionStore for LimeSessionStore {
) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
let cached_name = name.clone();
conn.execute(
"UPDATE agent_sessions SET title = ?1, user_set_name = ?2, updated_at = ?3 WHERE id = ?4",
rusqlite::params![name, user_set, now, session_id],
)?;
let updated_at = Self::parse_timestamp_or_now(&now);
self.update_cached_session_metadata(session_id, |session| {
session.name = cached_name;
session.user_set_name = user_set;
session.updated_at = updated_at;
});
Ok(())
}
@@ -783,16 +858,23 @@ impl SessionStore for LimeSessionStore {
) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
let cached_extension_data = extension_data.clone();
let extension_data_json = serde_json::to_string(&extension_data)
.map_err(|e| anyhow!("序列化 extension_data 失败: {e}"))?;
conn.execute(
"UPDATE agent_sessions SET extension_data_json = ?1, updated_at = ?2 WHERE id = ?3",
rusqlite::params![extension_data_json, now, session_id],
)?;
let updated_at = Self::parse_timestamp_or_now(&now);
self.update_cached_session_metadata(session_id, |session| {
session.extension_data = cached_extension_data;
session.updated_at = updated_at;
});
Ok(())
}
async fn update_token_stats(&self, session_id: &str, stats: TokenStatsUpdate) -> Result<()> {
let normalized_schedule_id = Self::normalize_optional_text(stats.schedule_id.clone());
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
// 当前 store 边界把 None 视为“跳过更新”,不是“清空字段”。
@@ -815,11 +897,36 @@ impl SessionStore for LimeSessionStore {
stats.accumulated_total,
stats.accumulated_input,
stats.accumulated_output,
Self::normalize_optional_text(stats.schedule_id),
normalized_schedule_id.clone(),
now,
session_id,
],
)?;
let updated_at = Self::parse_timestamp_or_now(&now);
self.update_cached_session_metadata(session_id, |session| {
if let Some(total_tokens) = stats.total_tokens {
session.total_tokens = Some(total_tokens);
}
if let Some(input_tokens) = stats.input_tokens {
session.input_tokens = Some(input_tokens);
}
if let Some(output_tokens) = stats.output_tokens {
session.output_tokens = Some(output_tokens);
}
if let Some(accumulated_total) = stats.accumulated_total {
session.accumulated_total_tokens = Some(accumulated_total);
}
if let Some(accumulated_input) = stats.accumulated_input {
session.accumulated_input_tokens = Some(accumulated_input);
}
if let Some(accumulated_output) = stats.accumulated_output {
session.accumulated_output_tokens = Some(accumulated_output);
}
if let Some(schedule_id) = normalized_schedule_id {
session.schedule_id = Some(schedule_id);
}
session.updated_at = updated_at;
});
Ok(())
}
@@ -834,6 +941,8 @@ impl SessionStore for LimeSessionStore {
.as_ref()
.map(|config| config.model_name.trim().to_string())
.filter(|value| !value.is_empty());
let cached_provider_name = normalized_provider_name.clone();
let cached_model_config = model_config.clone();
let model_config_json = model_config
.as_ref()
.map(serde_json::to_string)
@@ -855,13 +964,23 @@ impl SessionStore for LimeSessionStore {
updated_at = ?4
WHERE id = ?5",
rusqlite::params![
normalized_provider_name,
normalized_provider_name.clone(),
normalized_model_name,
model_config_json,
now,
session_id,
],
)?;
let updated_at = Self::parse_timestamp_or_now(&now);
self.update_cached_session_metadata(session_id, |session| {
if let Some(provider_name) = cached_provider_name {
session.provider_name = Some(provider_name);
}
if let Some(model_config) = cached_model_config {
session.model_config = Some(model_config);
}
session.updated_at = updated_at;
});
Ok(())
}
@@ -873,6 +992,8 @@ impl SessionStore for LimeSessionStore {
) -> Result<()> {
let conn = self.db.lock().map_err(|e| anyhow!("数据库锁定失败: {e}"))?;
let now = Utc::now().to_rfc3339();
let cached_recipe = recipe.clone();
let cached_user_recipe_values = user_recipe_values.clone();
let recipe_json = recipe
.as_ref()
.map(serde_json::to_string)
@@ -892,6 +1013,12 @@ impl SessionStore for LimeSessionStore {
WHERE id = ?4",
rusqlite::params![recipe_json, user_recipe_values_json, now, session_id],
)?;
let updated_at = Self::parse_timestamp_or_now(&now);
self.update_cached_session_metadata(session_id, |session| {
session.recipe = cached_recipe;
session.user_recipe_values = cached_user_recipe_values;
session.updated_at = updated_at;
});
Ok(())
}
@@ -1345,6 +1472,76 @@ mod tests {
);
}
#[tokio::test]
async fn metadata_cache_should_refresh_after_add_message() {
let store = setup_test_store();
let session = store
.create_session(
PathBuf::from("."),
"缓存消息计数测试".to_string(),
SessionType::User,
)
.await
.expect("创建会话失败");
let cached = store
.get_session(&session.id, false)
.await
.expect("预热缓存失败");
assert_eq!(cached.message_count, 0);
store
.add_message(&session.id, &Message::user().with_text("hello"))
.await
.expect("追加消息失败");
let refreshed = store
.get_session(&session.id, false)
.await
.expect("读取缓存会话失败");
assert_eq!(refreshed.message_count, 1);
}
#[tokio::test]
async fn metadata_cache_should_refresh_after_provider_update() {
let store = setup_test_store();
let session = store
.create_session(
PathBuf::from("."),
"缓存 provider 测试".to_string(),
SessionType::User,
)
.await
.expect("创建会话失败");
store
.get_session(&session.id, false)
.await
.expect("预热缓存失败");
store
.update_provider_config(
&session.id,
Some("openai".to_string()),
Some(ModelConfig::new("gpt-4.1").expect("model config")),
)
.await
.expect("更新 provider 配置失败");
let refreshed = store
.get_session(&session.id, false)
.await
.expect("读取缓存会话失败");
assert_eq!(refreshed.provider_name.as_deref(), Some("openai"));
assert_eq!(
refreshed
.model_config
.as_ref()
.map(|config| config.model_name.as_str()),
Some("gpt-4.1")
);
}
#[tokio::test]
async fn update_recipe_should_clear_existing_values_when_input_is_none() {
let store = setup_test_store();
@@ -0,0 +1,40 @@
---
name: analysis
description: 对当前文本、对话或显式文件内容做结构化分析,并区分事实、判断与待确认项。
allowed-tools: list_directory, read_file
metadata:
lime_argument_hint: 输入待分析内容、重点、风格与输出格式要求。
lime_when_to_use: 用户需要对文本、对话或文件内容做拆解、判断、评估或风险分析时使用。
lime_version: 1.0.0
lime_execution_mode: prompt
lime_surface: chat
lime_category: reasoning
---
你是 Lime 的分析助手。
## 工作目标
对用户提供的文本、当前对话上下文或显式文件内容做结构化分析,输出结论、依据与待确认项。
## 执行规则
- 优先分析 `analysis_request.content` 或用户明确给出的正文内容。
- 如果用户明确提供本地文件路径或资料目录,可先最小化使用 `list_directory` / `read_file` 读取必要内容,再进行分析。
- 若未提供显式内容,则默认分析当前对话里与请求最相关的内容。
- 分析必须区分“原文事实”“你的判断”“待确认项”,不要把推断写成已确认事实。
- 用户要求分析重点、风格、输出格式时要显式遵循;未指定时默认给出简洁结论与依据。
- 信息不足时最多追问 1 个关键问题,不要假装已经读到不存在的内容。
## 输出格式(固定)
# 分析结果
## 结论
{核心判断}
## 依据
- {支持判断的事实或上下文依据}
## 待确认项(可选)
- {存在歧义、缺失证据或需要用户补充的点}
@@ -1,11 +1,11 @@
---
name: modal_resource_search
description: 提交资源检索任务(图片、背景音乐、音效等),供前端资源面板消费。
allowed-tools: Bash, lime_create_modal_resource_search_task
description: 检索图片、背景音乐、音效、视频等素材;图片优先直搜候选,其他资源走任务主链。
allowed-tools: Bash, lime_search_web_images, lime_create_modal_resource_search_task
metadata:
lime_argument_hint: 输入资源类型、关键词、风格、用途、数量与限制条件。
lime_when_to_use: 用户需要为当前内容补充外部素材资源时使用。
lime_version: 1.1.0
lime_version: 1.2.0
lime_execution_mode: prompt
lime_surface: workbench
lime_category: media
@@ -15,18 +15,35 @@ metadata:
## 工作目标
把素材需求结构化为“可执行检索任务”,并输出简明候选清单,方便用户快速确认。
把素材需求分流为“图片直搜候选”或“可执行检索任务”,并输出简明结果,方便用户快速确认。
## 执行规则
- 先明确资源类型(图片/BGM/音效)和使用场景。
- 检索关键词控制在 1-3 个核心词,避免长句。
- 优先给出高相关候选,不要堆无关结果。
- 优先调用 `Bash` 执行 `lime task create resource-search --json` 创建任务。
- 如果 `resourceType=image` 且 `query` 明确:
- 第一优先调用 `lime_search_web_images`,直接复用当前设置里的 `Pexels API Key` 搜图。
- 不要先调用 `ToolSearch`、`WebSearch`、`Grep` 之类去“找怎么搜图”,也不要把明显的配图需求拉成长链工具搜索。
- 若 `lime_search_web_images` 返回候选,直接输出图片候选摘要,不要伪造“任务已创建”。
- 若返回 `Pexels API Key` 未配置、无结果,或用户明确要求继续异步追踪,再回退到任务链。
- 如果 `resourceType` 是 `bgm` / `sfx` / `video`,优先调用 `Bash` 执行 `lime task create resource-search --json` 创建任务。
- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_modal_resource_search_task`。
- `payload` 中至少包含:`resourceType`、`query`、`usage`、`count`。
- 创建任务时,`payload` 中至少包含:`resourceType`、`query`、`usage`、`count`。
## 输出格式(固定)
## 输出格式
### 图片直搜命中
仅输出图片候选摘要(不要再写 `<write_file>`):
- 检索来源:Pexels
- 检索关键词:{query}
- 候选数量:{returnedCount}
- 候选:
1. {name} | {width}x{height} | {hostPageUrl}
### 回退异步任务
仅输出任务提交摘要(不要再写 `<write_file>`):
@@ -0,0 +1,47 @@
---
name: pdf_read
description: 读取本地或工作区 PDF 内容,并输出结构化解读结果。
allowed-tools: list_directory, read_file
metadata:
lime_argument_hint: 输入 PDF 文件路径、关注重点与输出格式要求。
lime_when_to_use: 用户需要阅读本地 PDF、提炼结论、整理证据或快速理解文档时使用。
lime_version: 1.0.0
lime_execution_mode: prompt
lime_surface: chat
lime_category: research
---
你是 Lime 的 PDF 解读助手。
## 工作目标
读取用户指定的本地或工作区 PDF,输出结构化结论,并保留真实的文件读取 timeline。
## 执行规则
- 优先读取 `pdf_read_request.source_path` 指向的 PDF 文件。
- 如果是相对路径,可先最小化使用 `list_directory` 确认文件位置,再调用 `read_file` 读取 PDF 内容。
- 只有在用户明确提供本地路径或工作区路径时,才继续实际读取;不要假装文件已经读取成功。
- 如果只有 `pdf_read_request.source_url`,当前链路不保证稳定直读远程 PDF;最多追问 1 个关键问题,请用户提供本地路径或先导入工作区。
- 用户给出重点、输出格式时要显式遵循;未指定时默认提炼 3-5 条核心要点。
- 结论必须基于实际读到的 PDF 内容,不补写文档中不存在的新事实。
- 如文件不存在、不可读或内容不完整,要在结果中明确标注失败原因或待确认项。
## 输出格式(固定)
# PDF 解读
## 文档信息
- 文件:{文件名或路径}
- 解读目标:{本次关注重点}
## 核心要点
- {要点 1}
- {要点 2}
- {要点 3}
## 关键证据
- {来自 PDF 的关键事实、段落或数据}
## 待确认项(可选)
- {文件缺失、证据不足或存在歧义的部分}
@@ -0,0 +1,59 @@
---
name: report_generate
description: 联网检索并产出结构化研究报告,强调结论、证据、风险与建议。
allowed-tools: search_query
metadata:
lime_argument_hint: 输入研报主题、目标站点、时间范围、核心问题、输出对象。
lime_when_to_use: 用户需要行业研报、竞品研报、趋势研报或投资备忘时使用。
lime_version: 1.0.0
lime_execution_mode: prompt
lime_surface: chat
lime_category: research
---
你是 Lime 的研报助手。
## 工作目标
通过真实联网检索,产出“结论清晰、证据可追溯、风险与建议分离”的研究报告。
## 执行规则
- 必须先做真实检索,再写报告;不要只凭记忆直接生成研报。
- 优先拆成 2-4 个核心检索问题,不要把整段长提示原样丢给搜索。
- 如果用户要求最新信息,检索词必须带年份或明确时间范围(当前年份:2026)。
- 至少保留 3 条高价值来源;来源不足时要明确写出“不确定项”。
- 结论、证据、推断、风险要分开写,不把推断冒充事实。
- 输出应适合继续进入工作区加工,不要只返回零散片段。
## 输出格式(固定)
<write_file path="research-reports/{yyyyMMdd-HHmmss}-{slug}.md">
# 研究报告
## 研究主题
{主题}
## 核心结论
- {结论 1}
- {结论 2}
- {结论 3}
## 关键证据与来源
- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要}
- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要}
- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要}
## 关键风险与待确认项
- {风险或不确定项 1}
- {风险或不确定项 2}
## 建议动作
- {建议 1}
- {建议 2}
## 附录
- 检索关键词:{关键词列表}
- 目标时间范围:{时间范围}
- 目标站点:{站点列表}
</write_file>
@@ -0,0 +1,42 @@
---
name: summary
description: 提炼当前文本、对话或显式文件内容中的关键要点与结论。
allowed-tools: list_directory, read_file
metadata:
lime_argument_hint: 输入要总结的内容、关注重点、长度、风格与输出格式要求。
lime_when_to_use: 用户需要压缩长文本、提炼讨论重点或快速生成摘要时使用。
lime_version: 1.0.0
lime_execution_mode: prompt
lime_surface: chat
lime_category: writing
---
你是 Lime 的总结助手。
## 工作目标
把用户提供的文本、当前对话上下文或显式文件内容,压缩成高信噪比的结构化摘要。
## 执行规则
- 优先总结 `summary_request.content` 或用户明确给出的正文内容。
- 如果用户明确提供本地文件路径或资料目录,可先最小化使用 `list_directory` / `read_file` 读取必要内容,再进行总结。
- 若未提供显式内容,则默认总结当前对话里与请求最相关的内容。
- 严格保留原意,不补写原文没有的新事实。
- 用户要求长度、风格、输出格式时要显式遵循;未指定时默认输出 3-5 条关键要点。
- 信息不足时最多追问 1 个关键问题,不要假装已经读到不存在的内容。
## 输出格式(固定)
# 摘要
## 核心要点
- {要点 1}
- {要点 2}
- {要点 3}
## 关键细节(可选)
- {仅在用户要求更详细时输出}
## 待确认项(可选)
- {仅在原文信息不完整或存在歧义时输出}
@@ -0,0 +1,38 @@
---
name: translation
description: 将当前文本、对话或显式文件内容翻译成目标语言,并保留原意与关键信息。
allowed-tools: list_directory, read_file
metadata:
lime_argument_hint: 输入待翻译内容、原语言、目标语言、风格与输出格式要求。
lime_when_to_use: 用户需要把文本、对话或文件内容翻译成另一种语言时使用。
lime_version: 1.0.0
lime_execution_mode: prompt
lime_surface: chat
lime_category: writing
---
你是 Lime 的翻译助手。
## 工作目标
把用户提供的文本、当前对话上下文或显式文件内容,翻译成用户要求的目标语言,同时尽量保留原意、语气与格式。
## 执行规则
- 优先翻译 `translation_request.content` 或用户明确给出的正文内容。
- 如果用户明确提供本地文件路径或资料目录,可先最小化使用 `list_directory` / `read_file` 读取必要内容,再进行翻译。
- 若未提供显式内容,则默认翻译当前对话里与请求最相关的内容。
- 必须忠于原文,不补写原文没有的新事实或额外解释。
- 用户要求原语言、目标语言、风格、输出格式时要显式遵循;未指定时默认只输出译文。
- 人名、产品名、专有名词在必要时可保留原文,并在译文中采用最自然的表达。
- 信息不足时最多追问 1 个关键问题,不要假装已经读到不存在的内容。
## 输出格式(固定)
# 翻译结果
## 译文
{译文正文}
## 说明(可选)
- {仅在用户要求保留术语、双语对照或需要解释翻译取舍时输出}
+26 -2
View File
@@ -11,9 +11,11 @@ pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_ge
pub const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task";
pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str =
"lime_create_modal_resource_search_task";
pub const LIME_SEARCH_WEB_IMAGES_TOOL_NAME: &str = "lime_search_web_images";
pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task";
pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task";
pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task";
pub const LIME_RUN_SERVICE_SKILL_TOOL_NAME: &str = "lime_run_service_skill";
pub const LIME_SITE_LIST_TOOL_NAME: &str = "lime_site_list";
pub const LIME_SITE_RECOMMEND_TOOL_NAME: &str = "lime_site_recommend";
pub const LIME_SITE_SEARCH_TOOL_NAME: &str = "lime_site_search";
@@ -564,6 +566,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SEARCH_WEB_IMAGES_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
capabilities: SEARCH_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_CREATE_IMAGE_TASK_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
@@ -591,6 +602,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_RUN_SERVICE_SKILL_TOOL_NAME,
profiles: WORKBENCH_PROFILES,
capabilities: EXECUTION_CAP,
lifecycle: ToolLifecycle::Current,
source: ToolSourceKind::LimeInjected,
permission_plane: ToolPermissionPlane::SessionAllowlist,
workspace_default_allow: true,
},
ToolCatalogEntry {
name: LIME_SITE_LIST_TOOL_NAME,
profiles: BROWSER_PROFILES,
@@ -1085,10 +1105,12 @@ 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(), 9);
assert_eq!(names.len(), 11);
assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(LIME_CREATE_VIDEO_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));
assert!(!names.contains(TOOL_SEARCH_TOOL_NAME));
assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX));
}
@@ -1099,7 +1121,7 @@ mod tests {
let names = workspace_default_allowed_tool_names(
WorkspaceToolSurface::workbench_with_browser_assist(),
);
assert_eq!(names.len(), 43);
assert_eq!(names.len(), 45);
assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME));
assert!(names.contains(&TOOL_SEARCH_TOOL_NAME));
assert!(names.contains(&LIST_MCP_RESOURCES_TOOL_NAME));
@@ -1108,6 +1130,8 @@ mod tests {
assert!(names.contains(&"TeamCreate"));
assert!(names.contains(&"TeamDelete"));
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));
assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME));
assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME));
assert!(!names
+18 -3
View File
@@ -980,6 +980,9 @@ mod tests {
#[test]
fn test_build_tool_inventory_workbench_with_browser_surface_keeps_small_default_allowlist() {
let expected_catalog = tool_catalog_entries_for_surface(
WorkspaceToolSurface::workbench_with_browser_assist(),
);
let inventory = build_tool_inventory(AgentToolInventoryBuildInput {
surface: WorkspaceToolSurface::workbench_with_browser_assist(),
caller: "assistant".to_string(),
@@ -1001,9 +1004,21 @@ mod tests {
.map(ToString::to_string)
.collect::<Vec<_>>();
assert_eq!(inventory.counts.catalog_total, 56);
assert_eq!(inventory.counts.catalog_current_total, 55);
assert_eq!(inventory.counts.catalog_compat_total, 1);
assert_eq!(inventory.counts.catalog_total, expected_catalog.len());
assert_eq!(
inventory.counts.catalog_current_total,
expected_catalog
.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Current)
.count()
);
assert_eq!(
inventory.counts.catalog_compat_total,
expected_catalog
.iter()
.filter(|entry| entry.lifecycle == ToolLifecycle::Compat)
.count()
);
assert_eq!(inventory.default_allowed_tools, expected_default_allowed);
assert_eq!(
inventory.counts.default_allowed_total,
+3 -3
View File
@@ -1235,8 +1235,8 @@ pub fn run() {
// Execution run commands
commands::execution_run_cmd::execution_run_list,
commands::execution_run_cmd::execution_run_get,
commands::execution_run_cmd::execution_run_get_theme_workbench_state,
commands::execution_run_cmd::execution_run_list_theme_workbench_history,
commands::execution_run_cmd::execution_run_get_general_workbench_state,
commands::execution_run_cmd::execution_run_list_general_workbench_history,
// Ecommerce Review Reply commands
commands::ecommerce_review_reply_cmd::execute_ecommerce_review_reply,
// Provider Pool commands
@@ -1726,7 +1726,7 @@ pub fn run() {
// Content commands
commands::content_cmd::content_create,
commands::content_cmd::content_get,
commands::content_cmd::content_get_theme_workbench_document_state,
commands::content_cmd::content_get_general_workbench_document_state,
commands::content_cmd::content_list,
commands::content_cmd::content_update,
commands::content_cmd::content_delete,
@@ -0,0 +1,186 @@
use super::*;
const ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_analysis_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["analysis_skill_launch", "analysisSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_analysis_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_analysis_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_analysis_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["analysis_skill_launch", "analysisSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "analysis_request".to_string());
if kind != "analysis_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "analysis".to_string());
let analysis_request = launch
.get("analysis_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(analysis_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(analysis_request, &["prompt"])
.unwrap_or_else(|| "请分析当前对话中最相关的内容".to_string());
let content = extract_object_string(analysis_request, &["content"]);
let focus = extract_object_string(analysis_request, &["focus"]);
let style = extract_object_string(analysis_request, &["style"]);
let output_format = extract_object_string(analysis_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(analysis_request, &["project_id", "projectId"]);
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 args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"analysis_request": serde_json::Value::Object(analysis_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(analysis_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_explicit_content = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自分析技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速判断要分析什么,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天分析。".to_string(),
"- 若用户明确给了正文、文件路径或范围,优先分析这些材料;若未明确给材料,则分析当前对话中与请求最相关的内容。".to_string(),
"- 分析结果必须区分原文事实、你的判断与待确认项,不要把推断写成已确认事实。".to_string(),
format!("- 当前分析请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前分析目标:{prompt}"),
];
if let Some(value) = content.as_deref() {
lines.push(format!("- 当前显式正文:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前分析重点:{value}。"));
}
if let Some(value) = style.as_deref() {
lines.push(format!("- 当前风格偏好:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_explicit_content {
lines
.push("- 当前任务已经显式进入分析技能主链,不要再追问用户“是否开始分析”。".to_string());
} else {
lines.push(
"- 当前没有显式正文时,优先尝试分析当前对话上下文;只有在上下文也不足以完成时,才最多追问 1 个关键问题。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,205 @@
use super::*;
const BROADCAST_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_BROADCAST_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_broadcast_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["broadcast_skill_launch", "broadcastSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_broadcast_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_broadcast_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(BROADCAST_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_broadcast_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["broadcast_skill_launch", "broadcastSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "broadcast_task".to_string());
if kind != "broadcast_task" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "broadcast_generate".to_string());
let broadcast_task = launch
.get("broadcast_task")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(broadcast_task, &["raw_text", "rawText"]);
let prompt = extract_object_string(broadcast_task, &["prompt"]);
let content = extract_object_string(broadcast_task, &["content"]);
let title = extract_object_string(broadcast_task, &["title"]);
let audience = extract_object_string(broadcast_task, &["audience"]);
let tone = extract_object_string(broadcast_task, &["tone"]);
let duration_hint_minutes = broadcast_task
.get("duration_hint_minutes")
.or_else(|| broadcast_task.get("durationHintMinutes"))
.and_then(serde_json::Value::as_u64);
let session_id = extract_object_string(broadcast_task, &["session_id", "sessionId"]);
let project_id = extract_object_string(broadcast_task, &["project_id", "projectId"]);
let content_id = extract_object_string(broadcast_task, &["content_id", "contentId"]);
let entry_source = extract_object_string(broadcast_task, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_broadcast_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(content.clone())
.unwrap_or_else(|| "请根据当前要求执行播报整理任务".to_string()),
"broadcast_task": serde_json::Value::Object(broadcast_task.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let task_json = truncate_prompt_text(
serde_json::to_string(broadcast_task).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let content_present = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
BROADCAST_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自播报技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- Skill 执行后,优先沿 broadcast_generate skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_broadcast_generation_task。".to_string(),
"- 不要伪造“播报已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(),
format!("- 当前播报任务上下文(JSON):{task_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前播报目标:{value}"));
}
if let Some(value) = title.as_deref() {
lines.push(format!("- 当前标题:{value}。"));
}
if let Some(value) = audience.as_deref() {
lines.push(format!("- 当前目标听众:{value}。"));
}
if let Some(value) = tone.as_deref() {
lines.push(format!("- 当前语气风格:{value}。"));
}
if let Some(value) = duration_hint_minutes {
lines.push(format!("- 当前建议时长:{value} 分钟。"));
}
if let Some(value) = content.as_deref() {
lines.push(format!(
"- 当前待整理正文摘要:{}",
truncate_prompt_text(value.to_string(), 400)
));
}
if let Some(value) = session_id.as_deref() {
lines.push(format!("- 当前 session_id:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if content_present {
lines.push(
"- 当前任务已经显式进入播报技能主链,不要再要求用户额外确认“是否开始整理播报文本”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确待整理原文。你最多只能追问 1 个关键问题,请用户补充正文;在正文补齐前不要创建任务,也不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -249,6 +249,6 @@ pub(crate) fn should_enable_model_skill_tool(request_metadata: Option<&serde_jso
matches!(
extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(),
Some("theme_workbench")
Some("general_workbench") | Some("theme_workbench")
)
}
@@ -0,0 +1,203 @@
use super::*;
const DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_deep_search_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["deep_search_skill_launch", "deepSearchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_deep_search_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_deep_search_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_deep_search_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["deep_search_skill_launch", "deepSearchSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "deep_search_request".to_string());
if kind != "deep_search_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "research".to_string());
let deep_search_request = launch
.get("deep_search_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(deep_search_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(deep_search_request, &["prompt"]);
let query = extract_object_string(deep_search_request, &["query"]);
let site = extract_object_string(deep_search_request, &["site"]);
let time_range = extract_object_string(deep_search_request, &["time_range", "timeRange"]);
let depth = extract_object_string(deep_search_request, &["depth"]);
let focus = extract_object_string(deep_search_request, &["focus"]);
let output_format =
extract_object_string(deep_search_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(deep_search_request, &["project_id", "projectId"]);
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 args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行深度搜索任务".to_string()),
"deep_search_request": serde_json::Value::Object(deep_search_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(deep_search_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自深搜技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的深搜目标,然后立刻把任务交给 Skill 工具;不要先直接给出记忆性结论。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通聊天、普通 @搜索 或一次浅搜。".to_string(),
"- research skill 内部必须真正执行联网检索,不要只凭已有记忆直接回答。".to_string(),
"- 深搜至少执行 2 轮以上扩搜,主动使用不同关键词组合、来源或时间切片;不能只搜一次就直接收尾。".to_string(),
"- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在最终回答中标注时间口径。".to_string(),
"- 最终输出必须显式区分“已确认事实”“基于来源的推断”“待确认项”,若来源之间存在冲突,也要明确标出来。".to_string(),
"- Skill 执行后,再基于检索结果整理结论、来源聚类与后续建议;在真实检索完成前,不要伪造“已经深搜完毕”的细节。".to_string(),
format!("- 当前深搜请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前深搜目标:{value}"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前核心查询:{value}。"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点/来源:{value}。"));
}
if let Some(value) = time_range.as_deref() {
lines.push(format!("- 当前时间范围:{value}。"));
}
if let Some(value) = depth.as_deref() {
lines.push(format!("- 当前调研深度:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_query {
lines
.push("- 当前任务已经显式进入深搜技能主链,不要再追问用户“是否开始深搜”。".to_string());
} else {
lines.push(
"- 当前还缺少明确深搜主题。你最多只能追问 1 个关键问题,请用户补充最关键的检索对象;在主题补齐前不要伪造检索结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -98,7 +98,7 @@ pub struct AsterChatRequest {
/// 前端传入的 System Prompt(可选,优先级低于项目上下文)
#[serde(default, alias = "systemPrompt")]
pub system_prompt: Option<String>,
/// 请求级元数据(可选,用于 harness / 主题工作台状态对齐)
/// 请求级元数据(可选,用于 harness / 工作区编排状态对齐)
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// 回合 ID(可选,由前端提供时透传到 Aster runtime)
+52 -1
View File
@@ -258,22 +258,33 @@ fn normalize_optional_text(value: Option<String>) -> Option<String> {
}
pub(crate) mod action_runtime;
mod analysis_skill_launch;
mod broadcast_skill_launch;
mod browser_assist;
pub(crate) mod command_api;
mod cover_skill_launch;
mod deep_search_skill_launch;
mod dto;
mod image_skill_launch;
mod mcp_bridge;
mod pdf_read_skill_launch;
mod prompt_context;
mod reply_runtime;
mod report_skill_launch;
mod request_model_resolution;
mod research_skill_launch;
mod resource_search_skill_launch;
mod run_metadata;
mod runtime_turn;
mod service_skill_launch;
mod session_runtime;
mod site_search_skill_launch;
mod subagent_runtime;
mod summary_skill_launch;
pub(crate) mod tool_runtime;
mod transcription_skill_launch;
mod translation_skill_launch;
mod typesetting_skill_launch;
mod url_parse_skill_launch;
mod video_skill_launch;
#[cfg(test)]
@@ -294,6 +305,13 @@ pub(crate) use action_runtime::{
build_action_resume_runtime_status, build_runtime_action_user_data,
validate_elicitation_submission,
};
pub(crate) use analysis_skill_launch::{
merge_system_prompt_with_analysis_skill_launch, prepare_analysis_skill_launch_request_metadata,
};
pub(crate) use broadcast_skill_launch::{
merge_system_prompt_with_broadcast_skill_launch,
prepare_broadcast_skill_launch_request_metadata,
};
pub(crate) use browser_assist::{
append_browser_assist_session_permissions, apply_browser_requirement_to_request_tool_policy,
default_web_search_enabled_for_chat_mode, extract_browser_task_requirement,
@@ -320,6 +338,10 @@ pub(crate) use command_api::{
aster_agent_configure_provider, aster_agent_init, aster_agent_reset, aster_agent_status,
};
pub(crate) use cover_skill_launch::merge_system_prompt_with_cover_skill_launch;
pub(crate) use deep_search_skill_launch::{
merge_system_prompt_with_deep_search_skill_launch,
prepare_deep_search_skill_launch_request_metadata,
};
#[allow(unused_imports)]
pub(crate) use dto::{
build_incidents, build_last_outcome, build_pending_requests, AgentRuntimeActionType,
@@ -343,6 +365,9 @@ pub(crate) use image_skill_launch::{
merge_system_prompt_with_image_skill_launch, prepare_image_skill_launch_request_metadata,
};
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
pub(crate) use pdf_read_skill_launch::{
merge_system_prompt_with_pdf_read_skill_launch, prepare_pdf_read_skill_launch_request_metadata,
};
#[cfg(test)]
pub(crate) use prompt_context::build_team_preference_system_prompt;
pub(crate) use prompt_context::{
@@ -358,7 +383,17 @@ use reply_runtime::{
emit_runtime_status_with_projection, ensure_code_execution_extension_enabled,
should_fallback_to_react_from_code_orchestrated, stream_reply_once,
};
pub(crate) use report_skill_launch::{
merge_system_prompt_with_report_skill_launch, prepare_report_skill_launch_request_metadata,
};
use request_model_resolution::resolve_runtime_request_provider_config;
pub(crate) use research_skill_launch::{
merge_system_prompt_with_research_skill_launch, prepare_research_skill_launch_request_metadata,
};
pub(crate) use resource_search_skill_launch::{
merge_system_prompt_with_resource_search_skill_launch,
prepare_resource_search_skill_launch_request_metadata,
};
use run_metadata::{
build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array,
extract_harness_bool, extract_harness_nested_object, extract_harness_string,
@@ -378,7 +413,8 @@ pub(crate) use runtime_turn::{
pub(crate) use service_skill_launch::build_service_skill_launch_run_request;
pub(crate) use service_skill_launch::{
append_service_skill_launch_session_permissions, preload_service_skill_launch_execution,
should_lock_service_skill_launch_to_site_tools, ServiceSkillLaunchPreloadExecution,
prepare_service_scene_launch_request_metadata, should_lock_service_skill_launch_to_site_tools,
ServiceSkillLaunchPreloadExecution,
};
pub(crate) use session_runtime::{
delete_runtime_session_internal, persist_session_provider_routing,
@@ -387,6 +423,10 @@ pub(crate) use session_runtime::{
resolve_session_recent_runtime_context, SessionRecentHarnessContext,
SessionRecentRuntimeContext,
};
pub(crate) use site_search_skill_launch::{
merge_system_prompt_with_site_search_skill_launch,
prepare_site_search_skill_launch_request_metadata,
};
#[allow(unused_imports)]
pub(crate) use subagent_runtime::{
agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal,
@@ -394,6 +434,9 @@ pub(crate) use subagent_runtime::{
agent_runtime_wait_subagents_internal, emit_subagent_status_changed_events,
maybe_emit_subagent_status_for_runtime_event, SubagentControlRuntime,
};
pub(crate) use summary_skill_launch::{
merge_system_prompt_with_summary_skill_launch, prepare_summary_skill_launch_request_metadata,
};
#[allow(unused_imports)]
pub(crate) use tool_runtime::social_generate_cover_image_cmd;
pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput};
@@ -410,6 +453,14 @@ pub(crate) use tool_runtime::{
ensure_runtime_support_tools_registered, ensure_social_image_tool_registered,
};
pub(crate) use transcription_skill_launch::merge_system_prompt_with_transcription_skill_launch;
pub(crate) use translation_skill_launch::{
merge_system_prompt_with_translation_skill_launch,
prepare_translation_skill_launch_request_metadata,
};
pub(crate) use typesetting_skill_launch::{
merge_system_prompt_with_typesetting_skill_launch,
prepare_typesetting_skill_launch_request_metadata,
};
pub(crate) use url_parse_skill_launch::merge_system_prompt_with_url_parse_skill_launch;
pub(crate) use video_skill_launch::merge_system_prompt_with_video_skill_launch;
@@ -0,0 +1,196 @@
use super::*;
const PDF_READ_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_pdf_read_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["pdf_read_skill_launch", "pdfReadSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_pdf_read_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_pdf_read_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(PDF_READ_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_pdf_read_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["pdf_read_skill_launch", "pdfReadSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "pdf_read_request".to_string());
if kind != "pdf_read_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "pdf_read".to_string());
let pdf_read_request = launch
.get("pdf_read_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(pdf_read_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(pdf_read_request, &["prompt"])
.unwrap_or_else(|| "请阅读这份 PDF 并提炼关键信息".to_string());
let source_path = extract_object_string(pdf_read_request, &["source_path", "sourcePath"]);
let source_url = extract_object_string(pdf_read_request, &["source_url", "sourceUrl"]);
let focus = extract_object_string(pdf_read_request, &["focus"]);
let output_format = extract_object_string(pdf_read_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(pdf_read_request, &["project_id", "projectId"]);
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 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()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(pdf_read_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_source_path = source_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let has_source_url = source_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
PDF_READ_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自读 PDF 技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先确认 PDF 来源,再立刻把任务交给 Skill 工具;不要在未实际读取文件前直接总结 PDF 内容。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通聊天凭空回答。".to_string(),
"- 若拿到本地或工作区 PDF 路径,优先最小化使用 `list_directory / read_file` 读取目标 PDF,并保留真实 tool timeline。".to_string(),
"- 若路径是相对路径,可先用 `list_directory` 确认位置,再调用 `read_file`;不要假装文件已经读取成功。".to_string(),
"- 结果必须按“文档信息 / 核心要点 / 关键证据 / 待确认项”组织,且所有结论都要能回溯到实际读到的 PDF 内容。".to_string(),
format!("- 当前读 PDF 请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前解读目标:{prompt}"),
];
if let Some(value) = source_path.as_deref() {
lines.push(format!("- 当前 PDF 本地路径:{value}。"));
}
if let Some(value) = source_url.as_deref() {
lines.push(format!("- 当前 PDF 链接:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_source_path {
lines.push(
"- 当前任务已经显式提供 PDF 路径,不要再追问用户“是否开始读取 PDF”。".to_string(),
);
} else if has_source_url {
lines.push(
"- 当前只有 PDF URL。现有链路不保证能稳定直接读取远程 PDF;你最多只能追问 1 个关键问题,请用户提供本地路径或先把 PDF 导入工作区。".to_string(),
);
} else {
lines.push(
"- 当前缺少明确 PDF 来源。你最多只能追问 1 个关键问题,请用户补充本地路径或工作区内 PDF 文件位置;在来源补齐前不要伪造已读结果。".to_string(),
);
}
Some(lines.join("\n"))
}
@@ -253,6 +253,10 @@ fn build_service_skill_launch_run_example(
fn build_service_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
if let Some(prompt) = build_service_scene_launch_system_prompt(request_metadata) {
return Some(prompt);
}
let launch = extract_harness_nested_object(
request_metadata,
&["service_skill_launch", "serviceSkillLaunch"],
@@ -361,6 +365,94 @@ fn build_service_skill_launch_system_prompt(
Some(lines.join("\n"))
}
fn build_service_scene_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let context =
super::service_skill_launch::extract_service_scene_launch_context(request_metadata)?;
let tool_input = if let Some(user_input) = context
.user_input
.clone()
.filter(|value| !value.trim().is_empty())
{
serde_json::json!({ "input": user_input }).to_string()
} else {
"{}".to_string()
};
let mut lines = vec![
SERVICE_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自服务型场景启动,不要把它当成普通聊天或纯文本分析。".to_string(),
"- 先快速确认当前 slash 场景目标,然后立刻调用服务型技能运行工具;不要停留在泛泛解释。".to_string(),
"- 第一优先工具调用必须是 lime_run_service_skill。".to_string(),
"- lime_run_service_skill 会自动读取当前回合绑定的 serviceSkillId 与 OEM 运行时上下文,通常不需要手动补鉴权字段。".to_string(),
format!("- 推荐第一工具调用参数 JSON:{tool_input}。"),
"- 如果用户没有追加补充要求,直接传 {} 也可以;不要把 scene metadata 里的 session_token、tenant_id、scene_base_url 重新抄进工具参数。".to_string(),
"- 调用 lime_run_service_skill 后,若返回 queued/running,可以继续等待一次或基于当前状态向用户汇报“已提交云端,正在处理中”;不要伪造已完成结果。".to_string(),
"- 如果工具返回缺少 OEM 配置、缺少 Session Token 或授权失败,不要伪造成功结果;直接说明当前云端会话不可用,并引导用户先完成登录或注入会话。".to_string(),
format!("- 当前服务型技能 ID:{}。", context.service_skill_id),
format!(
"- 当前服务型技能标题:{}。",
context
.skill_title
.clone()
.unwrap_or_else(|| "未提供".to_string())
),
format!(
"- 当前 scene_key:{}。",
context
.scene_key
.clone()
.unwrap_or_else(|| "未提供".to_string())
),
format!(
"- 当前 command_prefix:{}。",
context
.command_prefix
.clone()
.unwrap_or_else(|| "未提供".to_string())
),
format!(
"- 当前入口来源:{}。",
context
.entry_source
.clone()
.unwrap_or_else(|| "slash_scene_command".to_string())
),
];
if let Some(value) = context.user_input.as_deref() {
lines.push(format!("- 当前补充要求:{value}"));
} else if let Some(value) = context.raw_text.as_deref() {
lines.push(format!("- 当前原始指令:{value}"));
}
if let Some(value) = context.project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = context.content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if let Some(value) = context.skill_summary.as_deref() {
lines.push(format!("- 当前技能说明:{value}"));
}
if let Some(value) = context.oem_runtime.scene_base_url.as_deref() {
lines.push(format!("- 当前 scene_base_url:{value}。"));
} else {
lines.push(
"- 当前缺少 scene_base_url。若工具返回缺少 OEM 配置,直接向用户说明未完成云端接线。"
.to_string(),
);
}
if context.oem_runtime.session_token.is_some() {
lines
.push("- 当前回合已绑定 OEM Session Token,可直接调用服务型技能运行工具。".to_string());
} else {
lines.push("- 当前回合尚未绑定 OEM Session Token。若工具返回授权失败,不要重试伪造结果,直接要求用户先登录 OEM 云端。".to_string());
}
Some(lines.join("\n"))
}
pub(crate) fn merge_system_prompt_with_service_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
@@ -0,0 +1,201 @@
use super::*;
const REPORT_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_REPORT_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_report_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["report_skill_launch", "reportSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_report_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_report_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(REPORT_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_report_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["report_skill_launch", "reportSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "report_request".to_string());
if kind != "report_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "report_generate".to_string());
let report_request = launch
.get("report_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(report_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(report_request, &["prompt"]);
let query = extract_object_string(report_request, &["query"]);
let site = extract_object_string(report_request, &["site"]);
let time_range = extract_object_string(report_request, &["time_range", "timeRange"]);
let depth = extract_object_string(report_request, &["depth"]);
let focus = extract_object_string(report_request, &["focus"]);
let output_format = extract_object_string(report_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(report_request, &["project_id", "projectId"]);
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 args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行研报任务".to_string()),
"report_request": serde_json::Value::Object(report_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(report_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
REPORT_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自研报技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的研报目标,然后立刻把任务交给 Skill 工具;不要先写空泛长文。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要退回普通聊天写长文。".to_string(),
"- report_generate skill 内部必须先执行真实联网检索,再产出研究报告。".to_string(),
"- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在结果中标注时间口径。".to_string(),
"- 最终输出必须清楚区分核心结论、关键证据、风险/待确认项与建议动作,不要把推断写成已确认事实。".to_string(),
format!("- 当前研报请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前研报目标:{value}"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前核心主题:{value}。"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点/来源:{value}。"));
}
if let Some(value) = time_range.as_deref() {
lines.push(format!("- 当前时间范围:{value}。"));
}
if let Some(value) = depth.as_deref() {
lines.push(format!("- 当前调研深度:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_query {
lines.push(
"- 当前任务已经显式进入研报技能主链,不要再追问用户“是否开始做研报”。".to_string(),
);
} else {
lines.push(
"- 当前还缺少明确研报主题。你最多只能追问 1 个关键问题,请用户补充最关键的研究对象;在主题补齐前不要伪造检索或研报结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -13,6 +13,7 @@ struct ProviderResolutionContext {
registry_provider_ids: Vec<String>,
alias_key: String,
custom_models: Vec<String>,
is_custom_provider: bool,
}
fn normalize_identifier(value: &str) -> String {
@@ -224,6 +225,8 @@ fn build_provider_resolution_context(
provider_selector: &str,
) -> Result<ProviderResolutionContext, String> {
let provider_selector = normalize_identifier(provider_selector);
let is_custom_provider =
lime_core::models::provider_type::is_custom_provider_id(&provider_selector);
let mut compatibility_provider_key = provider_selector.clone();
let mut registry_provider_ids = vec![
provider_selector.clone(),
@@ -231,7 +234,7 @@ fn build_provider_resolution_context(
];
let mut custom_models = Vec::new();
if lime_core::models::provider_type::is_custom_provider_id(&provider_selector) {
if is_custom_provider {
if let Some(provider_with_keys) = api_key_provider_service
.0
.get_provider(db, &provider_selector)?
@@ -251,6 +254,7 @@ fn build_provider_resolution_context(
alias_key: provider_alias_config_key(&provider_selector),
compatibility_provider_key,
custom_models,
is_custom_provider,
provider_selector,
registry_provider_ids,
})
@@ -422,6 +426,129 @@ fn resolve_base_model_on_thinking_off(
.unwrap_or_else(|| current_model_id.to_string())
}
fn normalize_model_lineage_key(model_id: &str) -> String {
let normalized = normalize_identifier(model_id);
let primary = normalized
.split('/')
.find(|part| !part.is_empty())
.unwrap_or(normalized.as_str());
let mut lineage = String::new();
for ch in primary.chars() {
if ch.is_ascii_alphabetic() {
lineage.push(ch);
continue;
}
if !lineage.is_empty() {
break;
}
}
if !lineage.is_empty() {
return lineage;
}
primary
.split(|ch| ['.', '_', '-'].contains(&ch))
.find(|part| !part.is_empty())
.unwrap_or(primary)
.to_string()
}
fn is_likely_non_chat_model(model: &EnhancedModelMetadata) -> bool {
let text = [
normalize_identifier(&model.id),
normalize_identifier(&model.display_name),
model
.family
.as_deref()
.map(normalize_identifier)
.unwrap_or_default(),
model
.description
.as_deref()
.map(normalize_identifier)
.unwrap_or_default(),
]
.join(" ");
is_likely_image_generation_model(model)
|| text_contains_any(
&text,
&[
"embedding",
"embed",
"rerank",
"tts",
"stt",
"transcribe",
"transcription",
"speech",
"audio",
"moderation",
],
)
}
fn resolve_catalog_fallback_model_id(
current_model_id: &str,
models: &[EnhancedModelMetadata],
prefer_reasoning: bool,
prefer_vision: bool,
) -> String {
if let Some(current_model) = find_model_meta(current_model_id, models) {
return current_model.id.clone();
}
let current_base_key = normalize_base_model_key(current_model_id);
let current_lineage_key = normalize_model_lineage_key(current_model_id);
let mut candidates = models
.iter()
.filter(|candidate| !is_likely_non_chat_model(candidate))
.collect::<Vec<_>>();
candidates.sort_by(|left, right| {
let left_same_base = normalize_base_model_key(&left.id) == current_base_key;
let right_same_base = normalize_base_model_key(&right.id) == current_base_key;
let left_same_lineage = !current_lineage_key.is_empty()
&& normalize_model_lineage_key(&left.id) == current_lineage_key;
let right_same_lineage = !current_lineage_key.is_empty()
&& normalize_model_lineage_key(&right.id) == current_lineage_key;
let left_reasoning_match =
model_has_reasoning_capability(Some(left), &left.id) == prefer_reasoning;
let right_reasoning_match =
model_has_reasoning_capability(Some(right), &right.id) == prefer_reasoning;
let left_vision_match = left.capabilities.vision == prefer_vision;
let right_vision_match = right.capabilities.vision == prefer_vision;
left_same_base
.cmp(&right_same_base)
.reverse()
.then(left_same_lineage.cmp(&right_same_lineage).reverse())
.then(left_reasoning_match.cmp(&right_reasoning_match).reverse())
.then(left_vision_match.cmp(&right_vision_match).reverse())
.then(
capability_score(left)
.cmp(&capability_score(right))
.reverse(),
)
.then(left.is_latest.cmp(&right.is_latest).reverse())
.then(
tier_weight(&left.tier)
.cmp(&tier_weight(&right.tier))
.reverse(),
)
.then(compare_release_date_desc(left, right).cmp(&0))
.then(left.id.cmp(&right.id))
});
candidates
.into_iter()
.next()
.map(|candidate| candidate.id.clone())
.unwrap_or_else(|| current_model_id.to_string())
}
fn is_likely_image_generation_model(model: &EnhancedModelMetadata) -> bool {
let text = [
normalize_identifier(&model.id),
@@ -738,6 +865,28 @@ pub(super) async fn resolve_runtime_request_provider_config(
} else {
resolve_base_model_on_thinking_off(&model_preference, &catalog)
};
let should_fallback_unknown_session_model =
matches!(model_preference_source, RequestPreferenceSource::Session)
&& !context.is_custom_provider
&& find_model_meta(&resolved_model, &catalog).is_none();
if should_fallback_unknown_session_model {
let fallback_model = resolve_catalog_fallback_model_id(
&resolved_model,
&catalog,
thinking_enabled,
has_images,
);
if fallback_model != resolved_model {
tracing::info!(
"[AsterAgent] 会话持久化模型已失效,自动回落到当前可用模型: session={}, provider={}, stale_model={}, fallback_model={}",
request.session_id,
context.provider_selector,
resolved_model,
fallback_model
);
resolved_model = fallback_model;
}
}
resolved_model =
resolve_provider_model_compatibility(&context.compatibility_provider_key, &resolved_model);
if has_images {
@@ -926,6 +1075,53 @@ mod tests {
);
}
#[test]
fn catalog_fallback_prefers_latest_same_lineage_chat_model_for_unknown_session_model() {
let models = vec![
build_model(
"embedding-3",
Some("embedding"),
false,
false,
true,
ModelTier::Pro,
Some("2026-01-05"),
),
build_model(
"glm-4v",
Some("glm"),
false,
true,
true,
ModelTier::Pro,
Some("2026-01-04"),
),
build_model(
"glm-4.6",
Some("glm"),
false,
false,
false,
ModelTier::Pro,
Some("2026-01-03"),
),
build_model(
"glm-4.7",
Some("glm"),
false,
false,
true,
ModelTier::Pro,
Some("2026-01-04"),
),
];
assert_eq!(
resolve_catalog_fallback_model_id("glm-5.1", &models, false, false),
"glm-4.7"
);
}
#[test]
fn model_preference_falls_back_to_session_model_when_provider_matches() {
let resolved = resolve_model_preference_with_session_fallback(
@@ -0,0 +1,200 @@
use super::*;
const RESEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_research_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["research_skill_launch", "researchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_research_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_research_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(RESEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_research_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["research_skill_launch", "researchSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "research_request".to_string());
if kind != "research_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "research".to_string());
let research_request = launch
.get("research_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(research_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(research_request, &["prompt"]);
let query = extract_object_string(research_request, &["query"]);
let site = extract_object_string(research_request, &["site"]);
let time_range = extract_object_string(research_request, &["time_range", "timeRange"]);
let depth = extract_object_string(research_request, &["depth"]);
let focus = extract_object_string(research_request, &["focus"]);
let output_format = extract_object_string(research_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(research_request, &["project_id", "projectId"]);
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 args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行联网搜索任务".to_string()),
"research_request": serde_json::Value::Object(research_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(research_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
RESEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自搜索技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的搜索目标,然后立刻把任务交给 Skill 工具;不要先直接给出记忆性结论。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要回退成普通聊天搜索。".to_string(),
"- research skill 内部必须真正执行联网检索,不要只凭已有记忆直接回答。".to_string(),
"- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在最终回答中标注时间口径。".to_string(),
"- Skill 执行后,再基于检索结果整理结论、来源与建议;在真实检索完成前,不要伪造“已经搜索完毕”的细节。".to_string(),
format!("- 当前搜索请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前搜索目标:{value}"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前核心查询:{value}。"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点/来源:{value}。"));
}
if let Some(value) = time_range.as_deref() {
lines.push(format!("- 当前时间范围:{value}。"));
}
if let Some(value) = depth.as_deref() {
lines.push(format!("- 当前调研深度:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_query {
lines
.push("- 当前任务已经显式进入搜索技能主链,不要再追问用户“是否开始搜索”。".to_string());
} else {
lines.push(
"- 当前还缺少明确搜索主题。你最多只能追问 1 个关键问题,请用户补充最关键的检索对象;在主题补齐前不要伪造检索结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,241 @@
use super::*;
const RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str =
"<<LIME_RESOURCE_SEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_resource_search_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["resource_search_skill_launch", "resourceSearchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_resource_search_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_resource_search_skill_launch_system_prompt(request_metadata)
else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_resource_search_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["resource_search_skill_launch", "resourceSearchSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "resource_search_task".to_string());
if kind != "resource_search_task" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "modal_resource_search".to_string());
let resource_search_task = launch
.get("resource_search_task")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(resource_search_task, &["raw_text", "rawText"]);
let prompt = extract_object_string(resource_search_task, &["prompt"]);
let title = extract_object_string(resource_search_task, &["title"]);
let resource_type =
extract_object_string(resource_search_task, &["resource_type", "resourceType"]);
let query = extract_object_string(resource_search_task, &["query"]);
let usage = extract_object_string(resource_search_task, &["usage"]);
let session_id = extract_object_string(resource_search_task, &["session_id", "sessionId"]);
let project_id = extract_object_string(resource_search_task, &["project_id", "projectId"]);
let content_id = extract_object_string(resource_search_task, &["content_id", "contentId"]);
let count = resource_search_task
.get("count")
.and_then(serde_json::Value::as_u64);
let filters = resource_search_task
.get("filters")
.and_then(serde_json::Value::as_object)
.cloned();
let entry_source =
extract_object_string(resource_search_task, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_resource_search_command".to_string());
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行素材检索任务".to_string()),
"resource_search_task": serde_json::Value::Object(resource_search_task.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let task_json = truncate_prompt_text(
serde_json::to_string(resource_search_task).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_resource_type = resource_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自素材检索技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户的素材目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
format!("- 当前素材检索任务上下文(JSON):{task_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前检索目标:{value}"));
}
if let Some(value) = title.as_deref() {
lines.push(format!("- 当前任务标题:{value}。"));
}
if let Some(value) = resource_type.as_deref() {
lines.push(format!("- 当前资源类型:{value}。"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前检索关键词:{value}。"));
}
if let Some(value) = usage.as_deref() {
lines.push(format!("- 当前使用场景:{value}。"));
}
if let Some(value) = count {
lines.push(format!("- 当前候选数量:{value}。"));
}
if let Some(value) = session_id.as_deref() {
lines.push(format!("- 当前 session_id:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if let Some(value) = filters.as_ref() {
lines.push(format!(
"- 当前过滤条件(JSON):{}",
truncate_prompt_text(
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
1_000,
)
));
}
if matches!(resource_type.as_deref(), Some("image")) && has_query {
lines.push(
"- 当前是图片素材检索。Skill 内必须优先调用 lime_search_web_images 直接搜图,不要先走 ToolSearch / WebSearch / Grep 等长链工具搜索。"
.to_string(),
);
lines.push(
"- 若 lime_search_web_images 返回候选,直接汇总候选摘要与来源,不要伪造“任务已创建”。只有 Pexels API Key 未配置、无结果,或用户明确要求异步追踪时,才回退 Bash / lime_create_modal_resource_search_task。"
.to_string(),
);
} else {
lines.push(
"- Skill 执行后,优先沿 modal_resource_search skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_modal_resource_search_task。"
.to_string(),
);
lines.push(
"- 不要伪造“素材已检索完成”;在 task file 真正返回候选前,只能汇报任务已提交、排队或执行中。"
.to_string(),
);
}
if has_resource_type && has_query {
lines.push(
"- 当前任务已经显式进入素材检索技能主链,不要再要求用户额外确认“是否开始检索素材”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确资源类型或检索关键词。你最多只能追问 1 个关键问题,请用户补充最关键缺口;在信息补齐前不要创建任务,也不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -463,6 +463,19 @@ async fn execute_aster_chat_request(
request.metadata.as_ref(),
request.images.as_deref(),
);
request.metadata = prepare_broadcast_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata =
prepare_resource_search_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_research_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_report_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_deep_search_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_site_search_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_pdf_read_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_summary_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_translation_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_analysis_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_typesetting_skill_launch_request_metadata(request.metadata.as_ref());
request.metadata = prepare_service_scene_launch_request_metadata(request.metadata.as_ref());
let runtime_config = config_manager.config();
apply_web_search_runtime_env(&runtime_config);
let auto_continue_config = request
@@ -726,9 +739,100 @@ async fn execute_aster_chat_request(
prompt_with_video_skill_launch.clone(),
);
let prompt_with_broadcast_skill_launch = merge_system_prompt_with_broadcast_skill_launch(
prompt_with_video_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::BroadcastSkillLaunch,
prompt_with_broadcast_skill_launch.clone(),
);
let prompt_with_resource_search_skill_launch =
merge_system_prompt_with_resource_search_skill_launch(
prompt_with_broadcast_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ResourceSearchSkillLaunch,
prompt_with_resource_search_skill_launch.clone(),
);
let prompt_with_research_skill_launch = merge_system_prompt_with_research_skill_launch(
prompt_with_resource_search_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ResearchSkillLaunch,
prompt_with_research_skill_launch.clone(),
);
let prompt_with_report_skill_launch = merge_system_prompt_with_report_skill_launch(
prompt_with_research_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ReportSkillLaunch,
prompt_with_report_skill_launch.clone(),
);
let prompt_with_deep_search_skill_launch = merge_system_prompt_with_deep_search_skill_launch(
prompt_with_report_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::DeepSearchSkillLaunch,
prompt_with_deep_search_skill_launch.clone(),
);
let prompt_with_site_search_skill_launch = merge_system_prompt_with_site_search_skill_launch(
prompt_with_deep_search_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::SiteSearchSkillLaunch,
prompt_with_site_search_skill_launch.clone(),
);
let prompt_with_pdf_read_skill_launch = merge_system_prompt_with_pdf_read_skill_launch(
prompt_with_site_search_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::PdfReadSkillLaunch,
prompt_with_pdf_read_skill_launch.clone(),
);
let prompt_with_summary_skill_launch = merge_system_prompt_with_summary_skill_launch(
prompt_with_pdf_read_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::SummarySkillLaunch,
prompt_with_summary_skill_launch.clone(),
);
let prompt_with_translation_skill_launch = merge_system_prompt_with_translation_skill_launch(
prompt_with_summary_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TranslationSkillLaunch,
prompt_with_translation_skill_launch.clone(),
);
let prompt_with_analysis_skill_launch = merge_system_prompt_with_analysis_skill_launch(
prompt_with_translation_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::AnalysisSkillLaunch,
prompt_with_analysis_skill_launch.clone(),
);
let prompt_with_transcription_skill_launch =
merge_system_prompt_with_transcription_skill_launch(
prompt_with_video_skill_launch,
prompt_with_analysis_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
@@ -745,10 +849,19 @@ async fn execute_aster_chat_request(
prompt_with_url_parse_skill_launch.clone(),
);
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
let prompt_with_typesetting_skill_launch = merge_system_prompt_with_typesetting_skill_launch(
prompt_with_url_parse_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::TypesettingSkillLaunch,
prompt_with_typesetting_skill_launch.clone(),
);
let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch(
prompt_with_typesetting_skill_launch,
request.metadata.as_ref(),
);
turn_input_builder.apply_prompt_stage(
TurnPromptAugmentationStageKind::ServiceSkillLaunch,
prompt_with_service_skill_launch.clone(),
@@ -2432,7 +2545,7 @@ mod tests {
metadata: Some(json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-1"
}
})),
@@ -2603,7 +2716,7 @@ mod tests {
metadata: Some(json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench"
"session_mode": "general_workbench"
}
})),
turn_id: None,
@@ -2614,7 +2727,7 @@ mod tests {
normalize_runtime_turn_request_metadata(
&mut request,
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
None,
None,
Some("content-from-session"),
@@ -2631,7 +2744,7 @@ mod tests {
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(
normalized_metadata
@@ -2651,7 +2764,7 @@ mod tests {
fn normalize_runtime_turn_request_metadata_should_backfill_theme_and_session_mode_from_session_runtime(
) {
let mut request = AsterChatRequest {
message: "继续推进当前主题工作台".to_string(),
message: "继续推进当前工作区编排".to_string(),
session_id: "session-artifact-theme-fallback".to_string(),
event_name: "agent_stream".to_string(),
images: None,
@@ -2681,7 +2794,7 @@ mod tests {
normalize_runtime_turn_request_metadata(
&mut request,
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
None,
None,
Some("content-from-session"),
@@ -2698,7 +2811,7 @@ mod tests {
normalized_metadata
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(
normalized_metadata
@@ -2732,7 +2845,7 @@ mod tests {
metadata: Some(json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-social-1"
}
})),
@@ -2744,7 +2857,7 @@ mod tests {
normalize_runtime_turn_request_metadata(
&mut request,
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
Some("write_mode"),
Some("社媒初稿"),
Some("content-social-1"),
@@ -30,6 +30,29 @@ pub(crate) struct ServiceSkillLaunchPreloadExecution {
pub(crate) result: SiteAdapterRunResult,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ServiceSceneLaunchOemRuntimeContext {
pub(crate) scene_base_url: Option<String>,
pub(crate) tenant_id: Option<String>,
pub(crate) session_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ServiceSceneLaunchContext {
pub(crate) raw_text: Option<String>,
pub(crate) user_input: Option<String>,
pub(crate) scene_key: Option<String>,
pub(crate) command_prefix: Option<String>,
pub(crate) service_skill_id: String,
pub(crate) service_skill_key: Option<String>,
pub(crate) skill_title: Option<String>,
pub(crate) skill_summary: Option<String>,
pub(crate) project_id: Option<String>,
pub(crate) content_id: Option<String>,
pub(crate) entry_source: Option<String>,
pub(crate) oem_runtime: ServiceSceneLaunchOemRuntimeContext,
}
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
@@ -48,6 +71,38 @@ fn normalized_optional_object(
value.and_then(serde_json::Value::as_object)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
pub(crate) fn extract_service_skill_launch_site_adapter_context(
request_metadata: Option<&serde_json::Value>,
) -> Option<ServiceSkillLaunchSiteAdapterContext> {
@@ -90,6 +145,71 @@ pub(crate) fn extract_service_skill_launch_site_adapter_context(
})
}
pub(crate) fn extract_service_scene_launch_context(
request_metadata: Option<&serde_json::Value>,
) -> Option<ServiceSceneLaunchContext> {
let launch = extract_harness_nested_object(
request_metadata,
&["service_scene_launch", "serviceSceneLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "cloud_scene".to_string());
if kind != "cloud_scene" {
return None;
}
let service_scene_run = launch
.get("service_scene_run")
.or_else(|| launch.get("serviceSceneRun"))
.and_then(serde_json::Value::as_object)?;
let service_skill_id = extract_object_string(
service_scene_run,
&["skill_id", "skillId", "linked_skill_id", "linkedSkillId"],
)?;
let oem_runtime = service_scene_run
.get("oem_runtime")
.or_else(|| service_scene_run.get("oemRuntime"))
.and_then(serde_json::Value::as_object);
Some(ServiceSceneLaunchContext {
raw_text: extract_object_string(service_scene_run, &["raw_text", "rawText"]),
user_input: extract_object_string(service_scene_run, &["user_input", "userInput"]),
scene_key: extract_object_string(service_scene_run, &["scene_key", "sceneKey"]),
command_prefix: extract_object_string(
service_scene_run,
&["command_prefix", "commandPrefix"],
),
service_skill_id,
service_skill_key: extract_object_string(service_scene_run, &["skill_key", "skillKey"]),
skill_title: extract_object_string(service_scene_run, &["skill_title", "skillTitle"]),
skill_summary: extract_object_string(service_scene_run, &["skill_summary", "skillSummary"]),
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"]),
oem_runtime: ServiceSceneLaunchOemRuntimeContext {
scene_base_url: oem_runtime.and_then(|value| {
extract_object_string(value, &["scene_base_url", "sceneBaseUrl"])
}),
tenant_id: oem_runtime
.and_then(|value| extract_object_string(value, &["tenant_id", "tenantId"])),
session_token: oem_runtime
.and_then(|value| extract_object_string(value, &["session_token", "sessionToken"])),
},
})
}
pub(crate) fn prepare_service_scene_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["service_scene_launch", "serviceSceneLaunch"],
);
Some(metadata)
}
pub(crate) fn should_lock_service_skill_launch_to_site_tools(
request_metadata: Option<&serde_json::Value>,
) -> bool {
@@ -0,0 +1,196 @@
use super::*;
const SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_site_search_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["site_search_skill_launch", "siteSearchSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_site_search_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_site_search_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_site_search_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["site_search_skill_launch", "siteSearchSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "site_search_request".to_string());
if kind != "site_search_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "site_search".to_string());
let site_search_request = launch
.get("site_search_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(site_search_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(site_search_request, &["prompt"]);
let site = extract_object_string(site_search_request, &["site"]);
let query = extract_object_string(site_search_request, &["query"]);
let project_id = extract_object_string(site_search_request, &["project_id", "projectId"]);
let content_id = extract_object_string(site_search_request, &["content_id", "contentId"]);
let limit = site_search_request
.get("limit")
.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 args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(query.clone())
.unwrap_or_else(|| "请根据当前要求执行站点检索任务".to_string()),
"site_search_request": serde_json::Value::Object(site_search_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(site_search_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_site = site
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let has_query = query
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自站点搜索技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户要查哪个站点、查什么,然后立刻把任务交给 Skill 工具。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通 research / WebSearch。".to_string(),
"- site_search skill 内部应优先沿 lime_site_info / lime_site_run / lime_site_search 主链执行,不要先改用 WebSearch、research、webReader 或底层浏览器工具替代。".to_string(),
"- 若用户已明确指定站点,应优先在该站点的 adapter 范围内求解;只有 adapter 名不明确时,才允许先用 lime_site_search 缩小范围。".to_string(),
"- Skill 执行后,再基于真实站点结果整理摘要;在真实检索完成前,不要伪造“已完成站点搜索”的结果。".to_string(),
format!("- 当前站点搜索请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前站点搜索目标:{value}"));
}
if let Some(value) = site.as_deref() {
lines.push(format!("- 当前目标站点:{value}。"));
}
if let Some(value) = query.as_deref() {
lines.push(format!("- 当前检索关键词:{value}。"));
}
if let Some(value) = limit {
lines.push(format!("- 当前结果数量上限:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_site && has_query {
lines.push(
"- 当前任务已经显式进入站点搜索技能主链,不要再追问用户“是否开始站点搜索”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确站点或检索关键词。你最多只能追问 1 个关键问题,请用户补最关键的缺口;在信息补齐前不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,195 @@
use super::*;
const SUMMARY_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_summary_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["summary_skill_launch", "summarySkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_summary_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_summary_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(SUMMARY_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_summary_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["summary_skill_launch", "summarySkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "summary_request".to_string());
if kind != "summary_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "summary".to_string());
let summary_request = launch
.get("summary_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(summary_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(summary_request, &["prompt"])
.unwrap_or_else(|| "请总结当前对话中的关键信息".to_string());
let content = extract_object_string(summary_request, &["content"]);
let focus = extract_object_string(summary_request, &["focus"]);
let length = extract_object_string(summary_request, &["length"]);
let style = extract_object_string(summary_request, &["style"]);
let output_format = extract_object_string(summary_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(summary_request, &["project_id", "projectId"]);
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 args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"summary_request": serde_json::Value::Object(summary_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(summary_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_explicit_material = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| raw_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
SUMMARY_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自总结技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速判断要总结什么,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天总结。".to_string(),
"- 若用户明确给了正文、文件路径或范围,优先总结这些材料;若未明确给材料,则总结当前对话中与请求最相关的内容。".to_string(),
"- 结果必须忠于原文,不要补写原文没有的新事实;遇到信息缺失或歧义时,要单独标注待确认项。".to_string(),
format!("- 当前总结请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前总结目标:{prompt}"),
];
if let Some(value) = content.as_deref() {
lines.push(format!("- 当前显式正文:{value}。"));
}
if let Some(value) = focus.as_deref() {
lines.push(format!("- 当前关注重点:{value}。"));
}
if let Some(value) = length.as_deref() {
lines.push(format!("- 当前摘要长度偏好:{value}。"));
}
if let Some(value) = style.as_deref() {
lines.push(format!("- 当前风格偏好:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_explicit_material {
lines
.push("- 当前任务已经显式进入总结技能主链,不要再追问用户“是否开始总结”。".to_string());
} else {
lines.push(
"- 当前没有显式正文时,优先尝试总结当前对话上下文;只有在上下文也不足以完成时,才最多追问 1 个关键问题。"
.to_string(),
);
}
Some(lines.join("\n"))
}
+752 -6
View File
@@ -1,7 +1,6 @@
#[cfg(test)]
mod tests {
use super::*;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use crate::commands::aster_agent_cmd::action_runtime::{
build_runtime_action_scope, build_runtime_action_session_config,
};
@@ -10,6 +9,7 @@ mod tests {
RunSiteAdapterRequest, SiteAdapterDefinition, SiteAdapterRunResult,
};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use lime_agent::request_tool_policy::resolve_request_tool_policy;
use lime_agent::AgentEvent as RuntimeAgentEvent;
use regex::Regex;
@@ -377,11 +377,11 @@ mod tests {
}
#[test]
fn test_should_enable_model_skill_tool_allows_theme_workbench() {
fn test_should_enable_model_skill_tool_allows_general_workbench() {
let metadata = serde_json::json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench"
"session_mode": "general_workbench"
}
});
@@ -393,7 +393,7 @@ mod tests {
let metadata = serde_json::json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"allow_model_skills": false
}
});
@@ -1920,14 +1920,14 @@ mod tests {
fast_mode_enabled: false,
continuation_length: 1,
sensitivity: 55,
source: Some("theme_workbench_document_auto_continue".to_string()),
source: Some("general_workbench_document_auto_continue".to_string()),
};
let merged =
merge_system_prompt_with_auto_continue(Some("你是助手".to_string()), Some(&config))
.expect("should contain merged prompt");
assert!(merged.contains(AUTO_CONTINUE_PROMPT_MARKER));
assert!(merged.contains("续写长度"));
assert!(merged.contains("theme_workbench_document_auto_continue"));
assert!(merged.contains("general_workbench_document_auto_continue"));
}
#[test]
@@ -2072,6 +2072,45 @@ mod tests {
assert!(merged.contains("不要再让用户额外确认"));
}
#[test]
fn test_merge_system_prompt_with_service_scene_launch_appends_runtime_tool_contract() {
let metadata = serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-scene-1",
"skill_title": "趋势赛题日报",
"skill_summary": "拉取热点赛题并整理成日报摘要。",
"scene_key": "daily-trend-brief",
"command_prefix": "/daily-trend-brief",
"user_input": "帮我输出今天的小红书趋势赛题",
"entry_source": "slash_scene_command",
"project_id": "project-1",
"content_id": "content-1",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "session-token-demo"
}
}
}
}
});
let merged = merge_system_prompt_with_service_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains(SERVICE_SKILL_LAUNCH_PROMPT_MARKER));
assert!(merged.contains("第一优先工具调用必须是 lime_run_service_skill"));
assert!(merged.contains("不要把 scene metadata 里的 session_token"));
assert!(merged.contains("当前服务型技能 ID:skill-scene-1"));
assert!(merged.contains("当前 scene_key:daily-trend-brief"));
assert!(merged.contains("当前回合已绑定 OEM Session Token"));
}
#[test]
fn test_merge_system_prompt_with_image_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
@@ -2214,6 +2253,626 @@ mod tests {
assert!(merged.contains("当前任务已经显式进入转写技能主链"));
}
#[test]
fn test_merge_system_prompt_with_broadcast_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"broadcast_skill_launch": {
"skill_name": "broadcast_generate",
"kind": "broadcast_task",
"broadcast_task": {
"prompt": "整理成 5 分钟创始人口播",
"raw_text": "@播报 标题: 创始人周报 听众: 创业者 语气: 口语化 时长: 5分钟 把下面文章整理成播报文本",
"content": "今天我们重点讨论 AI Agent 产品化的三个观察。",
"title": "创始人周报",
"audience": "创业者",
"tone": "口语化",
"duration_hint_minutes": 5
}
}
}
});
let merged = merge_system_prompt_with_broadcast_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_BROADCAST_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"broadcast_generate\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"broadcast_task\":"));
assert!(merged.contains("不要伪造“播报已完成”"));
assert!(merged.contains("当前任务已经显式进入播报技能主链"));
}
#[test]
fn test_merge_system_prompt_with_resource_search_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"resource_search_skill_launch": {
"skill_name": "modal_resource_search",
"kind": "resource_search_task",
"resource_search_task": {
"prompt": "咖啡馆木桌背景 公众号头图",
"raw_text": "@素材 类型:图片 关键词:咖啡馆木桌背景 用途:公众号头图 数量:8",
"resource_type": "image",
"query": "咖啡馆木桌背景",
"usage": "公众号头图",
"count": 8
}
}
}
});
let merged = merge_system_prompt_with_resource_search_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_RESOURCE_SEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"modal_resource_search\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"resource_search_task\":"));
assert!(merged.contains("lime_search_web_images"));
assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep"));
assert!(merged.contains("当前任务已经显式进入素材检索技能主链"));
}
#[test]
fn test_merge_system_prompt_with_research_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"research_skill_launch": {
"skill_name": "research",
"kind": "research_request",
"research_request": {
"prompt": "AI Agent 融资 36Kr 近30天 融资额与产品发布",
"raw_text": "@搜索 关键词:AI Agent 融资 站点:36Kr 时间:近30天 深度:深度 重点:融资额与产品发布 输出:要点",
"query": "AI Agent 融资",
"site": "36Kr",
"time_range": "近30天",
"depth": "deep",
"focus": "融资额与产品发布",
"output_format": "要点"
}
}
}
});
let merged = merge_system_prompt_with_research_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"research\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"research_request\":"));
assert!(merged.contains("research skill 内部必须真正执行联网检索"));
assert!(merged.contains("当前任务已经显式进入搜索技能主链"));
}
#[test]
fn test_merge_system_prompt_with_deep_search_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"deep_search_skill_launch": {
"skill_name": "research",
"kind": "deep_search_request",
"deep_search_request": {
"prompt": "AI Agent 融资 36Kr 近30天 融资额与产品发布",
"raw_text": "@深搜 关键词:AI Agent 融资 站点:36Kr 时间:近30天 重点:融资额与产品发布 输出:对比表",
"query": "AI Agent 融资",
"site": "36Kr",
"time_range": "近30天",
"depth": "deep",
"focus": "融资额与产品发布",
"output_format": "对比表"
}
}
}
});
let merged = merge_system_prompt_with_deep_search_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"research\""));
assert!(merged.contains("\"deep_search_request\":"));
assert!(merged.contains("深搜至少执行 2 轮以上扩搜"));
assert!(merged.contains("已确认事实"));
assert!(merged.contains("当前任务已经显式进入深搜技能主链"));
}
#[test]
fn test_merge_system_prompt_with_report_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"report_skill_launch": {
"skill_name": "report_generate",
"kind": "report_request",
"report_request": {
"prompt": "AI Agent 融资 36Kr 近30天 融资额与代表产品 投资人研报",
"raw_text": "@研报 关键词:AI Agent 融资 站点:36Kr 时间:近30天 重点:融资额与代表产品 输出:投资人研报",
"query": "AI Agent 融资",
"site": "36Kr",
"time_range": "近30天",
"depth": "deep",
"focus": "融资额与代表产品",
"output_format": "投资人研报"
}
}
}
});
let merged = merge_system_prompt_with_report_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_REPORT_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"report_generate\""));
assert!(merged.contains("\"report_request\":"));
assert!(merged.contains("report_generate skill 内部必须先执行真实联网检索"));
assert!(merged.contains("核心结论、关键证据、风险/待确认项与建议动作"));
assert!(merged.contains("当前任务已经显式进入研报技能主链"));
}
#[test]
fn test_merge_system_prompt_with_site_search_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"site_search_skill_launch": {
"skill_name": "site_search",
"kind": "site_search_request",
"site_search_request": {
"prompt": "openai agents sdk issue",
"raw_text": "@站点搜索 站点:GitHub 关键词:openai agents sdk issue 数量:8",
"site": "GitHub",
"query": "openai agents sdk issue",
"limit": 8
}
}
}
});
let merged = merge_system_prompt_with_site_search_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"site_search\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"site_search_request\":"));
assert!(merged.contains("不要先改用 WebSearch、research"));
assert!(merged.contains("当前任务已经显式进入站点搜索技能主链"));
}
#[test]
fn test_merge_system_prompt_with_pdf_read_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"pdf_read_skill_launch": {
"skill_name": "pdf_read",
"kind": "pdf_read_request",
"pdf_read_request": {
"prompt": "提炼三点结论并标注关键证据",
"raw_text": "@读PDF /tmp/agent-report.pdf 提炼三点结论并标注关键证据",
"source_path": "/tmp/agent-report.pdf",
"focus": "融资数据",
"output_format": "投资人摘要"
}
}
}
});
let merged = merge_system_prompt_with_pdf_read_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"pdf_read\""));
assert!(merged.contains("\"pdf_read_request\":"));
assert!(merged.contains("list_directory / read_file"));
assert!(merged.contains("当前任务已经显式提供 PDF 路径"));
}
#[test]
fn test_merge_system_prompt_with_summary_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"summary_skill_launch": {
"skill_name": "summary",
"kind": "summary_request",
"summary_request": {
"prompt": "请总结这篇长文的三点要点",
"raw_text": "@总结 内容:这是一篇关于 AI Agent 融资的长文 重点:融资额与发布时间 长度:简短 风格:投资人简报 输出:三点要点",
"content": "这是一篇关于 AI Agent 融资的长文",
"focus": "融资额与发布时间",
"length": "short",
"style": "投资人简报",
"output_format": "三点要点"
}
}
}
});
let merged = merge_system_prompt_with_summary_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"summary\""));
assert!(merged.contains("\"summary_request\":"));
assert!(merged.contains("结果必须忠于原文"));
assert!(merged.contains("当前任务已经显式进入总结技能主链"));
}
#[test]
fn test_merge_system_prompt_with_translation_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"translation_skill_launch": {
"skill_name": "translation",
"kind": "translation_request",
"translation_request": {
"prompt": "将 hello world 翻译成中文",
"raw_text": "@翻译 内容:hello world 原语言:英语 目标语言:中文 风格:产品文案 输出:只输出译文",
"content": "hello world",
"source_language": "英语",
"target_language": "中文",
"style": "产品文案",
"output_format": "只输出译文"
}
}
}
});
let merged = merge_system_prompt_with_translation_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"translation\""));
assert!(merged.contains("\"translation_request\":"));
assert!(merged.contains("译文必须忠于原文"));
assert!(merged.contains("当前任务已经显式进入翻译技能主链"));
}
#[test]
fn test_merge_system_prompt_with_analysis_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"analysis_skill_launch": {
"skill_name": "analysis",
"kind": "analysis_request",
"analysis_request": {
"prompt": "判断 OpenAI 新模型发布的商业影响",
"raw_text": "@分析 内容:OpenAI 发布新模型 重点:商业影响 风格:投资备忘 输出:三点判断",
"content": "OpenAI 发布新模型",
"focus": "商业影响",
"style": "投资备忘",
"output_format": "三点判断"
}
}
}
});
let merged = merge_system_prompt_with_analysis_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("skill=\"analysis\""));
assert!(merged.contains("\"analysis_request\":"));
assert!(merged.contains("分析结果必须区分原文事实、你的判断与待确认项"));
assert!(merged.contains("当前任务已经显式进入分析技能主链"));
}
#[test]
fn test_prepare_broadcast_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"broadcast_skill_launch": {
"skill_name": "broadcast_generate",
"kind": "broadcast_task",
"broadcast_task": {
"content": "待整理原文"
}
}
}
});
let prepared = prepare_broadcast_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_resource_search_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"resource_search_skill_launch": {
"skill_name": "modal_resource_search",
"kind": "resource_search_task",
"resource_search_task": {
"resource_type": "image",
"query": "咖啡馆木桌背景"
}
}
}
});
let prepared = prepare_resource_search_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_site_search_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"site_search_skill_launch": {
"skill_name": "site_search",
"kind": "site_search_request",
"site_search_request": {
"site": "GitHub",
"query": "openai agents sdk issue"
}
}
}
});
let prepared = prepare_site_search_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_pdf_read_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"pdf_read_skill_launch": {
"skill_name": "pdf_read",
"kind": "pdf_read_request",
"pdf_read_request": {
"source_path": "/tmp/agent-report.pdf"
}
}
}
});
let prepared = prepare_pdf_read_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_research_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"research_skill_launch": {
"skill_name": "research",
"kind": "research_request",
"research_request": {
"query": "AI Agent 融资"
}
}
}
});
let prepared = prepare_research_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_deep_search_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"deep_search_skill_launch": {
"skill_name": "research",
"kind": "deep_search_request",
"deep_search_request": {
"query": "AI Agent 融资"
}
}
}
});
let prepared = prepare_deep_search_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_report_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"report_skill_launch": {
"skill_name": "report_generate",
"kind": "report_request",
"report_request": {
"query": "AI Agent 融资"
}
}
}
});
let prepared = prepare_report_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_summary_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"summary_skill_launch": {
"skill_name": "summary",
"kind": "summary_request",
"summary_request": {
"prompt": "请总结当前对话"
}
}
}
});
let prepared = prepare_summary_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_translation_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"translation_skill_launch": {
"skill_name": "translation",
"kind": "translation_request",
"translation_request": {
"prompt": "请把当前对话翻译成英文"
}
}
}
});
let prepared = prepare_translation_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_analysis_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"analysis_skill_launch": {
"skill_name": "analysis",
"kind": "analysis_request",
"analysis_request": {
"prompt": "请分析当前对话"
}
}
}
});
let prepared = prepare_analysis_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_merge_system_prompt_with_url_parse_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
@@ -2247,6 +2906,66 @@ mod tests {
assert!(merged.contains("当前任务已经显式进入链接解析技能主链"));
}
#[test]
fn test_merge_system_prompt_with_typesetting_skill_launch_appends_prompt() {
let metadata = serde_json::json!({
"harness": {
"allow_model_skills": true,
"typesetting_skill_launch": {
"skill_name": "typesetting",
"kind": "typesetting_task",
"typesetting_task": {
"prompt": "整理成更适合小红书阅读的短句节奏",
"raw_text": "@排版 平台:小红书 帮我把下面文案整理成短句节奏",
"content": "平台:小红书 帮我把下面文案整理成短句节奏",
"target_platform": "小红书"
}
}
}
});
let merged = merge_system_prompt_with_typesetting_skill_launch(
Some("你是助手".to_string()),
Some(&metadata),
)
.expect("should contain merged prompt");
assert!(merged.contains("<<LIME_TYPESETTING_SKILL_LAUNCH_HINT>>"));
assert!(merged.contains("第一优先工具调用必须是 Skill"));
assert!(merged.contains("skill=\"typesetting\""));
assert!(merged.contains("Skill.args 的 JSON"));
assert!(merged.contains("\"typesetting_task\":"));
assert!(merged.contains("不要伪造“排版已完成”"));
assert!(merged.contains("当前任务已经显式进入排版技能主链"));
}
#[test]
fn test_prepare_typesetting_skill_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"typesetting_skill_launch": {
"skill_name": "typesetting",
"kind": "typesetting_task",
"typesetting_task": {
"content": "待排版正文"
}
}
}
});
let prepared = prepare_typesetting_skill_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_prepare_image_skill_launch_request_metadata_materializes_input_refs() {
let temp_dir = TempDir::new().expect("temp dir");
@@ -2352,6 +3071,33 @@ mod tests {
);
}
#[test]
fn test_prepare_service_scene_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-scene-1",
"scene_key": "daily-trend-brief"
}
}
}
});
let prepared = prepare_service_scene_launch_request_metadata(Some(&metadata))
.expect("prepared metadata");
let harness = prepared
.get("harness")
.and_then(serde_json::Value::as_object)
.expect("harness");
assert_eq!(
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
}
#[test]
fn test_merge_system_prompt_with_service_skill_launch_skips_duplicate_marker() {
let metadata = serde_json::json!({
@@ -10,8 +10,12 @@ mod lime_cli_runtime;
mod mcp_resource_tools;
#[path = "tool_runtime/media_cli_bridge.rs"]
pub(crate) mod media_cli_bridge;
#[path = "tool_runtime/resource_search_tools.rs"]
mod resource_search_tools;
#[path = "tool_runtime/search_bridge.rs"]
mod search_bridge;
#[path = "tool_runtime/service_skill_tools.rs"]
mod service_skill_tools;
#[path = "tool_runtime/site_tools.rs"]
mod site_tools;
#[path = "tool_runtime/social_tools.rs"]
@@ -31,6 +35,8 @@ pub(crate) use mcp_resource_tools::{ListMcpResourcesBridgeTool, ReadMcpResourceB
pub(crate) use search_bridge::ensure_tool_search_tool_registered;
#[allow(unused_imports)]
pub(crate) use search_bridge::ToolSearchBridgeTool;
#[allow(unused_imports)]
pub(crate) use service_skill_tools::LimeRunServiceSkillTool;
pub(crate) use social_tools::ensure_social_image_tool_registered;
pub(crate) use social_tools::social_generate_cover_image_cmd;
#[allow(unused_imports)]
@@ -92,6 +98,11 @@ fn sync_workspace_mode_native_tool_surface(
if surface.workbench {
social_tools::register_social_image_tool_to_registry(registry, config_manager);
resource_search_tools::register_resource_search_tools_to_registry(
registry,
app_handle.clone(),
);
service_skill_tools::register_service_skill_tools_to_registry(registry);
creation_tools::register_creation_task_tools_to_registry(
registry,
db,
@@ -101,6 +112,7 @@ fn sync_workspace_mode_native_tool_surface(
} else {
let workbench_tools = workbench_tool_names();
unregister_named_tools(registry, &workbench_tools);
service_skill_tools::unregister_service_skill_tools_from_registry(registry);
}
}
@@ -0,0 +1,226 @@
use super::*;
use crate::agent_tools::catalog::LIME_SEARCH_WEB_IMAGES_TOOL_NAME;
use crate::app::AppState;
use crate::commands::image_search_cmd::{
get_pexels_api_key_from_app_state, search_web_images_with_pexels_api_key, WebImageSearchRequest,
};
use tauri::Manager;
const DEFAULT_WEB_IMAGE_SEARCH_COUNT: u32 = 8;
const MAX_WEB_IMAGE_SEARCH_COUNT: u32 = 20;
const DEFAULT_WEB_IMAGE_SEARCH_PAGE: u32 = 1;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LimeSearchWebImagesToolInput {
query: String,
#[serde(default)]
count: Option<u32>,
#[serde(default)]
aspect: Option<String>,
#[serde(default)]
page: Option<u32>,
}
#[derive(Clone)]
pub(crate) struct LimeSearchWebImagesTool {
app_handle: AppHandle,
}
impl LimeSearchWebImagesTool {
fn new(app_handle: AppHandle) -> Self {
Self { app_handle }
}
fn normalize_optional_text(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn normalize_search_count(count: Option<u32>) -> u32 {
count
.unwrap_or(DEFAULT_WEB_IMAGE_SEARCH_COUNT)
.clamp(1, MAX_WEB_IMAGE_SEARCH_COUNT)
}
fn normalize_search_page(page: Option<u32>) -> u32 {
page.unwrap_or(DEFAULT_WEB_IMAGE_SEARCH_PAGE).max(1)
}
fn normalize_aspect_alias(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"landscape" | "horizontal" | "横版" | "横图" | "宽图" | "16:9" | "4:3" | "3:2" => {
Some("landscape")
}
"portrait" | "vertical" | "竖版" | "竖图" | "长图" | "9:16" | "3:4" | "2:3" => {
Some("portrait")
}
"square" | "方图" | "正方形" | "1:1" => Some("square"),
_ => None,
}
}
fn normalize_aspect(aspect: Option<&str>) -> Result<Option<String>, ToolError> {
let Some(raw) = aspect else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
Self::normalize_aspect_alias(trimmed)
.map(|value| Some(value.to_string()))
.ok_or_else(|| {
ToolError::invalid_params(
"aspect 仅支持 landscape / portrait / square(也兼容 横版 / 竖版 / 方图)"
.to_string(),
)
})
}
}
#[async_trait]
impl Tool for LimeSearchWebImagesTool {
fn name(&self) -> &str {
LIME_SEARCH_WEB_IMAGES_TOOL_NAME
}
fn description(&self) -> &str {
"使用当前已配置的 Pexels API Key 搜索联网图片素材候选。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "图片检索关键词。"
},
"count": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "返回候选数量,默认 8。"
},
"aspect": {
"type": "string",
"enum": ["landscape", "portrait", "square"],
"description": "画幅方向,可选 landscape / portrait / square。"
},
"page": {
"type": "integer",
"minimum": 1,
"description": "分页页码,默认 1。"
}
},
"required": ["query"],
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["image", "search", "resource"],
"allowed_callers": ["assistant", "skill"],
"input_examples": [
{
"query": "cozy coffee shop background",
"count": 8,
"aspect": "landscape"
}
]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: LimeSearchWebImagesToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
let query = Self::normalize_optional_text(&input.query)
.ok_or_else(|| ToolError::invalid_params("query 不能为空字符串".to_string()))?;
let count = Self::normalize_search_count(input.count);
let page = Self::normalize_search_page(input.page);
let aspect = Self::normalize_aspect(input.aspect.as_deref())?;
let app_state = self.app_handle.state::<AppState>();
let api_key = get_pexels_api_key_from_app_state(app_state.inner()).await;
let result = search_web_images_with_pexels_api_key(
api_key,
WebImageSearchRequest {
query: query.clone(),
page,
per_page: count,
aspect: aspect.clone(),
},
)
.await
.map_err(ToolError::execution_failed)?;
let provider = result.provider.clone();
let total = result.total;
let returned_count = result.hits.len();
let hits = result.hits;
let payload = serde_json::json!({
"provider": provider,
"query": query,
"requestedCount": count,
"returnedCount": returned_count,
"page": page,
"aspect": aspect,
"total": total,
"hits": hits,
});
let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
Ok(ToolResult::success(output)
.with_metadata("tool_family", serde_json::json!("search"))
.with_metadata("provider", payload["provider"].clone())
.with_metadata("query", payload["query"].clone())
.with_metadata("result", payload))
}
}
pub(super) fn register_resource_search_tools_to_registry(
registry: &mut aster::tools::ToolRegistry,
app_handle: AppHandle,
) {
if !registry.contains(LIME_SEARCH_WEB_IMAGES_TOOL_NAME) {
registry.register(Box::new(LimeSearchWebImagesTool::new(app_handle)));
}
}
#[cfg(test)]
mod tests {
use super::LimeSearchWebImagesTool;
#[test]
fn test_normalize_aspect_alias_supports_common_inputs() {
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("landscape"),
Some("landscape")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("横版"),
Some("landscape")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("9:16"),
Some("portrait")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("方图"),
Some("square")
);
assert_eq!(
LimeSearchWebImagesTool::normalize_aspect_alias("cinematic"),
None
);
}
}
@@ -0,0 +1,627 @@
use super::*;
use crate::agent_tools::catalog::LIME_RUN_SERVICE_SKILL_TOOL_NAME;
use crate::commands::aster_agent_cmd::service_skill_launch::{
extract_service_scene_launch_context, ServiceSceneLaunchContext,
};
use aster::session::{load_shared_session_runtime_snapshot, SessionRuntimeSnapshot};
const DEFAULT_SERVICE_SKILL_POLL_ATTEMPTS: u32 = 6;
const DEFAULT_SERVICE_SKILL_POLL_INTERVAL_MS: u64 = 1_500;
const MAX_SERVICE_SKILL_POLL_ATTEMPTS: u32 = 20;
const MAX_SERVICE_SKILL_POLL_INTERVAL_MS: u64 = 8_000;
const TERMINAL_SERVICE_SKILL_STATUSES: &[&str] = &["success", "failed", "canceled", "timeout"];
const SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS: &[&str] = &[
"LIME_SERVICE_SCENE_LAUNCH_CONTEXT",
"PROXYCAST_SERVICE_SCENE_LAUNCH_CONTEXT",
];
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct ServiceSkillRunToolInput {
#[serde(default)]
input: Option<String>,
#[serde(default)]
wait_for_completion: Option<bool>,
#[serde(default)]
poll_attempts: Option<u32>,
#[serde(default)]
poll_interval_ms: Option<u64>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(rename_all = "camelCase")]
struct ServiceSkillRunRecord {
id: String,
#[serde(default)]
status: String,
#[serde(default)]
run_type: Option<String>,
#[serde(default)]
scene_id: Option<String>,
#[serde(default)]
service_skill_id: Option<String>,
#[serde(default)]
service_skill_key: Option<String>,
#[serde(default)]
executor_kind: Option<String>,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
output_summary: Option<String>,
#[serde(default)]
output_text: Option<String>,
#[serde(default)]
error_code: Option<String>,
#[serde(default)]
error_message: Option<String>,
#[serde(default)]
fallback_applied: Option<bool>,
#[serde(default)]
fallback_kind: Option<String>,
#[serde(default)]
started_at: Option<String>,
#[serde(default)]
finished_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ServiceSkillRunEnvelope {
#[serde(default)]
code: Option<i64>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
data: Option<ServiceSkillRunRecord>,
}
#[derive(Clone)]
pub(crate) struct LimeRunServiceSkillTool;
impl LimeRunServiceSkillTool {
fn new() -> Self {
Self
}
fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn normalize_status(status: &str) -> String {
status.trim().to_ascii_lowercase()
}
fn is_terminal_status(status: &str) -> bool {
let normalized = Self::normalize_status(status);
TERMINAL_SERVICE_SKILL_STATUSES
.iter()
.any(|candidate| normalized == *candidate)
}
fn build_request_metadata_value(
metadata: &HashMap<String, serde_json::Value>,
) -> Option<serde_json::Value> {
if metadata.is_empty() {
return None;
}
let map = metadata
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<serde_json::Map<String, serde_json::Value>>();
Some(serde_json::Value::Object(map))
}
fn extract_launch_context_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<ServiceSceneLaunchContext> {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let request_metadata = turn
.context_override
.as_ref()
.and_then(|context| Self::build_request_metadata_value(&context.metadata))?;
let launch_context = extract_service_scene_launch_context(Some(&request_metadata))?;
Some((turn.updated_at, launch_context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, launch_context)| launch_context)
.or_else(|| {
snapshot
.threads
.iter()
.filter_map(|thread| {
let request_metadata =
Self::build_request_metadata_value(&thread.thread.metadata)?;
let launch_context =
extract_service_scene_launch_context(Some(&request_metadata))?;
Some((thread.thread.updated_at, launch_context))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, launch_context)| launch_context)
})
}
fn resolve_launch_context_from_environment(
context: &ToolContext,
) -> Option<ServiceSceneLaunchContext> {
SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS
.iter()
.find_map(|key| {
let raw = context.environment.get(*key)?;
let parsed = serde_json::from_str::<serde_json::Value>(raw).ok()?;
extract_service_scene_launch_context(Some(&parsed)).or_else(|| {
let wrapped = serde_json::json!({
"harness": {
"service_scene_launch": parsed,
}
});
extract_service_scene_launch_context(Some(&wrapped))
})
})
}
async fn resolve_launch_context(
context: &ToolContext,
) -> Result<ServiceSceneLaunchContext, ToolError> {
let session_id = context.session_id.trim();
if !session_id.is_empty() {
match load_shared_session_runtime_snapshot(session_id).await {
Ok(snapshot) => {
if let Some(launch_context) =
Self::extract_launch_context_from_runtime_snapshot(&snapshot)
{
return Ok(launch_context);
}
}
Err(error) => {
tracing::debug!(
"[AsterAgent][ServiceSkillTool] 读取 runtime snapshot 失败,跳过 session launch context 解析: session_id={}, error={}",
session_id,
error
);
}
}
}
Self::resolve_launch_context_from_environment(context).ok_or_else(|| {
ToolError::execution_failed(
"当前回合未绑定服务型场景启动上下文,无法执行 lime_run_service_skill".to_string(),
)
})
}
fn resolve_effective_input(
launch_context: &ServiceSceneLaunchContext,
input: &ServiceSkillRunToolInput,
) -> Result<String, ToolError> {
let effective_input = Self::normalize_optional_text(input.input.as_deref())
.or_else(|| Self::normalize_optional_text(launch_context.user_input.as_deref()))
.or_else(|| Self::normalize_optional_text(launch_context.raw_text.as_deref()))
.ok_or_else(|| {
ToolError::invalid_params("缺少服务型技能运行输入,请补充 input".to_string())
})?;
Ok(effective_input)
}
fn resolve_scene_base_url(
launch_context: &ServiceSceneLaunchContext,
) -> Result<String, ToolError> {
Self::normalize_optional_text(launch_context.oem_runtime.scene_base_url.as_deref())
.ok_or_else(|| {
ToolError::execution_failed(
"缺少 OEM sceneBaseUrl,请先完成 OEM 云端接线".to_string(),
)
})
}
fn resolve_session_token(
launch_context: &ServiceSceneLaunchContext,
) -> Result<String, ToolError> {
Self::normalize_optional_text(launch_context.oem_runtime.session_token.as_deref())
.ok_or_else(|| {
ToolError::execution_failed(
"缺少 OEM Session Token,请先登录或注入 OEM 云端会话".to_string(),
)
})
}
async fn request_run(
client: &reqwest::Client,
scene_base_url: &str,
session_token: &str,
path: &str,
method: reqwest::Method,
body: Option<serde_json::Value>,
) -> Result<ServiceSkillRunRecord, ToolError> {
let url = format!("{}{}", scene_base_url.trim_end_matches('/'), path);
let mut request = client
.request(method, &url)
.header(reqwest::header::ACCEPT, "application/json")
.bearer_auth(session_token)
.header(reqwest::header::CONTENT_TYPE, "application/json");
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
ToolError::execution_failed(format!("请求服务型技能运行时失败: {error}"))
})?;
let status = response.status();
let payload = response
.json::<ServiceSkillRunEnvelope>()
.await
.map_err(|error| {
ToolError::execution_failed(format!("解析服务型技能运行结果失败: {error}"))
})?;
if !status.is_success() {
let message = payload
.message
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("服务端返回失败");
return Err(ToolError::execution_failed(format!(
"服务型技能运行请求失败 ({}): {}",
status.as_u16(),
message
)));
}
if let Some(code) = payload.code {
if code >= 400 {
return Err(ToolError::execution_failed(
payload
.message
.unwrap_or_else(|| "服务端返回非法运行结果".to_string()),
));
}
}
payload.data.ok_or_else(|| {
ToolError::execution_failed("服务端返回的 service skill run 记录为空".to_string())
})
}
fn build_success_payload(
launch_context: &ServiceSceneLaunchContext,
run: &ServiceSkillRunRecord,
submitted_input: &str,
) -> serde_json::Value {
serde_json::json!({
"ok": run.status == "success",
"submittedInput": submitted_input,
"serviceSkill": {
"id": launch_context.service_skill_id,
"key": launch_context.service_skill_key,
"title": launch_context.skill_title,
"summary": launch_context.skill_summary,
},
"scene": {
"sceneKey": launch_context.scene_key,
"commandPrefix": launch_context.command_prefix,
},
"run": run,
})
}
fn build_result_summary(
launch_context: &ServiceSceneLaunchContext,
run: &ServiceSkillRunRecord,
) -> String {
let title = launch_context
.skill_title
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("服务型技能");
let status = run.status.trim();
if status == "success" {
if let Some(summary) = Self::normalize_optional_text(run.output_summary.as_deref()) {
return format!("{title} 执行完成:{summary}");
}
return format!("{title} 执行完成");
}
if Self::is_terminal_status(status) {
if let Some(message) = Self::normalize_optional_text(run.error_message.as_deref()) {
return format!("{title} 执行失败:{message}");
}
return format!("{title} 已结束,状态为 {status}");
}
if let Some(summary) = Self::normalize_optional_text(run.output_summary.as_deref()) {
return format!("{title} 当前状态 {status}:{summary}");
}
format!("{title} 已提交云端,当前状态 {status}")
}
}
#[async_trait]
impl Tool for LimeRunServiceSkillTool {
fn name(&self) -> &str {
LIME_RUN_SERVICE_SKILL_TOOL_NAME
}
fn description(&self) -> &str {
"运行当前回合绑定的服务型技能场景,提交到 OEM Scene Runtime 并返回最新运行状态。"
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "可选补充输入。默认取当前 scene launch 里的 user_input 或 raw_text。"
},
"waitForCompletion": {
"type": "boolean",
"description": "是否在当前工具调用内短轮询等待一轮结果,默认 true。"
},
"pollAttempts": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"description": "短轮询次数,默认 6。"
},
"pollIntervalMs": {
"type": "integer",
"minimum": 200,
"maximum": 8000,
"description": "轮询间隔毫秒数,默认 1500。"
}
},
"additionalProperties": false,
"x-lime": {
"always_visible": true,
"tags": ["service-skill", "scene", "cloud-runtime"],
"allowed_callers": ["assistant", "skill"]
}
})
}
async fn execute(
&self,
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let input: ServiceSkillRunToolInput = serde_json::from_value(params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
let launch_context = Self::resolve_launch_context(context).await?;
let effective_input = Self::resolve_effective_input(&launch_context, &input)?;
let scene_base_url = Self::resolve_scene_base_url(&launch_context)?;
let session_token = Self::resolve_session_token(&launch_context)?;
let wait_for_completion = input.wait_for_completion.unwrap_or(true);
let poll_attempts = input
.poll_attempts
.unwrap_or(DEFAULT_SERVICE_SKILL_POLL_ATTEMPTS)
.clamp(1, MAX_SERVICE_SKILL_POLL_ATTEMPTS);
let poll_interval_ms = input
.poll_interval_ms
.unwrap_or(DEFAULT_SERVICE_SKILL_POLL_INTERVAL_MS)
.clamp(200, MAX_SERVICE_SKILL_POLL_INTERVAL_MS);
let client = reqwest::Client::new();
let create_path = format!(
"/v1/service-skills/{}/runs",
urlencoding::encode(&launch_context.service_skill_id)
);
let mut run = Self::request_run(
&client,
&scene_base_url,
&session_token,
&create_path,
reqwest::Method::POST,
Some(serde_json::json!({
"input": effective_input,
})),
)
.await?;
if wait_for_completion && !Self::is_terminal_status(&run.status) {
for _ in 0..poll_attempts {
tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
let run_path = format!(
"/v1/service-skills/runs/{}",
urlencoding::encode(run.id.as_str())
);
run = Self::request_run(
&client,
&scene_base_url,
&session_token,
&run_path,
reqwest::Method::GET,
None,
)
.await?;
if Self::is_terminal_status(&run.status) {
break;
}
}
}
let payload = Self::build_success_payload(&launch_context, &run, &effective_input);
let summary = Self::build_result_summary(&launch_context, &run);
let serialized =
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
let mut result = if Self::normalize_status(&run.status) == "failed"
|| Self::normalize_status(&run.status) == "canceled"
|| Self::normalize_status(&run.status) == "timeout"
{
ToolResult::error(summary)
} else {
ToolResult::success(serialized)
};
result = result
.with_metadata("tool_family", serde_json::json!("service_skill"))
.with_metadata("result", payload)
.with_metadata("run_status", serde_json::json!(run.status))
.with_metadata(
"service_skill_id",
serde_json::json!(launch_context.service_skill_id),
);
if let Some(scene_key) = launch_context.scene_key.as_ref() {
result = result.with_metadata("scene_key", serde_json::json!(scene_key));
}
Ok(result)
}
}
pub(super) fn register_service_skill_tools_to_registry(registry: &mut aster::tools::ToolRegistry) {
if !registry.contains(LIME_RUN_SERVICE_SKILL_TOOL_NAME) {
registry.register(Box::new(LimeRunServiceSkillTool::new()));
}
}
pub(super) fn unregister_service_skill_tools_from_registry(
registry: &mut aster::tools::ToolRegistry,
) {
registry.unregister(LIME_RUN_SERVICE_SKILL_TOOL_NAME);
}
#[cfg(test)]
mod tests {
use super::*;
use aster::session::{ThreadRuntime, ThreadRuntimeSnapshot, TurnContextOverride, TurnRuntime};
use chrono::{Duration as ChronoDuration, Utc};
use std::path::PathBuf;
fn metadata_map(value: serde_json::Value) -> HashMap<String, serde_json::Value> {
value
.as_object()
.expect("metadata should be object")
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
#[test]
fn should_extract_latest_service_scene_launch_context_from_runtime_snapshot() {
let now = Utc::now();
let mut older_turn = TurnRuntime::new(
"turn-older",
"session-1",
"thread-1",
Some("旧 turn".to_string()),
Some(TurnContextOverride {
metadata: metadata_map(serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-older",
"scene_key": "scene-older",
"user_input": "旧输入",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "older-token"
}
}
}
}
})),
..TurnContextOverride::default()
}),
);
older_turn.updated_at = now;
let mut latest_turn = TurnRuntime::new(
"turn-latest",
"session-1",
"thread-1",
Some("新 turn".to_string()),
Some(TurnContextOverride {
metadata: metadata_map(serde_json::json!({
"harness": {
"service_scene_launch": {
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-latest",
"scene_key": "scene-latest",
"user_input": "最新输入",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "latest-token"
}
}
}
}
})),
..TurnContextOverride::default()
}),
);
latest_turn.updated_at = now + ChronoDuration::seconds(5);
let mut thread =
ThreadRuntime::new("thread-1", "session-1", PathBuf::from("/tmp/service-scene"));
thread.updated_at = latest_turn.updated_at;
let snapshot = SessionRuntimeSnapshot {
session_id: "session-1".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread,
turns: vec![older_turn, latest_turn],
items: Vec::new(),
}],
};
let launch_context =
LimeRunServiceSkillTool::extract_launch_context_from_runtime_snapshot(&snapshot)
.expect("should resolve launch context");
assert_eq!(launch_context.service_skill_id, "skill-latest");
assert_eq!(launch_context.scene_key.as_deref(), Some("scene-latest"));
assert_eq!(launch_context.user_input.as_deref(), Some("最新输入"));
assert_eq!(
launch_context.oem_runtime.session_token.as_deref(),
Some("latest-token")
);
}
#[test]
fn should_extract_launch_context_from_environment_payload() {
let context = ToolContext::new(PathBuf::from("/tmp/service-scene")).with_environment(
HashMap::from([(
SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS[0].to_string(),
serde_json::json!({
"kind": "cloud_scene",
"service_scene_run": {
"skill_id": "skill-env",
"scene_key": "scene-env",
"user_input": "环境输入",
"oem_runtime": {
"scene_base_url": "https://example.com/scene-api",
"session_token": "env-token"
}
}
})
.to_string(),
)]),
);
let launch_context =
LimeRunServiceSkillTool::resolve_launch_context_from_environment(&context)
.expect("should resolve env launch context");
assert_eq!(launch_context.service_skill_id, "skill-env");
assert_eq!(launch_context.scene_key.as_deref(), Some("scene-env"));
assert_eq!(
launch_context.oem_runtime.scene_base_url.as_deref(),
Some("https://example.com/scene-api")
);
}
}
@@ -0,0 +1,193 @@
use super::*;
const TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_translation_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["translation_skill_launch", "translationSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_translation_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_translation_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_translation_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["translation_skill_launch", "translationSkillLaunch"],
)?;
let kind = extract_object_string(launch, &["kind"])
.unwrap_or_else(|| "translation_request".to_string());
if kind != "translation_request" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "translation".to_string());
let translation_request = launch
.get("translation_request")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(translation_request, &["raw_text", "rawText"]);
let prompt = extract_object_string(translation_request, &["prompt"])
.unwrap_or_else(|| "请翻译当前对话中最相关的内容".to_string());
let content = extract_object_string(translation_request, &["content"]);
let source_language =
extract_object_string(translation_request, &["source_language", "sourceLanguage"]);
let target_language =
extract_object_string(translation_request, &["target_language", "targetLanguage"]);
let style = extract_object_string(translation_request, &["style"]);
let output_format =
extract_object_string(translation_request, &["output_format", "outputFormat"]);
let project_id = extract_object_string(translation_request, &["project_id", "projectId"]);
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 args_payload = serde_json::json!({
"user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()),
"translation_request": serde_json::Value::Object(translation_request.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let request_json = truncate_prompt_text(
serde_json::to_string(translation_request).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let has_explicit_content = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自翻译技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速判断要翻译什么、要翻译成什么语言,再立刻把任务交给 Skill 工具;不要直接跳过 Skill 在聊天区作答。"
.to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天翻译。".to_string(),
"- 若用户明确给了正文、文件路径或范围,优先翻译这些材料;若未明确给材料,则翻译当前对话中与请求最相关的内容。".to_string(),
"- 译文必须忠于原文,不要补写原文没有的新事实;遇到术语或语义歧义时,要单独标注待确认项。".to_string(),
format!("- 当前翻译请求上下文(JSON):{request_json}"),
format!("- 当前入口来源:{entry_source}。"),
format!("- 当前翻译目标:{prompt}"),
];
if let Some(value) = content.as_deref() {
lines.push(format!("- 当前显式正文:{value}。"));
}
if let Some(value) = source_language.as_deref() {
lines.push(format!("- 当前原语言偏好:{value}。"));
}
if let Some(value) = target_language.as_deref() {
lines.push(format!("- 当前目标语言偏好:{value}。"));
}
if let Some(value) = style.as_deref() {
lines.push(format!("- 当前风格偏好:{value}。"));
}
if let Some(value) = output_format.as_deref() {
lines.push(format!("- 当前输出格式偏好:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if has_explicit_content {
lines
.push("- 当前任务已经显式进入翻译技能主链,不要再追问用户“是否开始翻译”。".to_string());
} else {
lines.push(
"- 当前没有显式正文时,优先尝试翻译当前对话上下文;只有在上下文也不足以完成时,才最多追问 1 个关键问题。"
.to_string(),
);
}
Some(lines.join("\n"))
}
@@ -0,0 +1,204 @@
use super::*;
const TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TYPESETTING_SKILL_LAUNCH_HINT>>";
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_typesetting_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["typesetting_skill_launch", "typesettingSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_typesetting_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let Some(launch_prompt) = build_typesetting_skill_launch_system_prompt(request_metadata) else {
return base_prompt;
};
match base_prompt {
Some(base) => {
if base.contains(TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(launch_prompt)
} else {
Some(format!("{base}\n\n{launch_prompt}"))
}
}
None => Some(launch_prompt),
}
}
fn build_typesetting_skill_launch_system_prompt(
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
let launch = extract_harness_nested_object(
request_metadata,
&["typesetting_skill_launch", "typesettingSkillLaunch"],
)?;
let kind =
extract_object_string(launch, &["kind"]).unwrap_or_else(|| "typesetting_task".to_string());
if kind != "typesetting_task" {
return None;
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| "typesetting".to_string());
let typesetting_task = launch
.get("typesetting_task")
.and_then(serde_json::Value::as_object)?;
let raw_text = extract_object_string(typesetting_task, &["raw_text", "rawText"]);
let prompt = extract_object_string(typesetting_task, &["prompt"]);
let content = extract_object_string(typesetting_task, &["content"]);
let target_platform =
extract_object_string(typesetting_task, &["target_platform", "targetPlatform"]);
let session_id = extract_object_string(typesetting_task, &["session_id", "sessionId"]);
let project_id = extract_object_string(typesetting_task, &["project_id", "projectId"]);
let content_id = extract_object_string(typesetting_task, &["content_id", "contentId"]);
let entry_source = extract_object_string(typesetting_task, &["entry_source", "entrySource"])
.unwrap_or_else(|| "at_typesetting_command".to_string());
let rules = typesetting_task
.get("rules")
.and_then(serde_json::Value::as_object)
.cloned();
let args_payload = serde_json::json!({
"user_input": raw_text
.clone()
.or(prompt.clone())
.or(content.clone())
.unwrap_or_else(|| "请根据当前要求执行排版优化任务".to_string()),
"typesetting_task": serde_json::Value::Object(typesetting_task.clone()),
});
let args_json = truncate_prompt_text(
serde_json::to_string(&args_payload).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let task_json = truncate_prompt_text(
serde_json::to_string(typesetting_task).unwrap_or_else(|_| "{}".to_string()),
4_000,
);
let content_present = content
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
let mut lines = vec![
TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER.to_string(),
"- 当前回合来自排版技能启动,不要把它当成普通聊天回答。".to_string(),
"- 先快速归纳用户目标,然后立刻把任务交给 Skill 工具;不要停留在泛泛解释。".to_string(),
format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"),
"- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(),
format!("- 推荐传给 Skill.args 的 JSON:{args_json}"),
"- Skill 执行后,优先沿 typesetting skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_typesetting_task。".to_string(),
"- 不要伪造“排版已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(),
format!("- 当前排版任务上下文(JSON):{task_json}"),
format!("- 当前入口来源:{entry_source}。"),
];
if let Some(value) = prompt.as_deref() {
lines.push(format!("- 当前排版目标:{value}"));
}
if let Some(value) = content.as_deref() {
lines.push(format!(
"- 当前待排版内容摘要:{}",
truncate_prompt_text(value.to_string(), 400)
));
}
if let Some(value) = target_platform.as_deref() {
lines.push(format!("- 当前目标平台:{value}。"));
}
if let Some(value) = session_id.as_deref() {
lines.push(format!("- 当前 session_id:{value}。"));
}
if let Some(value) = project_id.as_deref() {
lines.push(format!("- 当前 project_id:{value}。"));
}
if let Some(value) = content_id.as_deref() {
lines.push(format!("- 当前 content_id:{value}。"));
}
if let Some(value) = rules.as_ref() {
lines.push(format!(
"- 当前结构化规则(JSON):{}",
truncate_prompt_text(
serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()),
1_000,
)
));
}
if content_present {
lines.push(
"- 当前任务已经显式进入排版技能主链,不要再要求用户额外确认“是否开始排版”。"
.to_string(),
);
} else {
lines.push(
"- 当前还缺少明确待排版内容。你最多只能追问 1 个关键问题,请用户补充正文;在正文补齐前不要创建任务,也不要伪造结果。"
.to_string(),
);
}
Some(lines.join("\n"))
}
+42 -21
View File
@@ -9,7 +9,8 @@ use crate::database::DbConnection;
use serde::{Deserialize, Serialize};
use tauri::State;
pub(crate) const THEME_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1";
pub(crate) const GENERAL_WORKBENCH_DOCUMENT_META_KEY: &str = "general_workbench_document_v1";
pub(crate) const LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY: &str = "theme_workbench_document_v1";
/// 内容列表项(用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -80,7 +81,7 @@ impl From<Content> for ContentDetail {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeWorkbenchVersionState {
pub struct GeneralWorkbenchVersionState {
pub id: String,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -91,24 +92,25 @@ pub struct ThemeWorkbenchVersionState {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeWorkbenchDocumentState {
pub struct GeneralWorkbenchDocumentState {
pub content_id: String,
pub current_version_id: String,
pub version_count: usize,
pub versions: Vec<ThemeWorkbenchVersionState>,
pub versions: Vec<GeneralWorkbenchVersionState>,
}
fn is_valid_topic_branch_status(status: &str) -> bool {
matches!(status, "in_progress" | "pending" | "merged" | "candidate")
}
pub(crate) fn parse_theme_workbench_document_state(
pub(crate) fn parse_general_workbench_document_state(
content_id: &str,
metadata: Option<&serde_json::Value>,
) -> Option<ThemeWorkbenchDocumentState> {
) -> Option<GeneralWorkbenchDocumentState> {
let metadata = metadata?.as_object()?;
let raw = metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)?
.get(GENERAL_WORKBENCH_DOCUMENT_META_KEY)
.or_else(|| metadata.get(LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY))?
.as_object()?;
let versions_raw = raw.get("versions")?.as_array()?;
@@ -127,7 +129,7 @@ pub(crate) fn parse_theme_workbench_document_state(
.cloned()
.unwrap_or_default();
let versions: Vec<ThemeWorkbenchVersionState> = versions_raw
let versions: Vec<GeneralWorkbenchVersionState> = versions_raw
.iter()
.filter_map(|version| {
let version_obj = version.as_object()?;
@@ -158,7 +160,7 @@ pub(crate) fn parse_theme_workbench_document_state(
.filter(|value| is_valid_topic_branch_status(value))
.map(ToString::to_string);
Some(ThemeWorkbenchVersionState {
Some(GeneralWorkbenchVersionState {
is_current: id == current_version_id,
id,
created_at,
@@ -179,7 +181,7 @@ pub(crate) fn parse_theme_workbench_document_state(
return None;
}
Some(ThemeWorkbenchDocumentState {
Some(GeneralWorkbenchDocumentState {
content_id: content_id.to_string(),
current_version_id,
version_count: versions.len(),
@@ -270,16 +272,16 @@ pub async fn content_get(
Ok(content.map(|c| c.into()))
}
/// 获取主题工作台文稿版本状态(从 content.metadata 解析)
/// 获取工作区文稿版本状态(从 content.metadata 解析)
#[tauri::command]
pub async fn content_get_theme_workbench_document_state(
pub async fn content_get_general_workbench_document_state(
db: State<'_, DbConnection>,
id: String,
) -> Result<Option<ThemeWorkbenchDocumentState>, String> {
) -> Result<Option<GeneralWorkbenchDocumentState>, String> {
let manager = ContentManager::new(db.inner().clone());
let content = manager.get(&id)?;
Ok(content
.and_then(|item| parse_theme_workbench_document_state(&item.id, item.metadata.as_ref())))
.and_then(|item| parse_general_workbench_document_state(&item.id, item.metadata.as_ref())))
}
/// 列出项目的所有内容
@@ -357,12 +359,15 @@ pub async fn content_stats(
#[cfg(test)]
mod tests {
use super::{parse_theme_workbench_document_state, THEME_WORKBENCH_DOCUMENT_META_KEY};
use super::{
parse_general_workbench_document_state, GENERAL_WORKBENCH_DOCUMENT_META_KEY,
LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY,
};
#[test]
fn test_parse_theme_workbench_document_state_success() {
fn test_parse_general_workbench_document_state_success() {
let metadata = serde_json::json!({
THEME_WORKBENCH_DOCUMENT_META_KEY: {
GENERAL_WORKBENCH_DOCUMENT_META_KEY: {
"currentVersionId": "v2",
"versions": [
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" },
@@ -375,7 +380,7 @@ mod tests {
}
});
let parsed = parse_theme_workbench_document_state("content-1", Some(&metadata))
let parsed = parse_general_workbench_document_state("content-1", Some(&metadata))
.expect("should parse");
assert_eq!(parsed.content_id, "content-1");
assert_eq!(parsed.current_version_id, "v2");
@@ -385,9 +390,9 @@ mod tests {
}
#[test]
fn test_parse_theme_workbench_document_state_rejects_invalid_current_version() {
fn test_parse_general_workbench_document_state_rejects_invalid_current_version() {
let metadata = serde_json::json!({
THEME_WORKBENCH_DOCUMENT_META_KEY: {
GENERAL_WORKBENCH_DOCUMENT_META_KEY: {
"currentVersionId": "v-not-exists",
"versions": [
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" }
@@ -396,6 +401,22 @@ mod tests {
}
});
assert!(parse_theme_workbench_document_state("content-1", Some(&metadata)).is_none());
assert!(parse_general_workbench_document_state("content-1", Some(&metadata)).is_none());
}
#[test]
fn test_parse_general_workbench_document_state_accepts_legacy_alias_key() {
let metadata = serde_json::json!({
LEGACY_GENERAL_WORKBENCH_DOCUMENT_META_KEY: {
"currentVersionId": "v1",
"versions": [
{ "id": "v1", "createdAt": 1700000000000_i64, "description": "初稿" }
]
}
});
let parsed = parse_general_workbench_document_state("content-1", Some(&metadata))
.expect("should parse");
assert_eq!(parsed.current_version_id, "v1");
}
}
+25 -25
View File
@@ -88,7 +88,7 @@ pub async fn execution_run_get(
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunTodoItem {
pub struct GeneralWorkbenchRunTodoItem {
pub run_id: String,
pub execution_id: Option<String>,
pub session_id: Option<String>,
@@ -103,7 +103,7 @@ pub struct ThemeWorkbenchRunTodoItem {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunTerminalItem {
pub struct GeneralWorkbenchRunTerminalItem {
pub run_id: String,
pub execution_id: Option<String>,
pub session_id: Option<String>,
@@ -119,19 +119,19 @@ pub struct ThemeWorkbenchRunTerminalItem {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunState {
pub struct GeneralWorkbenchRunState {
pub run_state: String,
pub current_gate_key: String,
pub queue_items: Vec<ThemeWorkbenchRunTodoItem>,
pub latest_terminal: Option<ThemeWorkbenchRunTerminalItem>,
pub recent_terminals: Vec<ThemeWorkbenchRunTerminalItem>,
pub queue_items: Vec<GeneralWorkbenchRunTodoItem>,
pub latest_terminal: Option<GeneralWorkbenchRunTerminalItem>,
pub recent_terminals: Vec<GeneralWorkbenchRunTerminalItem>,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThemeWorkbenchRunHistoryPage {
pub items: Vec<ThemeWorkbenchRunTerminalItem>,
pub struct GeneralWorkbenchRunHistoryPage {
pub items: Vec<GeneralWorkbenchRunTerminalItem>,
pub has_more: bool,
pub next_offset: Option<usize>,
}
@@ -239,9 +239,9 @@ fn derive_run_title(run: &AgentRun) -> String {
}
match run.source.as_str() {
"skill" => "执行主题工作台技能".to_string(),
"skill" => "执行工作区技能".to_string(),
"automation" => "执行自动化任务".to_string(),
_ => "执行主题工作台编排".to_string(),
_ => "执行工作区编排".to_string(),
}
}
@@ -280,7 +280,7 @@ fn derive_run_gate_key(run: &AgentRun, title: &str) -> String {
infer_gate_key_from_probe(probe.as_str())
}
fn derive_current_gate_key(queue_items: &[ThemeWorkbenchRunTodoItem]) -> String {
fn derive_current_gate_key(queue_items: &[GeneralWorkbenchRunTodoItem]) -> String {
queue_items
.iter()
.find(|item| item.status == AgentRunStatus::Running)
@@ -323,10 +323,10 @@ fn derive_run_artifact_paths(run: &AgentRun) -> Vec<String> {
.unwrap_or_default()
}
fn build_terminal_item(run: &AgentRun) -> ThemeWorkbenchRunTerminalItem {
fn build_terminal_item(run: &AgentRun) -> GeneralWorkbenchRunTerminalItem {
let title = derive_run_title(run);
let gate_key = derive_run_gate_key(run, title.as_str());
ThemeWorkbenchRunTerminalItem {
GeneralWorkbenchRunTerminalItem {
run_id: run.id.clone(),
execution_id: derive_run_execution_id(run),
session_id: run.session_id.clone(),
@@ -344,7 +344,7 @@ fn build_terminal_item(run: &AgentRun) -> ThemeWorkbenchRunTerminalItem {
fn derive_recent_terminal_items(
runs: &[AgentRun],
limit: usize,
) -> Vec<ThemeWorkbenchRunTerminalItem> {
) -> Vec<GeneralWorkbenchRunTerminalItem> {
runs.iter()
.filter(|run| {
matches!(
@@ -361,11 +361,11 @@ fn derive_recent_terminal_items(
}
#[tauri::command]
pub async fn execution_run_get_theme_workbench_state(
pub async fn execution_run_get_general_workbench_state(
db: State<'_, DbConnection>,
session_id: String,
limit: Option<usize>,
) -> Result<ThemeWorkbenchRunState, String> {
) -> Result<GeneralWorkbenchRunState, String> {
let trimmed_session_id = session_id.trim();
if trimmed_session_id.is_empty() {
return Err("session_id 不能为空".to_string());
@@ -381,14 +381,14 @@ pub async fn execution_run_get_theme_workbench_state(
runs = tracker.list_runs_by_session(trimmed_session_id, safe_limit * 5)?;
}
let queue_items: Vec<ThemeWorkbenchRunTodoItem> = runs
let queue_items: Vec<GeneralWorkbenchRunTodoItem> = runs
.iter()
.filter(|run| matches!(run.status, AgentRunStatus::Running | AgentRunStatus::Queued))
.take(safe_limit)
.map(|run| {
let title = derive_run_title(run);
let gate_key = derive_run_gate_key(run, title.as_str());
ThemeWorkbenchRunTodoItem {
GeneralWorkbenchRunTodoItem {
run_id: run.id.clone(),
execution_id: derive_run_execution_id(run),
session_id: run.session_id.clone(),
@@ -413,7 +413,7 @@ pub async fn execution_run_get_theme_workbench_state(
let recent_terminals = derive_recent_terminal_items(runs.as_slice(), safe_limit);
let latest_terminal = recent_terminals.first().cloned();
Ok(ThemeWorkbenchRunState {
Ok(GeneralWorkbenchRunState {
run_state,
current_gate_key,
queue_items,
@@ -424,12 +424,12 @@ pub async fn execution_run_get_theme_workbench_state(
}
#[tauri::command]
pub async fn execution_run_list_theme_workbench_history(
pub async fn execution_run_list_general_workbench_history(
db: State<'_, DbConnection>,
session_id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<ThemeWorkbenchRunHistoryPage, String> {
) -> Result<GeneralWorkbenchRunHistoryPage, String> {
let trimmed_session_id = session_id.trim();
if trimmed_session_id.is_empty() {
return Err("session_id 不能为空".to_string());
@@ -455,7 +455,7 @@ pub async fn execution_run_list_theme_workbench_history(
.map(|run| build_terminal_item(&run))
.collect::<Vec<_>>();
Ok(ThemeWorkbenchRunHistoryPage {
Ok(GeneralWorkbenchRunHistoryPage {
items,
has_more,
next_offset: if has_more {
@@ -516,7 +516,7 @@ mod tests {
#[test]
fn derive_current_gate_key_should_prefer_running_item() {
let queue_items = vec![
ThemeWorkbenchRunTodoItem {
GeneralWorkbenchRunTodoItem {
run_id: "run-1".to_string(),
execution_id: None,
session_id: None,
@@ -528,7 +528,7 @@ mod tests {
source_ref: None,
started_at: "2026-03-06T00:00:00Z".to_string(),
},
ThemeWorkbenchRunTodoItem {
GeneralWorkbenchRunTodoItem {
run_id: "run-2".to_string(),
execution_id: None,
session_id: None,
@@ -550,7 +550,7 @@ mod tests {
#[test]
fn derive_current_gate_key_should_fallback_to_first_item() {
let queue_items = vec![ThemeWorkbenchRunTodoItem {
let queue_items = vec![GeneralWorkbenchRunTodoItem {
run_id: "run-1".to_string(),
execution_id: None,
session_id: None,
+35 -44
View File
@@ -6,6 +6,22 @@ use crate::app::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
fn normalize_non_empty_api_key(raw: Option<String>) -> Option<String> {
raw.and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn resolve_api_key_with_env_fallback(config_key: Option<String>, env_key: &str) -> Option<String> {
normalize_non_empty_api_key(config_key)
.or_else(|| normalize_non_empty_api_key(std::env::var(env_key).ok()))
}
/// Pixabay 搜索请求
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -137,53 +153,21 @@ async fn get_pixabay_api_key(app_state: State<'_, AppState>) -> Option<String> {
state.config.image_gen.image_search_pixabay_api_key.clone()
};
key_from_config
.and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.or_else(|| {
std::env::var("PIXABAY_API_KEY").ok().and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
})
resolve_api_key_with_env_fallback(key_from_config, "PIXABAY_API_KEY")
}
pub(crate) fn resolve_pexels_api_key(config_key: Option<String>) -> Option<String> {
resolve_api_key_with_env_fallback(config_key, "PEXELS_API_KEY")
}
/// 获取 Pexels API Key(优先配置,其次环境变量)
async fn get_pexels_api_key(app_state: State<'_, AppState>) -> Option<String> {
pub(crate) async fn get_pexels_api_key_from_app_state(app_state: &AppState) -> Option<String> {
let key_from_config = {
let state = app_state.read().await;
state.config.image_gen.image_search_pexels_api_key.clone()
};
key_from_config
.and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.or_else(|| {
std::env::var("PEXELS_API_KEY").ok().and_then(|key| {
let trimmed = key.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
})
resolve_pexels_api_key(key_from_config)
}
fn map_aspect_to_pexels_orientation(aspect: Option<&str>) -> Option<&'static str> {
@@ -343,13 +327,11 @@ pub async fn search_pixabay_images(
}
/// 联网搜索图片(Pexels)
#[tauri::command]
pub async fn search_web_images(
app_state: State<'_, AppState>,
pub(crate) async fn search_web_images_with_pexels_api_key(
api_key: Option<String>,
req: WebImageSearchRequest,
) -> Result<WebImageSearchResponse, String> {
let api_key = get_pexels_api_key(app_state)
.await
let api_key = resolve_pexels_api_key(api_key)
.ok_or_else(|| "未配置 Pexels API Key,请先在设置 → 系统 → 网络搜索中配置".to_string())?;
let client = reqwest::Client::new();
@@ -392,6 +374,15 @@ pub async fn search_web_images(
Ok(map_pexels_to_web_response(body))
}
#[tauri::command]
pub async fn search_web_images(
app_state: State<'_, AppState>,
req: WebImageSearchRequest,
) -> Result<WebImageSearchResponse, String> {
let api_key = get_pexels_api_key_from_app_state(app_state.inner()).await;
search_web_images_with_pexels_api_key(api_key, req).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1,8 +1,8 @@
use super::{args_or_default, get_db, get_string_arg, parse_nested_arg, parse_optional_nested_arg};
use crate::commands::content_cmd::{
parse_theme_workbench_document_state, ContentDetail, ContentListItem,
CreateContentRequest as BridgeCreateContentRequest,
ListContentRequest as BridgeListContentRequest, ThemeWorkbenchDocumentState,
parse_general_workbench_document_state, ContentDetail, ContentListItem,
CreateContentRequest as BridgeCreateContentRequest, GeneralWorkbenchDocumentState,
ListContentRequest as BridgeListContentRequest,
UpdateContentRequest as BridgeUpdateContentRequest,
};
use crate::content::{
@@ -81,13 +81,13 @@ pub(super) fn try_handle(
let manager = content_manager(state)?;
serde_json::to_value(manager.get(&id)?.map(ContentDetail::from))?
}
"content_get_theme_workbench_document_state" => {
"content_get_general_workbench_document_state" => {
let args = args_or_default(args);
let id = get_string_arg(&args, "id", "id")?;
let manager = content_manager(state)?;
let content = manager.get(&id)?;
let document_state: Option<ThemeWorkbenchDocumentState> = content.and_then(|item| {
parse_theme_workbench_document_state(&item.id, item.metadata.as_ref())
let document_state: Option<GeneralWorkbenchDocumentState> = content.and_then(|item| {
parse_general_workbench_document_state(&item.id, item.metadata.as_ref())
});
serde_json::to_value(document_state)?
}
@@ -3,7 +3,7 @@
//! 负责在工作区内生成稳定路径、落盘 JSON 快照,并给前端 workbench
//! 提供可直接消费的 snapshot metadata。
use crate::commands::content_cmd::THEME_WORKBENCH_DOCUMENT_META_KEY;
use crate::commands::content_cmd::GENERAL_WORKBENCH_DOCUMENT_META_KEY;
use crate::content::{ContentManager, ContentUpdateRequest};
use crate::database::DbConnection;
use crate::services::artifact_document_validator::{
@@ -29,7 +29,7 @@ pub struct PersistedArtifactDocument {
pub absolute_path: PathBuf,
pub serialized_document: String,
pub snapshot_metadata: Map<String, Value>,
pub theme_workbench_document_state: Map<String, Value>,
pub general_workbench_document_state: Map<String, Value>,
pub content_body: String,
pub title: String,
pub kind: String,
@@ -274,8 +274,8 @@ pub fn persist_artifact_document_from_text(
&source_links,
version_diff.as_ref(),
);
let theme_workbench_document_state =
build_theme_workbench_document_state(&version_history, current_version.id.as_str());
let general_workbench_document_state =
build_general_workbench_document_state(&version_history, current_version.id.as_str());
let content_body = build_content_body_from_document(&enriched_document);
Ok(PersistedArtifactDocument {
@@ -286,7 +286,7 @@ pub fn persist_artifact_document_from_text(
absolute_path,
serialized_document,
snapshot_metadata,
theme_workbench_document_state,
general_workbench_document_state,
content_body,
title: outcome.title,
kind: outcome.kind,
@@ -423,7 +423,7 @@ fn resolve_topic_branch_status(status: &str) -> Option<&'static str> {
}
}
fn build_theme_workbench_document_state(
fn build_general_workbench_document_state(
version_history: &[ArtifactVersionSummary],
current_version_id: &str,
) -> Map<String, Value> {
@@ -628,8 +628,8 @@ pub fn sync_persisted_artifact_document_to_content(
}
}
next_metadata.insert(
THEME_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.theme_workbench_document_state.clone()),
GENERAL_WORKBENCH_DOCUMENT_META_KEY.to_string(),
Value::Object(persisted.general_workbench_document_state.clone()),
);
manager.update(
@@ -1426,7 +1426,7 @@ mod tests {
.contains("\"currentVersionDiff\""));
assert_eq!(
persisted_second
.theme_workbench_document_state
.general_workbench_document_state
.get("currentVersionId")
.and_then(Value::as_str),
Some("artifact-document:artifact:analysis:demo:v2")
@@ -1512,7 +1512,7 @@ mod tests {
let metadata = updated.metadata.expect("metadata should exist");
assert_eq!(
metadata
.get(THEME_WORKBENCH_DOCUMENT_META_KEY)
.get(GENERAL_WORKBENCH_DOCUMENT_META_KEY)
.and_then(Value::as_object)
.and_then(|value| value.get("currentVersionId"))
.and_then(Value::as_str),
@@ -5,6 +5,9 @@
use serde_json::{Map, Value};
const GENERAL_WORKBENCH_SESSION_MODE: &str = "general_workbench";
const LEGACY_GENERAL_WORKBENCH_SESSION_MODE_ALIAS: &str = "theme_workbench";
const ARTIFACT_MEANINGFUL_KEYS: &[&str] = &[
"artifact_mode",
"artifactMode",
@@ -31,6 +34,16 @@ fn normalize_text(value: Option<&str>) -> Option<String> {
.map(str::to_string)
}
fn normalize_session_mode_text(value: Option<&str>) -> Option<String> {
match normalize_text(value)?.as_str() {
GENERAL_WORKBENCH_SESSION_MODE | LEGACY_GENERAL_WORKBENCH_SESSION_MODE_ALIAS => {
Some(GENERAL_WORKBENCH_SESSION_MODE.to_string())
}
"default" => Some("default".to_string()),
_ => None,
}
}
fn root_object(request_metadata: Option<&Value>) -> Option<&Map<String, Value>> {
request_metadata?.as_object()
}
@@ -51,6 +64,40 @@ fn extract_harness_string(request_metadata: Option<&Value>, keys: &[&str]) -> Op
.and_then(|value| normalize_text(Some(value)))
}
fn extract_harness_session_mode(request_metadata: Option<&Value>) -> Option<String> {
normalize_session_mode_text(
extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(),
)
}
fn normalize_harness_session_mode_field(request_metadata: Value) -> Value {
let Some(normalized_session_mode) = extract_harness_session_mode(Some(&request_metadata))
else {
return request_metadata;
};
let mut request_metadata = request_metadata;
let Some(root) = request_metadata.as_object_mut() else {
return request_metadata;
};
if let Some(harness) = root.get_mut("harness").and_then(Value::as_object_mut) {
harness.insert(
"session_mode".to_string(),
Value::String(normalized_session_mode),
);
harness.remove("sessionMode");
return request_metadata;
}
root.insert(
"session_mode".to_string(),
Value::String(normalized_session_mode),
);
root.remove("sessionMode");
request_metadata
}
fn is_flat_artifact_metadata_key(key: &str) -> bool {
matches!(
key,
@@ -117,8 +164,8 @@ fn infer_source_policy(kind: Option<&str>) -> Option<&'static str> {
}
fn should_enable_artifact_draft(request_metadata: Option<&Value>) -> bool {
if extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref()
!= Some("theme_workbench")
if extract_harness_session_mode(request_metadata).as_deref()
!= Some(GENERAL_WORKBENCH_SESSION_MODE)
{
return false;
}
@@ -183,6 +230,7 @@ pub fn normalize_request_metadata_with_artifact_defaults(
content_id_fallback: Option<&str>,
) -> Option<Value> {
let request_metadata = request_metadata?;
let request_metadata = normalize_harness_session_mode_field(request_metadata);
let request_metadata = backfill_harness_string_if_missing(
request_metadata,
&["theme", "harness_theme", "harnessTheme"],
@@ -294,11 +342,11 @@ mod tests {
use serde_json::json;
#[test]
fn should_infer_theme_workbench_artifact_defaults_from_harness() {
fn should_infer_general_workbench_artifact_defaults_from_harness() {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-1"
}
});
@@ -356,7 +404,7 @@ mod tests {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"turn_purpose": "content_review",
"content_id": "content-1"
}
@@ -422,7 +470,7 @@ mod tests {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench"
"session_mode": "general_workbench"
}
});
@@ -461,7 +509,7 @@ mod tests {
let normalized = normalize_request_metadata_with_artifact_defaults(
Some(metadata),
Some("general"),
Some("theme_workbench"),
Some("general_workbench"),
None,
None,
None,
@@ -476,7 +524,7 @@ mod tests {
normalized
.pointer("/harness/session_mode")
.and_then(Value::as_str),
Some("theme_workbench")
Some("general_workbench")
);
assert_eq!(
normalized
@@ -491,7 +539,7 @@ mod tests {
let metadata = json!({
"harness": {
"theme": "general",
"session_mode": "theme_workbench",
"session_mode": "general_workbench",
"content_id": "content-social-1"
}
});
+55 -5
View File
@@ -7,10 +7,12 @@ use std::path::PathBuf;
use lime_core::app_paths;
use lime_core::models::parse_skill_manifest_from_content;
use lime_core::models::{
BROADCAST_GENERATE_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY,
COVER_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY,
MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY,
TRANSCRIPTION_GENERATE_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
ANALYSIS_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY,
CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, COVER_GENERATE_SKILL_DIRECTORY,
IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY,
PDF_READ_SKILL_DIRECTORY, REPORT_GENERATE_SKILL_DIRECTORY, RESEARCH_SKILL_DIRECTORY,
SITE_SEARCH_SKILL_DIRECTORY, SUMMARY_SKILL_DIRECTORY, TRANSCRIPTION_GENERATE_SKILL_DIRECTORY,
TRANSLATION_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, URL_PARSE_SKILL_DIRECTORY,
VIDEO_GENERATE_SKILL_DIRECTORY,
};
@@ -40,9 +42,23 @@ const URL_PARSE_SKILL_CONTENT: &str =
const RESEARCH_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/research/SKILL.md");
const REPORT_GENERATE_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/report_generate/SKILL.md");
const SITE_SEARCH_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/site_search/SKILL.md");
const PDF_READ_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/pdf_read/SKILL.md");
const SUMMARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/summary/SKILL.md");
const TRANSLATION_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/translation/SKILL.md");
const ANALYSIS_SKILL_CONTENT: &str =
include_str!("../../resources/default-skills/analysis/SKILL.md");
const SITE_SEARCH_ADAPTER_CATALOG_CONTENT: &str =
include_str!("../../resources/default-skills/site_search/references/adapter-catalog.md");
@@ -82,7 +98,7 @@ const SITE_SEARCH_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFile {
content: SITE_SEARCH_ADAPTER_CATALOG_CONTENT,
}];
fn default_skills() -> [BundledSkillDefinition; 12] {
fn default_skills() -> [BundledSkillDefinition; 17] {
[
BundledSkillDefinition {
directory: VIDEO_GENERATE_SKILL_DIRECTORY,
@@ -129,11 +145,36 @@ fn default_skills() -> [BundledSkillDefinition; 12] {
skill_content: RESEARCH_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: REPORT_GENERATE_SKILL_DIRECTORY,
skill_content: REPORT_GENERATE_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: SITE_SEARCH_SKILL_DIRECTORY,
skill_content: SITE_SEARCH_SKILL_CONTENT,
extra_files: SITE_SEARCH_EXTRA_FILES,
},
BundledSkillDefinition {
directory: PDF_READ_SKILL_DIRECTORY,
skill_content: PDF_READ_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: SUMMARY_SKILL_DIRECTORY,
skill_content: SUMMARY_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: TRANSLATION_SKILL_DIRECTORY,
skill_content: TRANSLATION_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: ANALYSIS_SKILL_DIRECTORY,
skill_content: ANALYSIS_SKILL_CONTENT,
extra_files: &[],
},
BundledSkillDefinition {
directory: TYPESETTING_SKILL_DIRECTORY,
skill_content: TYPESETTING_SKILL_CONTENT,
@@ -342,7 +383,13 @@ mod tests {
assert!(LIBRARY_SKILL_CONTENT.contains("name: library"));
assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse"));
assert!(RESEARCH_SKILL_CONTENT.contains("name: research"));
assert!(REPORT_GENERATE_SKILL_CONTENT.contains("name: report_generate"));
assert!(REPORT_GENERATE_SKILL_CONTENT.contains("allowed-tools: search_query"));
assert!(SITE_SEARCH_SKILL_CONTENT.contains("name: site_search"));
assert!(PDF_READ_SKILL_CONTENT.contains("name: pdf_read"));
assert!(PDF_READ_SKILL_CONTENT.contains("allowed-tools: list_directory, read_file"));
assert!(SUMMARY_SKILL_CONTENT.contains("name: summary"));
assert!(SUMMARY_SKILL_CONTENT.contains("allowed-tools: list_directory, read_file"));
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`github/search`"));
assert!(SITE_SEARCH_ADAPTER_CATALOG_CONTENT.contains("`zhihu/hot`"));
assert!(TYPESETTING_SKILL_CONTENT.contains("name: typesetting"));
@@ -356,7 +403,10 @@ mod tests {
assert!(LIBRARY_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(URL_PARSE_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(RESEARCH_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(REPORT_GENERATE_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(SITE_SEARCH_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(PDF_READ_SKILL_CONTENT.contains("lime_surface: chat"));
assert!(SUMMARY_SKILL_CONTENT.contains("lime_surface: chat"));
}
#[test]
+1 -1
View File
@@ -24,7 +24,7 @@ pub use runtime::{
build_skill_run_finish_decision, build_skill_run_start_metadata, prepare_skill_execution,
PreparedSkillExecution, SkillProviderSelection,
};
pub use social_post::infer_theme_workbench_gate_key;
pub use social_post::infer_general_workbench_gate_key;
// Tauri 实现(留在主 crate)
pub use default_skills::ensure_default_local_skills;
pub use execution_callback::TauriExecutionCallback;
+2 -2
View File
@@ -17,7 +17,7 @@ use std::path::Path;
use super::execution::SkillExecutionResult;
use super::execution_callback::TauriExecutionCallback;
use super::social_post::{infer_theme_workbench_gate_key, is_content_post_skill_name};
use super::social_post::{infer_general_workbench_gate_key, is_content_post_skill_name};
#[cfg(test)]
use super::social_post::CONTENT_POST_WITH_COVER_SKILL_NAME;
@@ -243,7 +243,7 @@ pub fn build_skill_run_start_metadata(
serde_json::json!({
"execution_id": execution_id,
"skill_name": skill_name,
"gate_key": infer_theme_workbench_gate_key(skill_name, user_input),
"gate_key": infer_general_workbench_gate_key(skill_name, user_input),
"provider_override": provider_override,
"model_override": model_override,
})
+1 -1
View File
@@ -28,7 +28,7 @@ struct SocialSkillOutputEnvelope {
file_content: String,
}
pub fn infer_theme_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str {
pub fn infer_general_workbench_gate_key(skill_name: &str, user_input: &str) -> &'static str {
let probe = format!("{} {}", skill_name, user_input).to_lowercase();
if probe.contains("publish")
|| probe.contains("adapt")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.3.0",
"version": "1.4.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.3.0",
"version": "1.4.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
+311 -147
View File
@@ -34,6 +34,7 @@ import { type TaskFile } from "./components/TaskFiles";
import { useWorkflow } from "@/lib/workspace/workbenchWorkflow";
import {
createInitialCanvasState,
createInitialVideoState,
type CanvasStateUnion,
} from "@/lib/workspace/workbenchCanvas";
import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas";
@@ -48,12 +49,12 @@ import {
} from "@/lib/artifact/store";
import type { Artifact } from "@/lib/artifact/types";
import { useAtomValue, useSetAtom } from "jotai";
import { generateThemeWorkbenchPrompt } from "@/lib/workspace/workbenchPrompt";
import { generateGeneralWorkbenchPrompt } from "@/lib/workspace/workbenchPrompt";
import { generateProjectMemoryPrompt } from "@/lib/workspace/workbenchPrompt";
import {
getProject,
getContent,
getThemeWorkbenchDocumentState,
getGeneralWorkbenchDocumentState,
ensureWorkspaceReady,
type Project,
} from "@/lib/api/project";
@@ -80,6 +81,7 @@ import {
import type {
Message,
MessagePreviewTarget,
SiteSavedContentTarget,
WriteArtifactContext,
} from "./types";
@@ -119,21 +121,18 @@ import { useServiceSkills } from "./service-skills/useServiceSkills";
import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection";
import { useBootstrapDispatchPreview } from "./hooks/useBootstrapDispatchPreview";
import { useRuntimeTeamFormation } from "./hooks/useRuntimeTeamFormation";
import { useThemeWorkbenchEntryPrompt } from "./hooks/useThemeWorkbenchEntryPrompt";
import { useThemeWorkbenchEntryPromptActions } from "./hooks/useThemeWorkbenchEntryPromptActions";
import { useThemeWorkbenchSendBoundary } from "./hooks/useThemeWorkbenchSendBoundary";
import type { BrowserTaskPreflight } from "./hooks/handleSendTypes";
import { useGeneralWorkbenchEntryPrompt } from "./hooks/useGeneralWorkbenchEntryPrompt";
import { useGeneralWorkbenchEntryPromptActions } from "./hooks/useGeneralWorkbenchEntryPromptActions";
import { useGeneralWorkbenchSendBoundary } from "./hooks/useGeneralWorkbenchSendBoundary";
import { mergeThreadItems } from "./utils/threadTimelineView";
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
import {
asRecord,
isResumableBrowserTaskReason,
mergeMessageArtifactsIntoStore,
readFirstString,
} from "./workspace/browserAssistArtifact";
import { ServiceSkillExecutionCard } from "./workspace/ServiceSkillExecutionCard";
import { useWorkspaceBrowserAssistRuntime } from "./workspace/useWorkspaceBrowserAssistRuntime";
import { useWorkspaceBrowserPreflightRuntime } from "./workspace/useWorkspaceBrowserPreflightRuntime";
import { useWorkspaceA2UISubmitActions } from "./workspace/useWorkspaceA2UISubmitActions";
import { useWorkspaceContextHarnessRuntime } from "./workspace/useWorkspaceContextHarnessRuntime";
import { useWorkspaceHarnessInventoryRuntime } from "./workspace/useWorkspaceHarnessInventoryRuntime";
@@ -152,26 +151,31 @@ import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasL
import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync";
import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync";
import { useWorkspaceArtifactWorkbenchActions } from "./workspace/useWorkspaceArtifactWorkbenchActions";
import { useWorkspaceImageWorkbenchActionRuntime } from "./workspace/useWorkspaceImageWorkbenchActionRuntime";
import {
useWorkspaceImageWorkbenchActionRuntime,
type SubmitImageWorkbenchAgentCommandParams,
} from "./workspace/useWorkspaceImageWorkbenchActionRuntime";
import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspaceImageWorkbenchEventRuntime";
import { buildImageSkillLaunchRequestMetadata } from "./workspace/imageSkillLaunch";
import { useWorkspaceImageTaskPreviewRuntime } from "./workspace/useWorkspaceImageTaskPreviewRuntime";
import { useWorkspaceVideoTaskPreviewRuntime } from "./workspace/useWorkspaceVideoTaskPreviewRuntime";
import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime";
import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore";
import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime";
import { useWorkspaceSendActions } from "./workspace/useWorkspaceSendActions";
import { useWorkspaceTeamSessionControlRuntime } from "./workspace/useWorkspaceTeamSessionControlRuntime";
import { useWorkspaceTeamWorkbenchAutoOpenRuntime } from "./workspace/useWorkspaceTeamWorkbenchAutoOpenRuntime";
import { useWorkspaceThemeWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceThemeWorkbenchScaffoldRuntime";
import { useWorkspaceThemeWorkbenchVersionStatusRuntime } from "./workspace/useWorkspaceThemeWorkbenchVersionStatusRuntime";
import { useWorkspaceGeneralWorkbenchScaffoldRuntime } from "./workspace/useWorkspaceGeneralWorkbenchScaffoldRuntime";
import { useWorkspaceGeneralWorkbenchVersionStatusRuntime } from "./workspace/useWorkspaceGeneralWorkbenchVersionStatusRuntime";
import { useWorkspaceTopicSwitch } from "./workspace/useWorkspaceTopicSwitch";
import { useWorkspaceA2UIRuntime } from "./workspace/useWorkspaceA2UIRuntime";
import { useWorkspaceAutoGuideRuntime } from "./workspace/useWorkspaceAutoGuideRuntime";
import { useWorkspaceThemeWorkbenchSidebarRuntime } from "./workspace/useWorkspaceThemeWorkbenchSidebarRuntime";
import { useWorkspaceThemeWorkbenchRuntime } from "./workspace/useWorkspaceThemeWorkbenchRuntime";
import { useWorkspaceThemeWorkbenchShellRuntime } from "./workspace/useWorkspaceThemeWorkbenchShellRuntime";
import { useWorkspaceGeneralWorkbenchSidebarRuntime } from "./workspace/useWorkspaceGeneralWorkbenchSidebarRuntime";
import { useWorkspaceGeneralWorkbenchRuntime } from "./workspace/useWorkspaceGeneralWorkbenchRuntime";
import { useWorkspaceGeneralWorkbenchShellRuntime } from "./workspace/useWorkspaceGeneralWorkbenchShellRuntime";
import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContextDetailActions";
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions";
import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
@@ -183,13 +187,13 @@ import {
} from "./workspace/imageWorkbenchHelpers";
import {
SOCIAL_ARTICLE_SKILL_KEY,
THEME_WORKBENCH_HISTORY_PAGE_SIZE,
applyBackendThemeWorkbenchDocumentState,
isCorruptedThemeWorkbenchDocumentContent,
GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
applyBackendGeneralWorkbenchDocumentState,
isCorruptedGeneralWorkbenchDocumentContent,
isSyncContentEmpty,
readPersistedThemeWorkbenchDocument,
readPersistedGeneralWorkbenchDocument,
serializeCanvasStateForSync,
} from "./workspace/themeWorkbenchHelpers";
} from "./workspace/generalWorkbenchHelpers";
import {
normalizeInitialTheme,
projectTypeToTheme,
@@ -222,6 +226,87 @@ function resolveDefaultSelectedArtifact(
return null;
}
function resolveVideoCanvasStatusFromPreview(
target: Extract<MessagePreviewTarget, { kind: "task" }>,
): "idle" | "generating" | "success" | "error" {
const preview = target.preview;
if (preview.kind !== "video_generate") {
return "idle";
}
if (
(preview.status === "complete" || preview.status === "partial") &&
preview.videoUrl
) {
return "success";
}
if (preview.status === "failed" || preview.status === "cancelled") {
return "error";
}
return "generating";
}
function normalizeTaskPreviewArtifactPath(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
return value.trim().replace(/\\/g, "/");
}
function resolveTaskPreviewArtifact(
message: Message,
target: Extract<MessagePreviewTarget, { kind: "task" }>,
): Artifact | null {
const normalizedArtifactPath = normalizeTaskPreviewArtifactPath(
target.preview.kind === "video_generate"
? null
: target.preview.artifactPath || null,
);
const messageArtifacts = message.artifacts || [];
if (normalizedArtifactPath) {
const matchedArtifact = messageArtifacts.find(
(artifact) =>
normalizeTaskPreviewArtifactPath(resolveArtifactProtocolFilePath(artifact)) ===
normalizedArtifactPath,
);
if (matchedArtifact) {
return matchedArtifact;
}
}
return messageArtifacts.length > 0
? (messageArtifacts[messageArtifacts.length - 1] ?? null)
: null;
}
function normalizeVideoAspectRatio(
value?: string,
): "adaptive" | "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "21:9" {
switch (value) {
case "16:9":
case "9:16":
case "1:1":
case "4:3":
case "3:4":
case "21:9":
return value;
default:
return "adaptive";
}
}
function normalizeVideoResolution(
value?: string,
): "480p" | "720p" | "1080p" {
switch (value) {
case "480p":
case "1080p":
return value;
case "720p":
default:
return "720p";
}
}
export type {
AgentChatWorkspaceProps,
WorkflowProgressSnapshot,
@@ -574,7 +659,7 @@ export function AgentChatWorkspace({
}, [activeTheme, serviceSkillsError]);
const combinedSkillsLoading = skillsLoading || serviceSkillsLoading;
// Workbench Store(用于主题工作台右侧面板状态同步)
// Workbench Store(用于工作区右侧技能面板状态同步)
const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey);
const clearThemeSkillsRailState = useWorkbenchStore(
(state) => state.clearThemeSkillsRailState,
@@ -641,8 +726,6 @@ export function AgentChatWorkspace({
>("desktop");
const [canvasWorkbenchLayoutMode, setCanvasWorkbenchLayoutMode] =
useState<CanvasWorkbenchLayoutMode>("split");
const [browserTaskPreflight, setBrowserTaskPreflight] =
useState<BrowserTaskPreflight | null>(null);
const [focusedArtifactBlockId, setFocusedArtifactBlockId] = useState<
string | null
>(null);
@@ -654,13 +737,6 @@ export function AgentChatWorkspace({
const [timelineFocusRequestKey, setTimelineFocusRequestKey] = useState(0);
const autoCollapsedTopicSidebarRef = useRef(false);
useEffect(() => {
if (activeTheme === "general") {
return;
}
setBrowserTaskPreflight(null);
}, [activeTheme]);
// 跳转到技能主页面
const handleNavigateToSkillSettings = useCallback(() => {
_onNavigate?.("skills");
@@ -809,7 +885,7 @@ export function AgentChatWorkspace({
: theme
) as ThemeType;
const rawBody = content.body || "";
const sanitizedBody = isCorruptedThemeWorkbenchDocumentContent(rawBody)
const sanitizedBody = isCorruptedGeneralWorkbenchDocumentContent(rawBody)
? ""
: rawBody;
@@ -826,11 +902,11 @@ export function AgentChatWorkspace({
createInitialDocumentState(sanitizedBody);
if (initialState.type === "document") {
const backendDocumentState = await getThemeWorkbenchDocumentState(
const backendDocumentState = await getGeneralWorkbenchDocumentState(
content.id,
).catch((error) => {
console.warn(
"[AgentChatPage] 读取主题工作台版本状态失败,降级为 metadata 解析:",
"[AgentChatPage] 读取工作区文稿版本状态失败,降级为 metadata 解析:",
error,
);
logAgentDebug(
@@ -851,7 +927,7 @@ export function AgentChatWorkspace({
hasBackendDocumentState: Boolean(backendDocumentState),
});
const backendApplied = backendDocumentState
? applyBackendThemeWorkbenchDocumentState(
? applyBackendGeneralWorkbenchDocumentState(
initialState,
backendDocumentState,
sanitizedBody,
@@ -862,7 +938,7 @@ export function AgentChatWorkspace({
initialState = backendApplied.state;
setDocumentVersionStatusMap(backendApplied.statusMap);
} else {
const persisted = readPersistedThemeWorkbenchDocument(
const persisted = readPersistedGeneralWorkbenchDocument(
content.metadata,
);
if (persisted) {
@@ -1043,7 +1119,7 @@ export function AgentChatWorkspace({
},
});
} else if (isSpecializedThemeMode) {
prompt = generateThemeWorkbenchPrompt(mappedTheme, creationMode);
prompt = generateGeneralWorkbenchPrompt(mappedTheme, creationMode);
}
// 注入项目 Memory
@@ -1103,8 +1179,10 @@ export function AgentChatWorkspace({
submittedActionsInFlight = [],
triggerAIGuide,
topics = [],
isAutoRestoringSession = false,
sessionId,
createFreshSession,
ensureSession = async () => null,
switchTopic: originalSwitchTopic,
deleteTopic,
renameTopic,
@@ -1319,26 +1397,47 @@ export function AgentChatWorkspace({
},
[imageWorkbenchSessionKey],
);
const appendLocalDispatchMessages = useCallback(
(nextMessages: Message[]) => {
setChatMessages((previous) => {
const next = [...previous];
const updateImageWorkbenchStateForSession = useCallback(
(
sessionKey: string,
updater: (
current: SessionImageWorkbenchState,
) => SessionImageWorkbenchState,
options?: {
fallbackState?: SessionImageWorkbenchState;
removeSessionKeys?: string[];
},
) => {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return;
}
for (const message of nextMessages) {
const existingIndex = next.findIndex(
(candidate) => candidate.id === message.id,
);
if (existingIndex === -1) {
next.push(message);
continue;
setImageWorkbenchBySessionId((previous) => {
const current =
previous[normalizedSessionKey] ||
options?.fallbackState ||
createInitialSessionImageWorkbenchState();
const nextState = {
...previous,
[normalizedSessionKey]: updater(current),
};
options?.removeSessionKeys?.forEach((candidateKey) => {
const normalizedCandidateKey = candidateKey.trim();
if (
!normalizedCandidateKey ||
normalizedCandidateKey === normalizedSessionKey
) {
return;
}
next[existingIndex] = message;
}
delete nextState[normalizedCandidateKey];
});
return next;
return nextState;
});
},
[setChatMessages],
[],
);
const teamSessionRuntime = useWorkspaceTeamSessionRuntime({
sessionId,
@@ -1468,8 +1567,6 @@ export function AgentChatWorkspace({
browserAssistLaunching,
browserAssistSessionState,
siteSkillExecutionState,
isBrowserAssistReady,
isBrowserAssistCanvasVisible,
currentBrowserAssistScopeKey,
ensureBrowserAssistCanvas,
suppressBrowserAssistCanvasAutoOpen,
@@ -1776,9 +1873,9 @@ export function AgentChatWorkspace({
harnessAttentionLevel,
navbarHarnessPanelVisible,
} = contextHarnessRuntime;
const themeWorkbenchScaffoldRuntime =
useWorkspaceThemeWorkbenchScaffoldRuntime({
isThemeWorkbench,
const generalWorkbenchScaffoldRuntime =
useWorkspaceGeneralWorkbenchScaffoldRuntime({
isGeneralWorkbench: isThemeWorkbench,
mappedTheme,
sessionId,
projectId,
@@ -1790,12 +1887,12 @@ export function AgentChatWorkspace({
setLayoutMode,
});
const {
shouldUseCompactThemeWorkbench,
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
setTopicStatus,
} = themeWorkbenchScaffoldRuntime;
} = generalWorkbenchScaffoldRuntime;
useWorkspaceThemeWorkbenchDocumentPersistenceRuntime({
useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime({
isThemeWorkbench,
contentId,
canvasState,
@@ -1824,14 +1921,14 @@ export function AgentChatWorkspace({
themeWorkbenchActiveQueueItem,
themeWorkbenchBackendRunState,
themeWorkbenchRunState,
} = useWorkspaceThemeWorkbenchRuntime({
} = useWorkspaceGeneralWorkbenchRuntime({
isThemeWorkbench,
sessionId,
isSending,
pendingActionRequest,
});
const themeWorkbenchSidebarRuntime = useWorkspaceThemeWorkbenchSidebarRuntime(
const generalWorkbenchSidebarRuntime = useWorkspaceGeneralWorkbenchSidebarRuntime(
{
isThemeWorkbench,
sessionId,
@@ -1839,7 +1936,7 @@ export function AgentChatWorkspace({
isSending,
themeWorkbenchBackendRunState,
contextActivityLogs: contextWorkspace.activityLogs,
historyPageSize: THEME_WORKBENCH_HISTORY_PAGE_SIZE,
historyPageSize: GENERAL_WORKBENCH_HISTORY_PAGE_SIZE,
},
);
@@ -1857,7 +1954,7 @@ export function AgentChatWorkspace({
task: effectiveChatToolPreferences.task,
subagent: effectiveChatToolPreferences.subagent,
},
sessionMode: isThemeWorkbench ? "theme_workbench" : "default",
sessionMode: isThemeWorkbench ? "general_workbench" : "default",
gateKey: isThemeWorkbench ? currentGate.key : undefined,
runTitle: themeWorkbenchActiveQueueItem?.title?.trim() || undefined,
contentId: contentId || undefined,
@@ -1912,7 +2009,7 @@ export function AgentChatWorkspace({
harnessPendingCount,
});
useWorkspaceThemeWorkbenchVersionStatusRuntime({
useWorkspaceGeneralWorkbenchVersionStatusRuntime({
isThemeWorkbench,
themeWorkbenchRunState,
canvasState,
@@ -1977,25 +2074,24 @@ export function AgentChatWorkspace({
} = useBootstrapDispatchPreview({
initialUserPrompt,
initialUserImages,
browserTaskPreflight,
messagesCount: messages.length,
isSending,
queuedTurnCount: queuedTurns.length,
consumedInitialPromptKey: consumedInitialPromptRef.current,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
});
const {
themeWorkbenchEntryPrompt,
themeWorkbenchEntryCheckPending,
clearThemeWorkbenchEntryPrompt,
dismissThemeWorkbenchEntryPrompt,
} = useThemeWorkbenchEntryPrompt({
generalWorkbenchEntryPrompt,
generalWorkbenchEntryCheckPending,
clearGeneralWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPrompt({
activeTheme,
contentId: contentId ?? undefined,
sessionId: sessionId ?? undefined,
isThemeWorkbench,
autoRunInitialPromptOnMount,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
messagesCount: messages.length,
initialDispatchKey,
initialUserPrompt,
@@ -2020,18 +2116,11 @@ export function AgentChatWorkspace({
hasTriggeredGuide.current = false;
consumedInitialPromptRef.current = null;
}, []);
const prepareBrowserTaskPreflight = useCallback(
(preflight: BrowserTaskPreflight) => {
setBrowserTaskPreflight(preflight);
},
[],
);
const {
resolveSendBoundary,
maybeStartBrowserTaskPreflight,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
} = useThemeWorkbenchSendBoundary({
} = useGeneralWorkbenchSendBoundary({
isThemeWorkbench,
contentId,
initialDispatchKey,
@@ -2039,11 +2128,9 @@ export function AgentChatWorkspace({
initialUserImages,
mappedTheme,
socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY,
isBrowserAssistReady,
onConsumeInitialPrompt: consumeInitialPrompt,
onResetConsumedInitialPrompt: resetConsumedInitialPrompt,
onClearEntryPrompt: clearThemeWorkbenchEntryPrompt,
onPrepareBrowserTaskPreflight: prepareBrowserTaskPreflight,
onClearEntryPrompt: clearGeneralWorkbenchEntryPrompt,
});
const { resetRestoredSessionState } = useWorkspaceSessionRestore({
sessionId,
@@ -2085,7 +2172,6 @@ export function AgentChatWorkspace({
setGeneralCanvasState,
setTaskFiles,
setSelectedFileId,
setBrowserTaskPreflight,
setMentionedCharacters,
setProject,
setProjectMemory,
@@ -2129,11 +2215,16 @@ export function AgentChatWorkspace({
setCanvasState,
});
const submitImageWorkbenchAgentCommandRef =
useRef<
((
params: SubmitImageWorkbenchAgentCommandParams,
) => Promise<boolean>) | null
>(null);
const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({
appendLocalDispatchMessages,
canvasState,
cancelImageTask: cancelMediaTaskArtifact,
contentId,
createFreshSession,
createImageGenerationTask: createImageGenerationTaskArtifact,
getImageTask: getMediaTaskArtifact,
currentImageWorkbenchState,
@@ -2144,10 +2235,12 @@ export function AgentChatWorkspace({
projectId,
projectRootPath: project?.rootPath || null,
saveImageWorkbenchImagesToResource,
submitImageWorkbenchAgentCommand: async (params) =>
(await submitImageWorkbenchAgentCommandRef.current?.(params)) ?? false,
setCanvasState,
setInput,
setLayoutMode,
setMentionedCharacters,
updateImageWorkbenchStateForSession,
updateCurrentImageWorkbenchState,
});
const { handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest } =
@@ -2160,7 +2253,6 @@ export function AgentChatWorkspace({
contentId,
input,
chatToolPreferences: effectiveChatToolPreferences,
serviceSkills: activeTheme === "general" ? serviceSkills : [],
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -2174,6 +2266,7 @@ export function AgentChatWorkspace({
handleRecommendationClick,
handleSendRef,
webSearchPreferenceRef,
isPreparingSend,
submissionPreview,
} = useWorkspaceSendActions({
input,
@@ -2187,7 +2280,7 @@ export function AgentChatWorkspace({
mappedTheme,
isThemeWorkbench,
contextWorkspace: {
enabled: contextWorkspace.enabled,
enabled: contextWorkspace.generalWorkbenchEnabled,
activeContextPrompt: contextWorkspace.activeContextPrompt,
prepareActiveContextPrompt: contextWorkspace.prepareActiveContextPrompt,
},
@@ -2209,7 +2302,6 @@ export function AgentChatWorkspace({
messagesCount: messages.length,
sendMessage,
resolveSendBoundary,
maybeStartBrowserTaskPreflight,
finalizeAfterSendSuccess,
rollbackAfterSendFailure,
prepareRuntimeTeamBeforeSend,
@@ -2217,17 +2309,36 @@ export function AgentChatWorkspace({
ensureBrowserAssistCanvas,
handleAutoLaunchMatchedSiteSkill:
workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill,
handleRuntimeSceneLaunch:
workspaceServiceSkillEntryActions.handleRuntimeSceneLaunch,
handleImageWorkbenchCommand,
ensureSessionForCommandMetadata: ensureSession,
resolveImageWorkbenchSkillRequest,
});
const submitImageWorkbenchAgentCommand = useCallback(
async (params: SubmitImageWorkbenchAgentCommandParams) =>
await handleSendRef.current(
params.images,
webSearchPreferenceRef.current,
effectiveChatToolPreferences.thinking,
params.rawText,
undefined,
undefined,
{
displayContent: params.displayContent,
requestMetadata: buildImageSkillLaunchRequestMetadata(
undefined,
params.requestContext,
),
},
),
[effectiveChatToolPreferences.thinking, handleSendRef, webSearchPreferenceRef],
);
submitImageWorkbenchAgentCommandRef.current =
submitImageWorkbenchAgentCommand;
const {
handleContinueThemeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
} = useThemeWorkbenchEntryPromptActions({
themeWorkbenchEntryPrompt,
handleContinueGeneralWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
} = useGeneralWorkbenchEntryPromptActions({
generalWorkbenchEntryPrompt,
input,
initialDispatchKey,
onContinuePrompt: async (promptToSend) => {
@@ -2238,7 +2349,7 @@ export function AgentChatWorkspace({
promptToSend,
);
},
dismissThemeWorkbenchEntryPrompt,
dismissGeneralWorkbenchEntryPrompt,
onConsumeInitialPrompt: (dispatchKey) => {
consumedInitialPromptRef.current = dispatchKey;
onInitialUserPromptConsumed?.();
@@ -2248,18 +2359,6 @@ export function AgentChatWorkspace({
toast.info("请先补充要继续执行的内容");
},
});
const {
browserAssistEntryLabel,
browserAssistAttentionLevel,
handlePermissionResponseWithBrowserPreflight,
} = useWorkspaceBrowserPreflightRuntime({
browserTaskPreflight,
setBrowserTaskPreflight,
browserAssistLaunching,
isBrowserAssistReady,
ensureBrowserAssistCanvas,
handlePermissionResponse,
});
const {
handleDocumentThinkingEnabledChange,
handleDocumentAutoContinueRun,
@@ -2286,7 +2385,7 @@ export function AgentChatWorkspace({
onRunImageWorkbenchCommand: handleImageWorkbenchCommand,
});
const { handleInputbarA2UISubmit } = useWorkspaceA2UISubmitActions({
handlePermissionResponseWithBrowserPreflight,
handlePermissionResponse,
pendingLegacyQuestionnaireA2UIForm,
pendingPromotedA2UIActionRequest,
resolvePendingA2UISubmit,
@@ -2302,7 +2401,7 @@ export function AgentChatWorkspace({
[handleInputbarA2UISubmit],
);
// 监听主题工作台技能触发
// 监听工作区技能触发
useEffect(() => {
if (!pendingSkillKey || !isThemeWorkbench) {
return;
@@ -2318,7 +2417,6 @@ export function AgentChatWorkspace({
}, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]);
const { displayMessages } = useWorkspaceDisplayMessagesRuntime({
browserTaskPreflight,
bootstrapDispatchPreviewMessages,
isSending,
messages,
@@ -2346,17 +2444,15 @@ export function AgentChatWorkspace({
(agentEntry === "new-task" &&
(hasDisplayMessages ||
isThemeWorkbench ||
(!shouldUseCompactThemeWorkbench && isBootstrapDispatchPending) ||
(!shouldUseCompactGeneralWorkbench && isBootstrapDispatchPending) ||
isSending ||
queuedTurns.length > 0 ||
Boolean(browserTaskPreflight)));
queuedTurns.length > 0));
const shouldRestoreImageTasksFromWorkspace = !(
agentEntry === "new-task" &&
!contentId &&
!hasDisplayMessages &&
!isSending &&
queuedTurns.length === 0 &&
!browserTaskPreflight &&
!isBootstrapDispatchPending
);
@@ -2401,7 +2497,6 @@ export function AgentChatWorkspace({
hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact),
currentCanvasArtifactType: currentCanvasArtifact?.type,
currentImageWorkbenchActive: currentImageWorkbenchState.active,
isBrowserAssistCanvasVisible,
onHasMessagesChange,
dismissActiveTeamWorkbenchAutoOpen,
suppressGeneralCanvasArtifactAutoOpen,
@@ -2438,15 +2533,10 @@ export function AgentChatWorkspace({
}, [activeTheme, liveArtifact, settledLiveArtifact, upsertGeneralArtifact]);
const handleResumeSidebarTask = useCallback(
async (topicId: string, statusReason?: TaskStatusReason) => {
if (topicId === sessionId && isResumableBrowserTaskReason(statusReason)) {
handleOpenBrowserRuntimeForBrowserAssist();
return;
}
async (topicId: string, _statusReason?: TaskStatusReason) => {
await switchTopic(topicId);
},
[handleOpenBrowserRuntimeForBrowserAssist, sessionId, switchTopic],
[switchTopic],
);
const handleWriteFile = useWorkspaceWriteFileAction({
@@ -2576,6 +2666,75 @@ export function AgentChatWorkspace({
},
[handleArtifactClick],
);
const handleOpenMessagePreview = useCallback(
(target: MessagePreviewTarget, message: Message) => {
if (target.kind === "image_workbench") {
updateCurrentImageWorkbenchState((current) => {
if (current.active) {
return current;
}
return {
...current,
active: true,
};
});
setLayoutMode("chat-canvas");
return;
}
if (target.preview.kind === "video_generate") {
const preview = target.preview;
const initialState = createInitialVideoState(preview.prompt);
setCanvasState({
...initialState,
providerId: preview.providerId?.trim() || "",
model: preview.model?.trim() || "",
duration: preview.durationSeconds || initialState.duration,
aspectRatio: normalizeVideoAspectRatio(preview.aspectRatio),
resolution: normalizeVideoResolution(preview.resolution),
status: resolveVideoCanvasStatusFromPreview(target),
videoUrl: preview.videoUrl || undefined,
errorMessage:
preview.status === "failed" || preview.status === "cancelled"
? preview.statusMessage?.trim() || "视频任务未成功完成"
: undefined,
});
setLayoutMode("chat-canvas");
return;
}
const matchedArtifact = resolveTaskPreviewArtifact(message, target);
if (matchedArtifact) {
handleWorkspaceArtifactClick(matchedArtifact);
return;
}
const normalizedArtifactPath = normalizeTaskPreviewArtifactPath(
target.preview.artifactPath || null,
);
if (normalizedArtifactPath) {
const matchedTaskFile = taskFiles.find(
(file) =>
normalizeTaskPreviewArtifactPath(file.name) ===
normalizedArtifactPath,
);
if (matchedTaskFile?.content?.trim()) {
handleWorkspaceFileClick(matchedTaskFile.name, matchedTaskFile.content);
return;
}
}
toast.info("当前任务产物还未同步完成,请稍后再试");
},
[
handleWorkspaceArtifactClick,
handleWorkspaceFileClick,
setCanvasState,
setLayoutMode,
taskFiles,
updateCurrentImageWorkbenchState,
],
);
const handleOpenArtifactFromTimeline = useCallback(
(target: ArtifactTimelineOpenTarget) => {
handleWorkspaceFileClick(target.filePath, target.content);
@@ -2618,10 +2777,11 @@ export function AgentChatWorkspace({
canvasState,
isThemeWorkbench,
mappedTheme,
shouldUseCompactThemeWorkbench,
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
themeWorkbenchEntryCheckPending,
themeWorkbenchEntryPrompt,
shouldUseCompactGeneralWorkbench,
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt:
shouldSkipGeneralWorkbenchAutoGuideWithoutPrompt,
generalWorkbenchEntryCheckPending,
generalWorkbenchEntryPrompt,
chatToolPreferences: effectiveChatToolPreferences,
setInput,
handleSend,
@@ -2656,11 +2816,14 @@ export function AgentChatWorkspace({
setChatMessages,
updateCurrentImageWorkbenchState,
});
useWorkspaceVideoTaskPreviewRuntime({
messages,
setChatMessages,
});
const shellChromeRuntime = useWorkspaceShellChromeRuntime({
agentEntry,
browserTaskPreflight,
contextWorkspaceEnabled: contextWorkspace.enabled,
contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled,
hasDisplayMessages,
hideTopBar,
isBootstrapDispatchPending,
@@ -2669,7 +2832,7 @@ export function AgentChatWorkspace({
isThemeWorkbench,
layoutMode,
queuedTurnCount: queuedTurns.length,
shouldUseCompactThemeWorkbench,
shouldUseCompactGeneralWorkbench,
showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard,
topBarChrome,
themeWorkbenchRunState,
@@ -2677,13 +2840,13 @@ export function AgentChatWorkspace({
hasRealTeamGraph: teamSessionRuntime.hasRealTeamGraph,
teamDispatchPreviewState,
});
const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({
const generalWorkbenchShellRuntime = useWorkspaceGeneralWorkbenchShellRuntime({
showChatPanel: effectiveShowChatPanel,
showSidebar,
hasPendingA2UIForm,
contextHarnessRuntime,
themeWorkbenchScaffoldRuntime,
themeWorkbenchSidebarRuntime,
generalWorkbenchScaffoldRuntime,
generalWorkbenchSidebarRuntime,
harnessInventoryRuntime,
handleCreateVersionSnapshot,
handleSwitchBranchVersion,
@@ -2743,10 +2906,11 @@ export function AgentChatWorkspace({
input,
setInput,
currentGate,
themeWorkbenchSidebarRuntime,
generalWorkbenchSidebarRuntime,
steps,
themeWorkbenchRunState,
workflowRunState: themeWorkbenchRunState,
handleSend,
isPreparingSend,
isSending,
providerType,
setProviderType,
@@ -2789,9 +2953,9 @@ export function AgentChatWorkspace({
removeQueuedTurn,
latestAssistantMessageId,
sessionIdForDiagnostics: sessionId || null,
themeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
handleContinueThemeWorkbenchEntryPrompt,
generalWorkbenchEntryPrompt,
handleRestartGeneralWorkbenchEntryPrompt,
handleContinueGeneralWorkbenchEntryPrompt,
generalWorkbenchEnabled: workspaceHarnessEnabled && chatMode === "general",
contextHarnessRuntime,
harnessState,
@@ -2868,7 +3032,7 @@ export function AgentChatWorkspace({
inputbarScene,
canvasScene,
shellChromeRuntime,
themeWorkbenchShellRuntime,
generalWorkbenchShellRuntime,
contextHarnessRuntime,
teamSessionRuntime,
currentImageWorkbenchState,
@@ -2889,7 +3053,7 @@ export function AgentChatWorkspace({
entryBannerVisible,
entryBannerMessage,
serviceSkillExecutionCard,
contextWorkspaceEnabled: contextWorkspace.enabled,
contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled,
input,
setInput,
providerType,
@@ -2929,13 +3093,11 @@ export function AgentChatWorkspace({
handleBackHome,
handleToggleSidebar,
chatMode,
isBrowserAssistCanvasVisible,
browserAssistAttentionLevel,
browserAssistEntryLabel,
showHarnessToggle,
navbarHarnessPanelVisible,
harnessPendingCount,
harnessAttentionLevel,
isAutoRestoringSession,
sessionId,
syncStatus,
pendingA2UIForm,
@@ -2957,6 +3119,7 @@ export function AgentChatWorkspace({
pendingActions,
submittedActionsInFlight,
queuedTurns,
isPreparingSend,
isSending,
stopSending,
resumeThread,
@@ -2970,8 +3133,9 @@ export function AgentChatWorkspace({
handleOpenArtifactFromTimeline,
handleOpenSavedSiteContent,
handleArtifactClick: handleWorkspaceArtifactClick,
handleOpenMessagePreview,
handleOpenSubagentSession,
handlePermissionResponseWithBrowserPreflight,
handlePermissionResponse,
pendingPromotedA2UIActionRequest,
shouldCollapseCodeBlocks,
shouldCollapseCodeBlockInChat,
@@ -586,13 +586,13 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).toContain("Mac mini 最新价格");
});
it("浏览器前置等待时应显示轻量待继续提示", () => {
it("存在待处理请求时应显示轻量待处理提示", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
...createBaseItem("action-1", 1),
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://mp.weixin.qq.com" },
tool_name: "write_file",
arguments: { path: "publish.md" },
},
];
@@ -602,13 +602,11 @@ describe("AgentThreadTimeline", () => {
},
actionRequests: [
{
requestId: "req-browser",
requestId: "req-title",
actionType: "ask_user",
status: "pending",
uiKind: "browser_preflight",
browserPrepState: "awaiting_user",
prompt: "请先在浏览器完成登录。",
detail: "浏览器已经打开,请先完成登录、扫码或验证码后继续。",
prompt: "请先确认文章标题。",
questions: [{ question: "这篇文章的最终标题是什么?" }],
},
],
});
@@ -616,8 +614,8 @@ describe("AgentThreadTimeline", () => {
expect(
container.querySelector('[data-testid="agent-thread-inline-status"]')
?.textContent,
).toContain("待继续");
expect(container.textContent).toContain("完成登录");
).toContain("待处理");
expect(container.textContent).toContain("确认文章标题");
expect(container.textContent).not.toContain("已中断");
});
@@ -604,37 +604,6 @@ function isThinkingTimelineItem(
);
}
function normalizeToolMarker(value: string | undefined): string {
return (value || "").replace(/[\s_-]+/g, "").trim().toLowerCase();
}
function isBrowserTimelineItem(item: AgentThreadItem): boolean {
if (item.type !== "tool_call") {
return false;
}
const normalized = normalizeToolMarker(item.tool_name);
return [
"browser",
"page",
"runtime",
"dom",
"cdp",
"playwright",
"navigate",
"screenshot",
"snapshot",
"click",
"hover",
"presskey",
"type",
"selectoption",
"drag",
"evaluate",
"goto",
].some((marker) => normalized.includes(marker));
}
function isToolExecutionTimelineItem(item: AgentThreadItem): boolean {
return (
item.type === "tool_call" ||
@@ -998,16 +967,6 @@ function resolveFocusBlockIndex(params: {
const pendingAction = findLatestPendingAction(actionRequests);
if (pendingAction?.uiKind === "browser_preflight") {
const browserIndex = findLastBlockIndex(
blocks,
(block) => block.items.some((item) => isBrowserTimelineItem(item)),
);
if (browserIndex >= 0) {
return browserIndex;
}
}
if (pendingAction) {
const pendingIndex = findLastBlockIndex(
blocks,
@@ -1211,17 +1170,6 @@ function resolveThreadInlineStatusHint(params: {
}) {
const pendingAction = findLatestPendingAction(params.actionRequests);
if (pendingAction?.uiKind === "browser_preflight") {
return {
tone: "warning" as const,
label: "待继续",
detail:
pendingAction.detail?.trim() ||
pendingAction.prompt?.trim() ||
"浏览器已经打开,等待你完成登录、授权或验证后继续。",
};
}
if (pendingAction) {
return {
tone: "warning" as const,
@@ -188,87 +188,35 @@ describe("ChatNavbar", () => {
expect(container.querySelector('[data-testid="harness-panel"]')).toBeNull();
});
it("通用对话应支持从顶栏打开浏览器协助", () => {
const onOpenBrowserAssist = vi.fn();
it("Harness 告警态应使用强调样式", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
onOpenBrowserAssist,
showHarnessToggle: true,
harnessAttentionLevel: "warning",
harnessToggleLabel: "执行提醒",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
'button[aria-label="展开执行提醒"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("浏览器工作台");
act(() => {
button?.click();
});
expect(onOpenBrowserAssist).toHaveBeenCalledTimes(1);
});
it("应支持显示浏览器协助状态文案", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistLabel: "等待登录",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button?.textContent).toContain("等待登录");
});
it("浏览器已就绪时不应再占用顶栏状态入口", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistLabel: "浏览器已就绪",
});
const button = container.querySelector(
'button[aria-label="打开浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button).toBeNull();
expect(container.textContent).not.toContain("浏览器已就绪");
});
it("浏览器待继续时顶栏按钮应显示恢复态语义", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistAttentionLevel: "warning",
browserAssistLabel: "等待登录",
});
const button = container.querySelector(
'button[aria-label="恢复浏览器工作台"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("等待登录");
expect(button?.className).toContain("border-amber-300");
expect(button?.className).toContain("text-amber-800");
});
it("浏览器启动中时顶栏按钮应显示启动态语义", () => {
it("压缩上下文运行中时应禁用顶栏操作", () => {
const container = renderChatNavbar({
showBrowserAssistEntry: true,
browserAssistAttentionLevel: "info",
browserAssistLoading: true,
showContextCompactionAction: true,
contextCompactionRunning: true,
});
const button = container.querySelector(
'button[aria-label="查看浏览器工作台状态"]',
'button[aria-label="压缩上下文"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.disabled).toBe(true);
expect(button?.textContent).toContain("启动中...");
expect(button?.className).toContain("border-sky-300");
expect(button?.className).toContain("text-sky-800");
expect(button?.textContent).toContain("压缩中...");
});
it("通用对话项目选择器应启用管理能力", () => {
@@ -3,7 +3,6 @@ import {
Box,
ChevronDown,
FolderOpen,
Globe,
Home,
PanelRightClose,
PanelRightOpen,
@@ -40,26 +39,6 @@ interface ChatNavbarProps {
showContextCompactionAction?: boolean;
contextCompactionRunning?: boolean;
onCompactContext?: () => void;
showBrowserAssistEntry?: boolean;
browserAssistActive?: boolean;
browserAssistLoading?: boolean;
browserAssistAttentionLevel?: "idle" | "info" | "warning";
browserAssistLabel?: string;
onOpenBrowserAssist?: () => void;
}
function resolveBrowserAssistTitle(
attentionLevel: NonNullable<ChatNavbarProps["browserAssistAttentionLevel"]>,
): string {
if (attentionLevel === "warning") {
return "恢复浏览器工作台";
}
if (attentionLevel === "info") {
return "查看浏览器工作台状态";
}
return "打开浏览器工作台";
}
const toolbarGroupClassName =
@@ -102,17 +81,8 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
showContextCompactionAction = false,
contextCompactionRunning = false,
onCompactContext,
showBrowserAssistEntry = false,
browserAssistActive = false,
browserAssistLoading = false,
browserAssistAttentionLevel = "idle",
browserAssistLabel,
onOpenBrowserAssist,
}) => {
const isWorkspaceCompact = chrome === "workspace-compact";
const browserAssistTitle = resolveBrowserAssistTitle(
browserAssistAttentionLevel,
);
const groupClassName = cn(
toolbarGroupClassName,
isWorkspaceCompact && "rounded-[18px] p-1",
@@ -129,15 +99,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
toolbarGhostIconButtonClassName,
isWorkspaceCompact && "h-8 w-8 rounded-[18px]",
);
const shouldShowBrowserAssistEntry =
showBrowserAssistEntry &&
(browserAssistLoading ||
browserAssistAttentionLevel !== "idle" ||
browserAssistLabel?.trim() !== "浏览器已就绪");
const showStatusTools =
shouldShowBrowserAssistEntry ||
showHarnessToggle ||
showContextCompactionAction;
const showStatusTools = showHarnessToggle || showContextCompactionAction;
const showNavigationTools =
!isWorkspaceCompact &&
(Boolean(onBackHome) ||
@@ -298,51 +260,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
) : null}
{showContextCompactionAction &&
(shouldShowBrowserAssistEntry || showHarnessToggle) ? (
<div className={dividerClassName} aria-hidden="true" />
) : null}
{shouldShowBrowserAssistEntry ? (
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
embeddedButtonClassName,
toolbarTextButtonClassName,
browserAssistActive && "bg-slate-100 text-slate-900",
browserAssistAttentionLevel === "warning" &&
"border-amber-300 bg-amber-50/80 text-amber-800 hover:bg-amber-100 hover:text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/15 dark:hover:text-amber-100",
browserAssistAttentionLevel === "info" &&
"border-sky-300 bg-sky-50/80 text-sky-800 hover:bg-sky-100 hover:text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-200 dark:hover:bg-sky-500/15 dark:hover:text-sky-100",
)}
onClick={onOpenBrowserAssist}
disabled={browserAssistLoading}
aria-label={browserAssistTitle}
title={browserAssistTitle}
>
<Globe size={14} />
{browserAssistAttentionLevel !== "idle" ? (
<span
aria-hidden="true"
className={cn(
"h-2 w-2 rounded-full shadow-sm shadow-slate-950/10",
browserAssistAttentionLevel === "warning"
? "bg-amber-500"
: "bg-sky-500",
)}
/>
) : null}
<span>
{browserAssistLoading
? "启动中..."
: browserAssistLabel?.trim() || "浏览器工作台"}
</span>
</Button>
) : null}
{shouldShowBrowserAssistEntry && showHarnessToggle ? (
{showContextCompactionAction && showHarnessToggle ? (
<div className={dividerClassName} aria-hidden="true" />
) : null}
@@ -99,24 +99,21 @@ function renderSidebar(
}
describe("ChatSidebar", () => {
function createBrowserPreflightMessage(
phase: "launching" | "awaiting_user" | "failed",
detail: string,
function createPendingActionMessage(
prompt: string,
question = "请补充需要继续执行的信息。",
): Message {
return {
id: `msg-preflight-${phase}`,
id: "msg-pending-action",
role: "assistant",
content: "",
timestamp: new Date("2026-03-15T09:45:00.001Z"),
actionRequests: [
{
requestId: `browser-preflight-${phase}`,
requestId: "req-user-action",
actionType: "ask_user",
uiKind: "browser_preflight",
browserRequirement: "required_with_user_step",
browserPrepState: phase,
prompt: "该任务需要真实浏览器执行,不能仅靠网页检索完成。",
detail,
prompt,
questions: [{ question }],
},
],
};
@@ -278,26 +275,26 @@ describe("ChatSidebar", () => {
expect(container.textContent).toContain("进行中");
});
it("浏览器未就绪时应显示可操作的失败前置态", () => {
it("当前任务存在待处理请求时应覆盖失败态并显示待处理摘要", () => {
const now = new Date("2026-03-15T09:45:00.000Z");
const topics: Topic[] = [
{
...defaultTopics[0],
status: "failed",
lastPreview: "执行失败:browser_connect",
lastPreview: "执行失败:write_file",
},
];
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我整理一篇公众号发布文案",
timestamp: now,
},
{
...createBrowserPreflightMessage(
"failed",
"还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。",
...createPendingActionMessage(
"请先确认发布标题后继续执行。",
"这篇文章的最终标题是什么?",
),
timestamp: new Date(now.getTime() + 1),
},
@@ -311,22 +308,22 @@ describe("ChatSidebar", () => {
pendingActionCount: 0,
});
expect(container.textContent).toContain("浏览器未就绪");
expect(container.textContent).toContain("浏览器会话");
expect(container.textContent).toContain("待处理");
expect(container.textContent).toContain("确认发布标题");
expect(container.textContent).not.toContain("执行失败");
});
it("等待用户在浏览器完成登录时应显示待继续", () => {
it("等待用户补充信息时应显示待处理提示", () => {
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我写一篇活动预热文案",
timestamp: new Date("2026-03-15T09:45:00.000Z"),
},
createBrowserPreflightMessage(
"awaiting_user",
"已为你打开浏览器。请先完成登录、扫码或验证码,然后继续当前任务。",
createPendingActionMessage(
"请先补充活动标题后继续。",
"这次活动的正式标题是什么?",
),
];
@@ -343,48 +340,17 @@ describe("ChatSidebar", () => {
pendingActionCount: 0,
});
expect(container.textContent).toContain("待继续");
expect(container.textContent).toContain("完成登录");
expect(container.textContent).toContain("待处理");
expect(container.textContent).toContain("补充活动标题");
});
it("浏览器启动中时应显示连接浏览器", () => {
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
timestamp: new Date("2026-03-15T09:45:00.000Z"),
},
createBrowserPreflightMessage(
"launching",
"正在尝试建立浏览器会话,请稍候...",
),
];
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
status: "waiting",
},
],
currentMessages,
currentTopicId: "topic-1",
isSending: false,
pendingActionCount: 0,
});
expect(container.textContent).toContain("连接浏览器");
expect(container.textContent).toContain("正在建立浏览器会话");
});
it("浏览器介入任务应单独归入待继续分组", () => {
it("待处理任务应统一归入待处理分组", () => {
const topics: Topic[] = [
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
statusReason: "user_action",
lastPreview: "请先补充文章标题。",
},
{
...defaultTopics[0],
@@ -403,24 +369,23 @@ describe("ChatSidebar", () => {
currentMessages: [],
});
expect(container.textContent).toContain("待继续1");
expect(container.textContent).toContain("待处理1");
expect(container.textContent).toContain("待处理2");
expect(container.textContent).toContain("任务一");
expect(container.textContent).toContain("任务二");
});
it("当前待继续任务应在顶部提供打开浏览器入口", () => {
it("当前待处理任务应提供继续任务入口", () => {
const onResumeTask = vi.fn();
const currentMessages: Message[] = [
{
id: "msg-user",
role: "user",
content: "帮我把文章发布到微信公众号",
content: "帮我把文章整理成周报",
timestamp: new Date("2026-03-15T09:45:00.000Z"),
},
createBrowserPreflightMessage(
"awaiting_user",
"已为你打开浏览器。请先完成登录、扫码或验证码,然后继续当前任务。",
createPendingActionMessage(
"请先补充周报标题后继续。",
"本周周报的标题是什么?",
),
];
const container = renderSidebar({
@@ -429,35 +394,31 @@ describe("ChatSidebar", () => {
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
statusReason: "user_action",
lastPreview: "请先补充周报标题后继续。",
},
],
currentTopicId: "topic-1",
currentMessages,
});
expect(container.textContent).toContain("当前任务待继续");
expect(container.textContent).toContain("打开浏览器");
expect(container.textContent).toContain("继续任务");
const openBrowserButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("打开浏览器"),
const resumeButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("继续任务"),
);
expect(openBrowserButton).toBeTruthy();
expect(resumeButton).toBeTruthy();
act(() => {
openBrowserButton?.dispatchEvent(
resumeButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(onResumeTask).toHaveBeenCalledWith(
"topic-1",
"browser_awaiting_user",
);
expect(onResumeTask).toHaveBeenCalledWith("topic-1", "user_action");
});
it("非当前待继续任务应提供进入任务动作", () => {
it("非当前待处理任务也应提供继续任务动作", () => {
const onResumeTask = vi.fn();
const container = renderSidebar({
onResumeTask,
@@ -465,65 +426,25 @@ describe("ChatSidebar", () => {
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
statusReason: "user_action",
lastPreview: "请先补充周报标题后继续。",
},
],
currentTopicId: null,
});
expect(container.textContent).toContain("有 1 个任务待继续");
expect(container.textContent).toContain("进入任务");
expect(container.textContent).toContain("继续任务");
const enterTaskButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("进入任务"),
const resumeButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("继续任务"),
);
expect(enterTaskButton).toBeTruthy();
expect(resumeButton).toBeTruthy();
act(() => {
enterTaskButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
resumeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onResumeTask).toHaveBeenCalledWith(
"topic-1",
"browser_awaiting_user",
);
});
it("可按待继续筛选任务", () => {
const container = renderSidebar({
topics: [
{
...defaultTopics[0],
status: "waiting",
statusReason: "browser_awaiting_user",
lastPreview: "请先在浏览器完成登录后继续。",
},
{
...defaultTopics[0],
id: "topic-2",
title: "任务二",
sourceSessionId: "topic-2",
status: "done",
lastPreview: "已完成。",
},
],
currentTopicId: null,
});
const resumableFilterButton = Array.from(
container.querySelectorAll("button"),
).find((element) => element.textContent?.includes("待继续 1"));
expect(resumableFilterButton).toBeTruthy();
act(() => {
resumableFilterButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(container.textContent).toContain("任务一");
expect(container.textContent).not.toContain("任务二");
expect(onResumeTask).toHaveBeenCalledWith("topic-1", "user_action");
});
it("父线程应在侧栏展示真实子代理并支持打开", () => {
@@ -5,7 +5,6 @@ import {
ChevronDown,
Clock3,
GitBranch,
Globe,
ListTodo,
Loader2,
MoreHorizontal,
@@ -87,7 +86,6 @@ const STATUS_META: Record<
type TaskSectionKey =
| "running"
| "resumable"
| "waiting"
| "recent"
| "older";
@@ -134,14 +132,6 @@ interface ChatSidebarProps {
onReturnToParentSession?: () => void | Promise<void>;
}
function isResumableStatusReason(statusReason?: TaskStatusReason) {
return (
statusReason === "browser_launching" ||
statusReason === "browser_awaiting_user" ||
statusReason === "browser_failed"
);
}
function formatRelativeTime(date: Date) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60)));
@@ -222,15 +212,6 @@ function resolveCurrentStatusPreview(
if (status === "running") {
return "正在生成回复或执行工具,请稍候。";
}
if (status === "waiting" && statusReason === "browser_launching") {
return "正在建立浏览器会话,请稍候。";
}
if (status === "waiting" && statusReason === "browser_awaiting_user") {
return fallbackPreview || "请先在浏览器完成登录或授权后继续。";
}
if (status === "waiting" && statusReason === "browser_failed") {
return fallbackPreview || "浏览器/CDP 还未连接,请重试启动后继续。";
}
if (status === "waiting" && pendingActionCount > 0) {
return "等待你确认或补充信息后继续执行。";
}
@@ -244,18 +225,6 @@ function resolveStatusLabel(
status: TaskStatus,
statusReason?: TaskStatusReason,
): string {
if (status === "waiting" && statusReason === "browser_launching") {
return "连接浏览器";
}
if (status === "waiting" && statusReason === "browser_awaiting_user") {
return "待继续";
}
if (status === "waiting" && statusReason === "browser_failed") {
return "浏览器未就绪";
}
if (status === "failed" && statusReason === "workspace_error") {
return "工作区异常";
}
@@ -263,26 +232,6 @@ function resolveStatusLabel(
return STATUS_META[status].label;
}
function resolveResumableActionLabel(
statusReason?: TaskStatusReason,
isCurrent = false,
): string {
if (!isCurrent) {
return "进入任务";
}
switch (statusReason) {
case "browser_launching":
return "查看浏览器";
case "browser_awaiting_user":
return "打开浏览器";
case "browser_failed":
return "重试浏览器";
default:
return "继续处理";
}
}
function resolveTaskStatus(params: {
topic: Topic;
currentTopicId: string | null;
@@ -321,7 +270,6 @@ function resolveTaskStatus(params: {
function buildTaskSections(items: TaskCardViewModel[]) {
const now = Date.now();
const running: TaskCardViewModel[] = [];
const resumable: TaskCardViewModel[] = [];
const waiting: TaskCardViewModel[] = [];
const recent: TaskCardViewModel[] = [];
const older: TaskCardViewModel[] = [];
@@ -332,14 +280,6 @@ function buildTaskSections(items: TaskCardViewModel[]) {
continue;
}
if (
item.status === "waiting" &&
isResumableStatusReason(item.statusReason)
) {
resumable.push(item);
continue;
}
if (
item.status === "waiting" ||
item.status === "draft" ||
@@ -359,7 +299,6 @@ function buildTaskSections(items: TaskCardViewModel[]) {
return [
{ key: "running", title: "进行中", items: sortTaskItems(running) },
{ key: "resumable", title: "待继续", items: sortTaskItems(resumable) },
{ key: "waiting", title: "待处理", items: sortTaskItems(waiting) },
{ key: "recent", title: "最近完成", items: sortTaskItems(recent) },
{ key: "older", title: "更早任务", items: sortTaskItems(older) },
@@ -488,9 +427,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<
"all" | "active" | "resumable"
>("all");
const [statusFilter, setStatusFilter] = useState<"all" | "active">("all");
const [showAllOlder, setShowAllOlder] = useState(false);
const [pinnedTaskIds, setPinnedTaskIds] = useState<string[]>(() =>
loadPinnedTaskIds(),
@@ -499,7 +436,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
Record<TaskSectionKey, boolean>
>({
running: false,
resumable: false,
waiting: false,
recent: false,
older: false,
@@ -647,16 +583,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
const filteredTaskItems = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase();
return taskItems.filter((item) => {
if (
statusFilter === "resumable" &&
!(
item.status === "waiting" &&
isResumableStatusReason(item.statusReason)
)
) {
return false;
}
if (
statusFilter === "active" &&
item.status !== "running" &&
@@ -679,32 +605,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
() => buildTaskSections(filteredTaskItems),
[filteredTaskItems],
);
const resumableTaskCount = useMemo(
() =>
taskItems.filter(
(item) =>
item.status === "waiting" &&
isResumableStatusReason(item.statusReason),
).length,
[taskItems],
);
const resumableItems = useMemo(
() =>
filteredTaskItems.filter(
(item) =>
item.status === "waiting" &&
isResumableStatusReason(item.statusReason),
),
[filteredTaskItems],
);
const primaryResumableItem = useMemo(() => {
if (resumableItems.length === 0) {
return null;
}
return resumableItems.find((item) => item.isCurrent) || resumableItems[0];
}, [resumableItems]);
const hasAnyTasks = topics.length > 0;
const hasFilteredResults = filteredTaskItems.length > 0;
@@ -891,59 +791,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
新建任务
</button>
{primaryResumableItem ? (
<div className="rounded-[24px] border border-amber-200/80 bg-[linear-gradient(180deg,rgba(255,251,235,0.96)_0%,rgba(255,255,255,0.94)_100%)] px-3.5 py-3.5 shadow-sm shadow-amber-950/5 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-200">
<Globe className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{primaryResumableItem.isCurrent
? "当前任务待继续"
: `有 ${resumableItems.length} 个任务待继续`}
</div>
<span className="rounded-full bg-white/80 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-white/10 dark:text-amber-200">
{primaryResumableItem.statusLabel}
</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-amber-800/90 dark:text-amber-200/90">
{primaryResumableItem.isCurrent
? "当前任务卡在浏览器环节,先恢复浏览器会话再继续执行后续动作。"
: `优先恢复“${primaryResumableItem.title}”,避免关键浏览器步骤继续堆积。`}
</p>
<div className="mt-2 line-clamp-2 text-xs leading-5 text-amber-800/85 dark:text-amber-200/85">
{primaryResumableItem.lastPreview}
</div>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-xl bg-amber-600 px-3 text-xs font-semibold text-white transition hover:bg-amber-700"
onClick={() => handleResumeTask(primaryResumableItem)}
>
{resolveResumableActionLabel(
primaryResumableItem.statusReason,
primaryResumableItem.isCurrent,
)}
</button>
{!primaryResumableItem.isCurrent ? (
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-xl border border-amber-200 bg-white/80 px-3 text-xs font-medium text-amber-800 transition hover:border-amber-300 hover:bg-white dark:border-amber-500/20 dark:bg-white/5 dark:text-amber-200 dark:hover:bg-white/10"
onClick={() => {
void onSwitchTopic(primaryResumableItem.id);
}}
>
查看任务
</button>
) : null}
</div>
</div>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 rounded-[22px] border border-white/85 bg-white/72 p-2 shadow-sm shadow-slate-950/5">
<button
type="button"
@@ -969,20 +816,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
>
仅看进行中
</button>
{resumableTaskCount > 0 ? (
<button
type="button"
onClick={() => setStatusFilter("resumable")}
className={cn(
"inline-flex h-9 items-center justify-center rounded-2xl border px-3 text-xs font-medium transition",
statusFilter === "resumable"
? "border-amber-500 bg-amber-500 text-white dark:border-amber-400 dark:bg-amber-400 dark:text-slate-900"
: "border-amber-200/80 bg-amber-50/90 text-amber-700 hover:border-amber-300 hover:bg-amber-100 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200",
)}
>
待继续 {resumableTaskCount}
</button>
) : null}
</div>
</div>
@@ -1229,10 +1062,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<div className="space-y-3">
{sections.map((section) => {
const isOlderSection = section.key === "older";
const isResumableSection = section.key === "resumable";
const isSectionCollapsed = isResumableSection
? false
: collapsedSections[section.key];
const isSectionCollapsed = collapsedSections[section.key];
const visibleItems =
isOlderSection && !showAllOlder
? section.items.slice(0, OLDER_TASKS_INITIAL_COUNT)
@@ -1244,57 +1074,40 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
return (
<section key={section.key} className="space-y-2">
{isResumableSection ? (
<div className="rounded-[20px] border border-amber-200/80 bg-amber-50/80 px-3 py-3 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-amber-800 dark:text-amber-200">
{section.title}
</span>
</div>
<span className="text-[11px] text-amber-700/80 dark:text-amber-300/80">
{section.items.length}
</span>
</div>
<p className="mt-1 text-[11px] leading-5 text-amber-700/90 dark:text-amber-300/90">
这些任务需要你先在浏览器完成登录、授权或恢复连接,再继续执行。
</p>
</div>
) : (
<button
type="button"
onClick={() =>
setCollapsedSections((prev) => ({
...prev,
[section.key]: !prev[section.key],
}))
}
className="flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-left transition hover:bg-white/78 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-4 w-4 text-slate-400 transition-transform",
isSectionCollapsed ? "-rotate-90" : "",
)}
/>
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{section.title}
</span>
</div>
<span className="text-[11px] text-slate-400">
{section.items.length}
<button
type="button"
onClick={() =>
setCollapsedSections((prev) => ({
...prev,
[section.key]: !prev[section.key],
}))
}
className="flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-left transition hover:bg-white/78 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-4 w-4 text-slate-400 transition-transform",
isSectionCollapsed ? "-rotate-90" : "",
)}
/>
<span className="text-xs font-semibold text-slate-700 dark:text-slate-200">
{section.title}
</span>
</button>
)}
</div>
<span className="text-[11px] text-slate-400">
{section.items.length}
</span>
</button>
{isSectionCollapsed ? null : (
<div className="space-y-2">
{visibleItems.map((item) => {
const statusMeta = STATUS_META[item.status];
const isResumableItem = isResumableStatusReason(
item.statusReason,
);
const isResumableItem =
item.status === "waiting" ||
(item.status === "failed" &&
item.statusReason === "workspace_error");
return (
<div
@@ -1469,19 +1282,16 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
? `${item.messagesCount} 条消息`
: "尚未开始执行"}
</span>
{isResumableItem ? (
{isResumableItem && onResumeTask ? (
<button
type="button"
className="ml-auto inline-flex h-7 items-center justify-center rounded-lg bg-amber-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-amber-700"
onClick={(event) => {
event.stopPropagation();
handleResumeTask(item);
}}
className="inline-flex items-center rounded-full border border-amber-200/80 bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700 transition hover:border-amber-300 hover:bg-amber-100 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200 dark:hover:bg-amber-500/15"
>
{resolveResumableActionLabel(
item.statusReason,
item.isCurrent,
)}
继续任务
</button>
) : null}
</div>
@@ -178,22 +178,6 @@ function createSubmittedAskUserRequest(requestId: string): ActionRequired {
};
}
function createBrowserPreflightRequest(
requestId: string,
phase: ActionRequired["browserPrepState"] = "failed",
): ActionRequired {
return {
requestId,
actionType: "ask_user",
uiKind: "browser_preflight",
browserRequirement: "required_with_user_step",
browserPrepState: phase,
prompt:
"该任务需要真实浏览器执行,不能仅靠网页检索完成。请先准备右侧浏览器会话。",
detail: "请在右侧浏览器中完成登录、扫码或验证码,然后继续当前任务。",
};
}
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
@@ -489,50 +473,3 @@ describe("DecisionPanel copywriting", () => {
});
});
});
describe("DecisionPanel browser preflight", () => {
it("浏览器未就绪时应支持重试打开浏览器", () => {
const request = createBrowserPreflightRequest("req-browser-preflight");
const { container, onSubmit } = renderDecisionPanel(request);
expect(container.textContent).toContain("浏览器未就绪");
expect(container.textContent).toContain("必须浏览器执行");
clickButton(findButtonByText(container, "重试打开浏览器"));
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-browser-preflight",
confirmed: true,
response: "重新打开浏览器",
actionType: "ask_user",
userData: {
answer: "重新打开浏览器",
browserAction: "launch",
},
});
});
it("等待用户完成登录时不应再提供继续执行确认", () => {
const request = createBrowserPreflightRequest(
"req-browser-awaiting",
"awaiting_user",
);
const { container, onSubmit } = renderDecisionPanel(request);
expect(container.textContent).toContain("请先完成浏览器准备");
expect(container.textContent).not.toContain("我已完成登录,继续执行");
clickButton(findButtonByText(container, "重新打开浏览器"));
expect(onSubmit).toHaveBeenCalledWith({
requestId: "req-browser-awaiting",
confirmed: true,
response: "重新打开浏览器",
actionType: "ask_user",
userData: {
answer: "重新打开浏览器",
browserAction: "launch",
},
});
});
});
@@ -46,10 +46,6 @@ function isPromiseLike(value: unknown): value is Promise<unknown> {
);
}
function isBrowserPreflightRequest(request: ActionRequired): boolean {
return request.uiKind === "browser_preflight";
}
/** 获取工具图标 */
function getToolIcon(toolName?: string) {
if (!toolName) return <HelpCircle className="h-4 w-4" />;
@@ -614,161 +610,6 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
);
}
if (isBrowserPreflightRequest(request)) {
const phase = request.browserPrepState || "idle";
const isLaunching = phase === "launching";
const isAwaitingUser =
phase === "awaiting_user" || phase === "ready_to_resume";
const isFailed = phase === "failed";
const allowsFallback = request.allowCapabilityFallback === true;
const requirementLabel =
request.browserRequirement === "required_with_user_step"
? "必须浏览器执行 · 需要你完成登录/授权"
: request.browserRequirement === "required"
? "必须浏览器执行"
: "优先浏览器执行";
const detailText =
request.detail ||
(request.browserRequirement === "required_with_user_step"
? "请先在浏览器工作台完成登录、扫码、验证码或授权,然后回到原入口重新发起任务。"
: "请先让浏览器工作台中的页面处于可操作状态,然后回到原入口重新发起任务。");
const title = isLaunching
? "正在准备浏览器执行环境"
: isAwaitingUser
? "请先完成浏览器准备"
: isFailed
? "浏览器未就绪"
: "此任务需要先准备浏览器";
const handleBrowserAction = (
browserAction: "launch" | "continue" | "fallback",
response: string,
) => {
void submitResponse(
{
requestId: request.requestId,
confirmed: true,
response,
actionType: request.actionType,
userData: {
answer: response,
browserAction,
},
},
{ key: `browser:${browserAction}:${response}`, kind: "allow" },
);
};
return (
<Card
className="border-amber-200 bg-amber-50/60 dark:border-amber-800 dark:bg-amber-950/20"
{...requestAnchorProps}
>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium text-amber-800 dark:text-amber-200">
{isLaunching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isFailed ? (
<AlertTriangle className="h-4 w-4" />
) : (
<Globe className="h-4 w-4" />
)}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Badge variant="secondary" className="bg-amber-100 text-amber-900">
{requirementLabel}
</Badge>
{request.prompt ? (
<p className="text-sm text-foreground whitespace-pre-wrap">
{request.prompt}
</p>
) : null}
<div className="rounded-lg border border-amber-200/80 bg-background/80 px-3 py-2 text-sm text-muted-foreground dark:border-amber-900/60">
{detailText}
</div>
<div className="flex flex-wrap gap-2 pt-1">
{isLaunching ? (
<Button
size="sm"
disabled
className="bg-amber-600 hover:bg-amber-600"
>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
启动中...
</Button>
) : (
<Button
size="sm"
onClick={() =>
handleBrowserAction(
"launch",
isFailed ? "重新打开浏览器" : "打开浏览器工作台",
)
}
className="bg-amber-600 hover:bg-amber-700"
disabled={isSubmitting}
>
{submissionState?.key ===
`browser:launch:${isFailed ? "重新打开浏览器" : "打开浏览器工作台"}` ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Globe className="mr-1 h-4 w-4" />
)}
{submissionState?.key ===
`browser:launch:${isFailed ? "重新打开浏览器" : "打开浏览器工作台"}`
? "启动中..."
: isFailed
? "重试打开浏览器"
: "打开浏览器工作台"}
</Button>
)}
{isAwaitingUser ? (
<Button
size="sm"
variant="outline"
onClick={() => handleBrowserAction("launch", "重新打开浏览器")}
disabled={isSubmitting}
>
{submissionState?.key === "browser:launch:重新打开浏览器" ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Globe className="mr-1 h-4 w-4" />
)}
{submissionState?.key === "browser:launch:重新打开浏览器"
? "处理中..."
: "重新打开浏览器"}
</Button>
) : null}
{allowsFallback ? (
<Button
size="sm"
variant="outline"
onClick={() =>
handleBrowserAction("fallback", "改为仅做网页检索")
}
disabled={isSubmitting}
>
{submissionState?.key ===
"browser:fallback:改为仅做网页检索" ? (
<>
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
处理中...
</>
) : (
"改为仅做网页检索"
)}
</Button>
) : null}
</div>
</CardContent>
</Card>
);
}
// 渲染 elicitation 面板
if (request.actionType === "elicitation" && !usesQuestionnaireUi) {
return (
@@ -91,6 +91,8 @@ interface EmptyStateProps extends SkillSelectionSourceProps {
executionStrategy?: "react" | "code_orchestrated" | "auto",
images?: MessageImage[],
) => void;
isLoading?: boolean;
disabled?: boolean;
/** 创作模式 */
creationMode?: CreationMode;
/** 创作模式变更回调 */
@@ -219,6 +221,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
projectId = null,
onProjectChange,
onOpenSettings,
isLoading = false,
disabled = false,
}) => {
const { wrapTextWithSkill, buildSkillSelection } = useActiveSkill();
const skillSelection = buildSkillSelection({
@@ -292,6 +296,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
const isGeneralTheme = isGeneralResearchTheme(activeTheme);
const isComposerBusy = isLoading || disabled;
const wrapTextWithDefaultSkill = (text: string) => {
const wrappedByActiveSkill = wrapTextWithSkill(text);
@@ -391,7 +396,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
};
const handleSend = () => {
if (!input.trim() && pendingImages.length === 0) return;
if (isComposerBusy || (!input.trim() && pendingImages.length === 0)) {
return;
}
const imagesToSend = pendingImages.length > 0 ? pendingImages : undefined;
onSend(
@@ -733,6 +740,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
accessMode={accessMode}
setAccessMode={setAccessMode}
onManageProviders={onManageProviders}
isLoading={isComposerBusy}
disabled={isComposerBusy}
isGeneralTheme={isGeneralTheme}
characters={characters}
skillSelection={skillSelection}
@@ -303,6 +303,34 @@ describe("EmptyStateComposerPanel", () => {
expect(onPaste).toHaveBeenCalledTimes(1);
});
it("发送准备中应禁用首页发送入口并展示忙碌态", () => {
const onSend = vi.fn();
const container = renderPanel({
input: "请帮我梳理首页首次发送链路",
isLoading: true,
disabled: true,
onSend,
});
const textarea = container.querySelector("textarea") as
| HTMLTextAreaElement
| null;
const pendingButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("稍后处理"),
) as HTMLButtonElement | undefined;
expect(textarea?.disabled).toBe(true);
expect(pendingButton).toBeTruthy();
expect(pendingButton?.disabled).toBe(true);
expect(container.querySelector('button[title="发送"]')).toBeNull();
act(() => {
pendingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSend).not.toHaveBeenCalled();
});
it("有待发送图片时应显示预览并支持删除", () => {
const onRemoveImage = vi.fn();
const container = renderPanel({
@@ -38,6 +38,8 @@ interface EmptyStateComposerPanelProps {
setInput: (value: string) => void;
placeholder: string;
onSend: () => void;
isLoading?: boolean;
disabled?: boolean;
activeTheme: string;
providerType: string;
setProviderType: (type: string) => void;
@@ -78,6 +80,8 @@ export function EmptyStateComposerPanel({
setInput,
placeholder,
onSend,
isLoading = false,
disabled = false,
activeTheme,
providerType,
setProviderType,
@@ -314,6 +318,8 @@ export function EmptyStateComposerPanel({
text={input}
setText={setInput}
onSend={onSend}
isLoading={isLoading}
disabled={disabled}
onToolClick={handleToolAction}
activeTools={{
thinking: thinkingEnabled,
@@ -25,17 +25,17 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type {
ThemeWorkbenchContextBudget,
ThemeWorkbenchContextItem,
} from "./themeWorkbenchContextData";
GeneralWorkbenchContextBudget,
GeneralWorkbenchContextItem,
} from "./generalWorkbenchContextData";
interface ThemeWorkbenchContextPanelProps {
contextItems: ThemeWorkbenchContextItem[];
searchContextItems: ThemeWorkbenchContextItem[];
orderedContextItems: ThemeWorkbenchContextItem[];
selectedSearchResult: ThemeWorkbenchContextItem | null;
interface GeneralWorkbenchContextPanelProps {
contextItems: GeneralWorkbenchContextItem[];
searchContextItems: GeneralWorkbenchContextItem[];
orderedContextItems: GeneralWorkbenchContextItem[];
selectedSearchResult: GeneralWorkbenchContextItem | null;
latestSearchLabel: string;
contextBudget: ThemeWorkbenchContextBudget;
contextBudget: GeneralWorkbenchContextBudget;
contextSearchQuery: string;
contextSearchMode: "web" | "social";
contextSearchLoading: boolean;
@@ -221,8 +221,8 @@ function formatContextCreatedAt(createdAt?: number): string | null {
}
function resolveContextSourceSubLabel(
source: ThemeWorkbenchContextItem["source"],
searchMode?: ThemeWorkbenchContextItem["searchMode"],
source: GeneralWorkbenchContextItem["source"],
searchMode?: GeneralWorkbenchContextItem["searchMode"],
): string {
if (source === "material") {
return "素材库";
@@ -278,7 +278,7 @@ function getContextConfirmButtonClassName(disabled: boolean) {
}
function renderContextList(
items: ThemeWorkbenchContextItem[],
items: GeneralWorkbenchContextItem[],
emptyText: string,
onSelectSearchResult: (contextId: string | null) => void,
onToggleContextActive: (contextId: string) => void,
@@ -340,7 +340,7 @@ function renderContextList(
);
}
function ThemeWorkbenchContextPanelComponent({
function GeneralWorkbenchContextPanelComponent({
contextItems,
searchContextItems,
orderedContextItems,
@@ -379,7 +379,7 @@ function ThemeWorkbenchContextPanelComponent({
onContextDropActiveChange,
onSubmitTextContext,
onSubmitLinkContext,
}: ThemeWorkbenchContextPanelProps) {
}: GeneralWorkbenchContextPanelProps) {
return (
<>
<section className={CONTEXT_SECTION_CLASSNAME}>
@@ -828,4 +828,4 @@ function ThemeWorkbenchContextPanelComponent({
);
}
export const ThemeWorkbenchContextPanel = memo(ThemeWorkbenchContextPanelComponent);
export const GeneralWorkbenchContextPanel = memo(GeneralWorkbenchContextPanelComponent);
@@ -1,8 +1,8 @@
import { act, type ComponentProps } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeWorkbenchEntryPromptAccessory } from "./ThemeWorkbenchEntryPromptAccessory";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
import { GeneralWorkbenchEntryPromptAccessory } from "./GeneralWorkbenchEntryPromptAccessory";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface MountedHarness {
container: HTMLDivElement;
@@ -36,13 +36,13 @@ afterEach(() => {
});
function renderAccessory(
props?: Partial<ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory>>,
props?: Partial<ComponentProps<typeof GeneralWorkbenchEntryPromptAccessory>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultPrompt: ThemeWorkbenchEntryPromptState = {
const defaultPrompt: GeneralWorkbenchEntryPromptState = {
kind: "initial_prompt",
signature: "dispatch-1",
title: "已恢复待执行创作意图",
@@ -50,8 +50,9 @@ function renderAccessory(
actionLabel: "继续生成",
prompt: "请先生成主稿",
};
const defaultProps: ComponentProps<typeof ThemeWorkbenchEntryPromptAccessory> =
{
const defaultProps: ComponentProps<
typeof GeneralWorkbenchEntryPromptAccessory
> = {
prompt: defaultPrompt,
onRestart: vi.fn(),
onContinue: vi.fn(async () => undefined),
@@ -59,7 +60,7 @@ function renderAccessory(
act(() => {
root.render(
<ThemeWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
<GeneralWorkbenchEntryPromptAccessory {...defaultProps} {...props} />,
);
});
@@ -73,7 +74,7 @@ function renderAccessory(
};
}
describe("ThemeWorkbenchEntryPromptAccessory", () => {
describe("GeneralWorkbenchEntryPromptAccessory", () => {
it("应渲染提示文案与操作按钮", () => {
const { container } = renderAccessory();
@@ -102,7 +103,7 @@ describe("ThemeWorkbenchEntryPromptAccessory", () => {
);
if (!restartButton || !continueButton) {
throw new Error("未找到主题工作台入口提示操作按钮");
throw new Error("未找到通用工作台入口提示操作按钮");
}
act(() => {
@@ -1,15 +1,15 @@
import { memo } from "react";
import { Info } from "lucide-react";
import styled from "styled-components";
import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt";
import type { GeneralWorkbenchEntryPromptState } from "../hooks/useGeneralWorkbenchEntryPrompt";
interface ThemeWorkbenchEntryPromptAccessoryProps {
prompt: ThemeWorkbenchEntryPromptState;
interface GeneralWorkbenchEntryPromptAccessoryProps {
prompt: GeneralWorkbenchEntryPromptState;
onRestart: () => void;
onContinue: () => Promise<void> | void;
}
const ThemeWorkbenchEntryPromptCard = styled.div`
const GeneralWorkbenchEntryPromptCard = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
@@ -27,38 +27,38 @@ const ThemeWorkbenchEntryPromptCard = styled.div`
box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26);
`;
const ThemeWorkbenchEntryPromptHeader = styled.div`
const GeneralWorkbenchEntryPromptHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
`;
const ThemeWorkbenchEntryPromptTitleWrap = styled.div`
const GeneralWorkbenchEntryPromptTitleWrap = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
`;
const ThemeWorkbenchEntryPromptTitle = styled.span`
const GeneralWorkbenchEntryPromptTitle = styled.span`
font-size: 13px;
font-weight: 700;
line-height: 1.4;
`;
const ThemeWorkbenchEntryPromptDescription = styled.span`
const GeneralWorkbenchEntryPromptDescription = styled.span`
font-size: 12px;
line-height: 1.5;
color: #475569;
`;
const ThemeWorkbenchEntryPromptActions = styled.div`
const GeneralWorkbenchEntryPromptActions = styled.div`
display: flex;
justify-content: flex-end;
gap: 8px;
`;
const ThemeWorkbenchEntryPromptButton = styled.button<{
const GeneralWorkbenchEntryPromptButton = styled.button<{
$variant?: "primary" | "ghost";
}>`
display: inline-flex;
@@ -96,35 +96,35 @@ const ThemeWorkbenchEntryPromptButton = styled.button<{
}
`;
export const ThemeWorkbenchEntryPromptAccessory = memo(
function ThemeWorkbenchEntryPromptAccessory({
export const GeneralWorkbenchEntryPromptAccessory = memo(
function GeneralWorkbenchEntryPromptAccessory({
prompt,
onRestart,
onContinue,
}: ThemeWorkbenchEntryPromptAccessoryProps) {
}: GeneralWorkbenchEntryPromptAccessoryProps) {
return (
<ThemeWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<ThemeWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptCard data-testid="theme-workbench-entry-prompt">
<GeneralWorkbenchEntryPromptHeader>
<Info className="mt-0.5 h-4 w-4 shrink-0 text-sky-600" />
<ThemeWorkbenchEntryPromptTitleWrap>
<ThemeWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptTitleWrap>
<GeneralWorkbenchEntryPromptTitle>
{prompt.title}
</ThemeWorkbenchEntryPromptTitle>
<ThemeWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitle>
<GeneralWorkbenchEntryPromptDescription>
{prompt.description}
</ThemeWorkbenchEntryPromptDescription>
</ThemeWorkbenchEntryPromptTitleWrap>
</ThemeWorkbenchEntryPromptHeader>
<ThemeWorkbenchEntryPromptActions>
<ThemeWorkbenchEntryPromptButton
</GeneralWorkbenchEntryPromptDescription>
</GeneralWorkbenchEntryPromptTitleWrap>
</GeneralWorkbenchEntryPromptHeader>
<GeneralWorkbenchEntryPromptActions>
<GeneralWorkbenchEntryPromptButton
type="button"
$variant="ghost"
data-testid="theme-workbench-entry-restart"
onClick={onRestart}
>
重新开始
</ThemeWorkbenchEntryPromptButton>
<ThemeWorkbenchEntryPromptButton
</GeneralWorkbenchEntryPromptButton>
<GeneralWorkbenchEntryPromptButton
type="button"
data-testid="theme-workbench-entry-continue"
onClick={() => {
@@ -132,9 +132,9 @@ export const ThemeWorkbenchEntryPromptAccessory = memo(
}}
>
{prompt.actionLabel}
</ThemeWorkbenchEntryPromptButton>
</ThemeWorkbenchEntryPromptActions>
</ThemeWorkbenchEntryPromptCard>
</GeneralWorkbenchEntryPromptButton>
</GeneralWorkbenchEntryPromptActions>
</GeneralWorkbenchEntryPromptCard>
);
},
);
@@ -164,7 +164,7 @@ function getExecLogMoreButtonClassName(disabled?: boolean) {
);
}
interface ThemeWorkbenchExecLogProps {
interface GeneralWorkbenchExecLogProps {
entries: ExecLogEntry[];
totalEntriesCount: number;
wasCleared: boolean;
@@ -174,7 +174,7 @@ interface ThemeWorkbenchExecLogProps {
historyLoading?: boolean;
}
export function ThemeWorkbenchExecLog({
export function GeneralWorkbenchExecLog({
entries,
totalEntriesCount,
wasCleared,
@@ -182,7 +182,7 @@ export function ThemeWorkbenchExecLog({
onLoadMoreHistory,
historyHasMore = false,
historyLoading = false,
}: ThemeWorkbenchExecLogProps) {
}: GeneralWorkbenchExecLogProps) {
const [filter, setFilter] = useState<ExecLogFilter>("all");
const [expandedEntryIds, setExpandedEntryIds] = useState<string[]>([]);
const bottomRef = useRef<HTMLDivElement>(null);
@@ -2,13 +2,13 @@ import { Clock3, FolderOpen, Sparkles, Workflow } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
type ThemeWorkbenchHarnessRunState =
type GeneralWorkbenchHarnessRunState =
| "idle"
| "auto_running"
| "await_user_decision";
interface ThemeWorkbenchHarnessCardProps {
runState: ThemeWorkbenchHarnessRunState;
interface GeneralWorkbenchHarnessCardProps {
runState: GeneralWorkbenchHarnessRunState;
stageTitle: string;
stageDescription: string;
runTitle?: string | null;
@@ -36,7 +36,7 @@ function formatUpdatedAt(value?: string | null): string {
});
}
function resolveRunStateMeta(runState: ThemeWorkbenchHarnessRunState): {
function resolveRunStateMeta(runState: GeneralWorkbenchHarnessRunState): {
label: string;
variant: "secondary" | "default" | "destructive";
description: string;
@@ -63,7 +63,7 @@ function resolveRunStateMeta(runState: ThemeWorkbenchHarnessRunState): {
}
}
export function ThemeWorkbenchHarnessCard({
export function GeneralWorkbenchHarnessCard({
runState,
stageTitle,
stageDescription,
@@ -74,7 +74,7 @@ export function ThemeWorkbenchHarnessCard({
harnessPanelVisible,
layout = "card",
onToggleHarnessPanel,
}: ThemeWorkbenchHarnessCardProps) {
}: GeneralWorkbenchHarnessCardProps) {
const runStateMeta = resolveRunStateMeta(runState);
const resolvedRunTitle = runTitle?.trim() || "暂无运行记录";
const artifactSummary = artifactCount > 0 ? `${artifactCount} 个产物` : "暂无产物";
@@ -2,7 +2,7 @@ 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 { ThemeWorkbenchSidebar } from "./ThemeWorkbenchSidebar";
import { GeneralWorkbenchSidebar } from "./GeneralWorkbenchSidebar";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
const mockWriteClipboardText = vi.fn();
@@ -76,13 +76,13 @@ afterEach(() => {
});
function renderSidebar(
props?: Partial<React.ComponentProps<typeof ThemeWorkbenchSidebar>>,
props?: Partial<React.ComponentProps<typeof GeneralWorkbenchSidebar>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof ThemeWorkbenchSidebar> = {
const defaultProps: React.ComponentProps<typeof GeneralWorkbenchSidebar> = {
onNewTopic: vi.fn(),
onSwitchTopic: vi.fn(),
onDeleteTopic: vi.fn(),
@@ -171,14 +171,14 @@ function renderSidebar(
};
act(() => {
root.render(<ThemeWorkbenchSidebar {...defaultProps} {...props} />);
root.render(<GeneralWorkbenchSidebar {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return { container, props: { ...defaultProps, ...props } };
}
describe("ThemeWorkbenchSidebar", () => {
describe("GeneralWorkbenchSidebar", () => {
it("传入折叠回调时应显示折叠按钮并可触发", () => {
const onRequestCollapse = vi.fn();
const { container } = renderSidebar({ onRequestCollapse });
@@ -279,7 +279,7 @@ describe("ThemeWorkbenchSidebar", () => {
container.querySelector('button[aria-label="打开上下文管理"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="打开编排工作台"]'),
container.querySelector('button[aria-label="打开编排面板"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="打开执行日志"]'),
@@ -681,7 +681,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -709,7 +709,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -736,7 +736,7 @@ describe("ThemeWorkbenchSidebar", () => {
it("活动日志应展示后端闸门与运行标识", () => {
const { container } = renderSidebar();
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -789,7 +789,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -822,7 +822,7 @@ describe("ThemeWorkbenchSidebar", () => {
const onViewRunDetail = vi.fn();
const { container } = renderSidebar({ onViewRunDetail });
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -872,7 +872,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -914,7 +914,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -989,7 +989,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -1084,7 +1084,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -1165,7 +1165,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -1220,7 +1220,7 @@ describe("ThemeWorkbenchSidebar", () => {
});
const workflowTab = container.querySelector(
'button[aria-label="打开编排工作台"]',
'button[aria-label="打开编排面板"]',
) as HTMLButtonElement | null;
if (workflowTab) {
act(() => {
@@ -0,0 +1,66 @@
import React, { memo, useState } from "react";
import {
GeneralWorkbenchSidebarShell,
type GeneralWorkbenchSidebarTab,
} from "./GeneralWorkbenchSidebarShell";
import { GeneralWorkbenchSidebarPanels } from "./GeneralWorkbenchSidebarPanels";
import { buildGeneralWorkbenchSidebarOrchestrationSource } from "./buildGeneralWorkbenchSidebarOrchestrationSource";
import { createGeneralWorkbenchSidebarOrchestrationInput } from "./generalWorkbenchSidebarOrchestrationContract";
import { type GeneralWorkbenchSidebarProps } from "./generalWorkbenchSidebarContract";
import { areGeneralWorkbenchSidebarPropsEqual } from "./generalWorkbenchSidebarComparator";
import { useGeneralWorkbenchSidebarOrchestration } from "./useGeneralWorkbenchSidebarOrchestration";
function GeneralWorkbenchSidebarComponent({
branchMode = "version",
onRequestCollapse,
headerActionSlot,
topSlot,
...props
}: GeneralWorkbenchSidebarProps) {
const [activeTab, setActiveTab] =
useState<GeneralWorkbenchSidebarTab>("context");
const isVersionMode = branchMode === "version";
const orchestrationInput = createGeneralWorkbenchSidebarOrchestrationInput(
buildGeneralWorkbenchSidebarOrchestrationSource({
isVersionMode,
props,
}),
);
const {
branchCount,
activeContextCount,
visibleExecLogCount,
contextPanelProps,
workflowPanelProps,
execLogProps,
} = useGeneralWorkbenchSidebarOrchestration({
activeTab,
input: orchestrationInput,
});
return (
<GeneralWorkbenchSidebarShell
activeTab={activeTab}
isVersionMode={isVersionMode}
activeContextCount={activeContextCount}
branchCount={branchCount}
visibleExecLogCount={visibleExecLogCount}
onTabChange={setActiveTab}
onRequestCollapse={onRequestCollapse}
headerActionSlot={headerActionSlot}
topSlot={topSlot}
>
<GeneralWorkbenchSidebarPanels
activeTab={activeTab}
contextPanelProps={contextPanelProps}
workflowPanelProps={workflowPanelProps}
execLogProps={execLogProps}
/>
</GeneralWorkbenchSidebarShell>
);
}
export const GeneralWorkbenchSidebar = memo(
GeneralWorkbenchSidebarComponent,
areGeneralWorkbenchSidebarPropsEqual,
);
@@ -0,0 +1,28 @@
import { GeneralWorkbenchContextPanel } from "./GeneralWorkbenchContextPanel";
import { GeneralWorkbenchExecLog } from "./GeneralWorkbenchExecLog";
import { GeneralWorkbenchWorkflowPanel } from "./GeneralWorkbenchWorkflowPanel";
import type { GeneralWorkbenchSidebarTab } from "./GeneralWorkbenchSidebarShell";
import type { GeneralWorkbenchSidebarContentProps } from "./generalWorkbenchSidebarContentContract";
export interface GeneralWorkbenchSidebarPanelsProps
extends GeneralWorkbenchSidebarContentProps {
activeTab: GeneralWorkbenchSidebarTab;
}
export function GeneralWorkbenchSidebarPanels({
activeTab,
contextPanelProps,
workflowPanelProps,
execLogProps,
}: GeneralWorkbenchSidebarPanelsProps) {
if (activeTab === "context") {
return <GeneralWorkbenchContextPanel {...contextPanelProps} />;
}
if (activeTab === "workflow") {
return <GeneralWorkbenchWorkflowPanel {...workflowPanelProps} />;
}
if (activeTab === "log") {
return <GeneralWorkbenchExecLog {...execLogProps} />;
}
return null;
}
@@ -2,15 +2,15 @@ import { type ReactNode } from "react";
import { ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";
export type ThemeWorkbenchSidebarTab = "context" | "workflow" | "log";
export type GeneralWorkbenchSidebarTab = "context" | "workflow" | "log";
interface ThemeWorkbenchSidebarShellProps {
activeTab: ThemeWorkbenchSidebarTab;
interface GeneralWorkbenchSidebarShellProps {
activeTab: GeneralWorkbenchSidebarTab;
isVersionMode: boolean;
activeContextCount: number;
branchCount: number;
visibleExecLogCount: number;
onTabChange: (tab: ThemeWorkbenchSidebarTab) => void;
onTabChange: (tab: GeneralWorkbenchSidebarTab) => void;
onRequestCollapse?: () => void;
headerActionSlot?: ReactNode;
topSlot?: ReactNode;
@@ -68,7 +68,7 @@ function getSidebarTabCountClassName(active: boolean) {
}
function resolveSidebarTitle(
activeTab: ThemeWorkbenchSidebarTab,
activeTab: GeneralWorkbenchSidebarTab,
isVersionMode: boolean,
): string {
if (activeTab === "context") {
@@ -77,14 +77,16 @@ function resolveSidebarTitle(
return isVersionMode ? "编排与版本" : "编排与分支";
}
function resolveSidebarDescription(activeTab: ThemeWorkbenchSidebarTab): string {
function resolveSidebarDescription(
activeTab: GeneralWorkbenchSidebarTab,
): string {
if (activeTab === "context") {
return "检索、筛选并启用当前创作真正会用到的上下文。";
}
return "跟踪编排进度、产物版本与运行记录。";
}
export function ThemeWorkbenchSidebarShell({
export function GeneralWorkbenchSidebarShell({
activeTab,
isVersionMode,
activeContextCount,
@@ -95,19 +97,19 @@ export function ThemeWorkbenchSidebarShell({
headerActionSlot,
topSlot,
children,
}: ThemeWorkbenchSidebarShellProps) {
}: GeneralWorkbenchSidebarShellProps) {
return (
<div
className={SIDEBAR_CONTAINER_CLASSNAME}
data-testid="theme-workbench-sidebar"
data-testid="general-workbench-sidebar"
>
<div className={SIDEBAR_HEADER_CLASSNAME}>
<div className={SIDEBAR_HEADER_META_ROW_CLASSNAME}>
<div className={SIDEBAR_EYEBROW_CLASSNAME}>Theme Workbench</div>
<div className={SIDEBAR_EYEBROW_CLASSNAME}>工作区编排</div>
{headerActionSlot ? (
<div
className={SIDEBAR_HEADER_ACTION_SLOT_CLASSNAME}
data-testid="theme-workbench-sidebar-header-action"
data-testid="general-workbench-sidebar-header-action"
>
{headerActionSlot}
</div>
@@ -134,8 +136,8 @@ export function ThemeWorkbenchSidebarShell({
</button>
<button
type="button"
aria-label="打开编排工作台"
title="编排工作台"
aria-label="打开编排面板"
title="编排面板"
className={getSidebarTabButtonClassName(activeTab === "workflow")}
onClick={() => onTabChange("workflow")}
>
@@ -172,7 +174,7 @@ export function ThemeWorkbenchSidebarShell({
{topSlot ? (
<div
className={SIDEBAR_TOP_SLOT_CLASSNAME}
data-testid="theme-workbench-sidebar-top-slot"
data-testid="general-workbench-sidebar-top-slot"
>
{topSlot}
</div>
@@ -23,12 +23,12 @@ import { cn } from "@/lib/utils";
import type { TopicBranchItem, TopicBranchStatus } from "../hooks/useTopicBranchBoard";
import type { SidebarActivityLog } from "../hooks/useThemeContextWorkspace";
import type {
ThemeWorkbenchActivityLogGroup,
ThemeWorkbenchCreationTaskGroup,
ThemeWorkbenchRunMetadataSummary,
} from "./themeWorkbenchWorkflowData";
GeneralWorkbenchActivityLogGroup,
GeneralWorkbenchCreationTaskGroup,
GeneralWorkbenchRunMetadataSummary,
} from "./generalWorkbenchWorkflowData";
interface ThemeWorkbenchWorkflowPanelProps {
interface GeneralWorkbenchWorkflowPanelProps {
isVersionMode: boolean;
onNewTopic: () => void;
onSwitchTopic: (topicId: string) => void;
@@ -43,16 +43,16 @@ interface ThemeWorkbenchWorkflowPanelProps {
creationTaskEventsCount: number;
showCreationTasks: boolean;
onToggleCreationTasks: () => void;
groupedCreationTaskEvents: ThemeWorkbenchCreationTaskGroup[];
groupedCreationTaskEvents: GeneralWorkbenchCreationTaskGroup[];
showActivityLogs: boolean;
onToggleActivityLogs: () => void;
groupedActivityLogs: ThemeWorkbenchActivityLogGroup[];
groupedActivityLogs: GeneralWorkbenchActivityLogGroup[];
onViewRunDetail?: (runId: string) => void;
activeRunDetail?: AgentRun | null;
activeRunDetailLoading?: boolean;
activeRunStagesLabel?: string | null;
runMetadataText: string;
runMetadataSummary: ThemeWorkbenchRunMetadataSummary;
runMetadataSummary: GeneralWorkbenchRunMetadataSummary;
onCopyText: (text: string) => Promise<void> | void;
onRevealArtifactInFinder: (
artifactPath: string,
@@ -312,10 +312,10 @@ function formatRunStatusLabel(status: AgentRun["status"]): string {
}
function renderActivityLogItem(
group: ThemeWorkbenchActivityLogGroup,
onViewRunDetail: ThemeWorkbenchWorkflowPanelProps["onViewRunDetail"],
onRevealArtifactInFinder: ThemeWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"],
onOpenArtifactWithDefaultApp: ThemeWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"],
group: GeneralWorkbenchActivityLogGroup,
onViewRunDetail: GeneralWorkbenchWorkflowPanelProps["onViewRunDetail"],
onRevealArtifactInFinder: GeneralWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"],
onOpenArtifactWithDefaultApp: GeneralWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"],
) {
const gateLabel = formatGateLabel(group.gateKey);
const runLabel = formatRunIdShort(group.runId);
@@ -384,8 +384,8 @@ function ActivityMetaFragment({
}: {
artifactPath: string;
sessionId?: string | null;
onRevealArtifactInFinder: ThemeWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"];
onOpenArtifactWithDefaultApp: ThemeWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"];
onRevealArtifactInFinder: GeneralWorkbenchWorkflowPanelProps["onRevealArtifactInFinder"];
onOpenArtifactWithDefaultApp: GeneralWorkbenchWorkflowPanelProps["onOpenArtifactWithDefaultApp"];
}) {
return (
<>
@@ -411,7 +411,7 @@ function ActivityMetaFragment({
);
}
function ThemeWorkbenchWorkflowPanelComponent({
function GeneralWorkbenchWorkflowPanelComponent({
isVersionMode,
onNewTopic,
onSwitchTopic,
@@ -439,7 +439,7 @@ function ThemeWorkbenchWorkflowPanelComponent({
onCopyText,
onRevealArtifactInFinder,
onOpenArtifactWithDefaultApp,
}: ThemeWorkbenchWorkflowPanelProps) {
}: GeneralWorkbenchWorkflowPanelProps) {
return (
<>
<section className={cn(WORKFLOW_SECTION_CLASSNAME, "relative z-10")}>
@@ -738,4 +738,4 @@ function ThemeWorkbenchWorkflowPanelComponent({
);
}
export const ThemeWorkbenchWorkflowPanel = memo(ThemeWorkbenchWorkflowPanelComponent);
export const GeneralWorkbenchWorkflowPanel = memo(GeneralWorkbenchWorkflowPanelComponent);
@@ -7,6 +7,7 @@ import { RenderableTaskImage } from "./RenderableTaskImage";
interface ImageWorkbenchMessagePreviewProps {
preview: MessageImageWorkbenchPreview;
onOpen?: (preview: MessageImageWorkbenchPreview) => void;
}
function resolveModeEyebrow(
@@ -208,18 +209,22 @@ function resolveSourcePlaceholderLabel(
export const ImageWorkbenchMessagePreview: React.FC<
ImageWorkbenchMessagePreviewProps
> = ({ preview }) => {
> = ({ preview, onOpen }) => {
const showSourcePanel = shouldShowSourcePanel(preview);
return (
<button
type="button"
onClick={() =>
onClick={() => {
if (onOpen) {
onOpen(preview);
return;
}
emitImageWorkbenchFocus({
projectId: preview.projectId ?? null,
contentId: preview.contentId ?? null,
})
}
});
}}
data-testid={`image-workbench-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
>
@@ -11,7 +11,7 @@ import { InputbarCore } from "./InputbarCore";
import { SkillSelector } from "../../../skill-selection/SkillSelector";
import type { BuiltinInputCommand } from "../../../skill-selection/builtinCommands";
import { TeamSelector } from "./TeamSelector";
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
import { InputbarWorkflowStatusPanel } from "./InputbarWorkflowStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
@@ -25,16 +25,16 @@ import {
} from "../../../skill-selection/skillSelectionBindings";
import type { AgentAccessMode } from "../../../hooks/agentChatStorage";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
ThemeWorkbenchWorkflowStep,
} from "../../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowQuickAction,
WorkflowStep,
} from "../../../utils/workflowInputState";
interface InputbarComposerSectionProps {
renderThemeWorkbenchGeneratingPanel: boolean;
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
themeWorkbenchQuickActions: ThemeWorkbenchQuickAction[];
themeWorkbenchQueueItems: ThemeWorkbenchWorkflowStep[];
renderWorkflowGeneratingPanel: boolean;
workflowGate?: WorkflowGateState | null;
workflowQuickActions: WorkflowQuickAction[];
workflowQueueItems: WorkflowStep[];
inputAdapter: ChatInputAdapter;
characters: Character[];
skillSelection: SkillSelectionProps;
@@ -54,7 +54,7 @@ interface InputbarComposerSectionProps {
onRemoveImage: (index: number) => void;
onPaste: (event: React.ClipboardEvent) => void;
isFullscreen: boolean;
isThemeWorkbenchVariant: boolean;
isWorkspaceVariant: boolean;
activeTheme?: string;
onManageProviders?: () => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
@@ -72,10 +72,10 @@ interface InputbarComposerSectionProps {
export const InputbarComposerSection: React.FC<
InputbarComposerSectionProps
> = ({
renderThemeWorkbenchGeneratingPanel,
themeWorkbenchGate,
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderWorkflowGeneratingPanel,
workflowGate,
workflowQuickActions,
workflowQueueItems,
inputAdapter,
characters,
skillSelection,
@@ -95,7 +95,7 @@ export const InputbarComposerSection: React.FC<
onRemoveImage,
onPaste,
isFullscreen,
isThemeWorkbenchVariant,
isWorkspaceVariant,
activeTheme,
onManageProviders,
executionRuntime,
@@ -111,7 +111,7 @@ export const InputbarComposerSection: React.FC<
number | null
>(null);
const showSkillSelector =
!isThemeWorkbenchVariant && isGeneralResearchTheme(activeTheme);
!isWorkspaceVariant && isGeneralResearchTheme(activeTheme);
const currentPendingImages =
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
pendingImages;
@@ -150,12 +150,12 @@ export const InputbarComposerSection: React.FC<
onToolClick(tool);
};
if (renderThemeWorkbenchGeneratingPanel) {
if (renderWorkflowGeneratingPanel) {
return (
<ThemeWorkbenchStatusPanel
gate={themeWorkbenchGate}
quickActions={themeWorkbenchQuickActions}
queueItems={themeWorkbenchQueueItems}
<InputbarWorkflowStatusPanel
gate={workflowGate}
quickActions={workflowQuickActions}
queueItems={workflowQueueItems}
renderGeneratingPanel
onQuickAction={inputAdapter.actions.setText}
onStop={inputAdapter.actions.stop}
@@ -165,10 +165,10 @@ export const InputbarComposerSection: React.FC<
return (
<>
<ThemeWorkbenchStatusPanel
gate={themeWorkbenchGate}
quickActions={themeWorkbenchQuickActions}
queueItems={themeWorkbenchQueueItems}
<InputbarWorkflowStatusPanel
gate={workflowGate}
quickActions={workflowQuickActions}
queueItems={workflowQueueItems}
renderGeneratingPanel={false}
onQuickAction={inputAdapter.actions.setText}
onStop={inputAdapter.actions.stop}
@@ -197,15 +197,15 @@ export const InputbarComposerSection: React.FC<
onPaste={onPaste}
isFullscreen={isFullscreen}
placeholder={
isThemeWorkbenchVariant
? themeWorkbenchGate?.status === "waiting"
isWorkspaceVariant
? workflowGate?.status === "waiting"
? "说说你的选择,剩下的交给我"
: "试着输入任何指令,剩下的交给我"
: undefined
}
toolMode={isThemeWorkbenchVariant ? "attach-only" : "default"}
showDragHandle={!isThemeWorkbenchVariant}
visualVariant={isThemeWorkbenchVariant ? "floating" : "default"}
toolMode={isWorkspaceVariant ? "attach-only" : "default"}
showDragHandle={!isWorkspaceVariant}
visualVariant={isWorkspaceVariant ? "floating" : "default"}
topExtra={resolvedTopExtra}
activeTheme={activeTheme}
queuedTurns={queuedTurns}
@@ -234,7 +234,7 @@ export const InputbarComposerSection: React.FC<
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
{!isThemeWorkbenchVariant ? (
{!isWorkspaceVariant ? (
<InputbarModelExtra
isFullscreen={isFullscreen}
providerType={inputAdapter.model?.providerType}
@@ -8,15 +8,15 @@ import {
} from "lucide-react";
import styled from "styled-components";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
ThemeWorkbenchWorkflowStep,
} from "../../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowQuickAction,
WorkflowStep,
} from "../../../utils/workflowInputState";
interface ThemeWorkbenchStatusPanelProps {
gate?: ThemeWorkbenchGateState | null;
quickActions: ThemeWorkbenchQuickAction[];
queueItems: ThemeWorkbenchWorkflowStep[];
interface InputbarWorkflowStatusPanelProps {
gate?: WorkflowGateState | null;
quickActions: WorkflowQuickAction[];
queueItems: WorkflowStep[];
renderGeneratingPanel: boolean;
onQuickAction: (prompt: string) => void;
onStop?: () => void;
@@ -296,14 +296,14 @@ const StopGlyph = styled.span`
}
`;
export function ThemeWorkbenchStatusPanel({
export function InputbarWorkflowStatusPanel({
gate,
quickActions,
queueItems,
renderGeneratingPanel,
onQuickAction,
onStop,
}: ThemeWorkbenchStatusPanelProps) {
}: InputbarWorkflowStatusPanelProps) {
const [queueCollapsed, setQueueCollapsed] = useState(false);
if (renderGeneratingPanel) {
@@ -374,7 +374,7 @@ export function ThemeWorkbenchStatusPanel({
<RunningSub>切换项目或关闭网页将中断任务</RunningSub>
<StopButton
type="button"
data-testid="theme-workbench-stop"
data-testid="workflow-stop"
onClick={() => onStop?.()}
aria-label="停止生成"
>
@@ -7,15 +7,15 @@ import { useImageAttachments } from "./useImageAttachments";
import { useInputbarAdapter } from "./useInputbarAdapter";
import { useInputbarSend } from "./useInputbarSend";
import {
useInputbarToolState,
type InputbarToolStates,
useInputbarToolState,
} from "./useInputbarToolState";
import type { SkillSelectionSourceProps } from "../../../skill-selection/skillSelectionBindings";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchWorkflowStep,
} from "../../../utils/themeWorkbenchInputState";
import { useThemeWorkbenchInputState } from "../../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowStep,
} from "../../../utils/workflowInputState";
import { useWorkflowInputState } from "../../../utils/workflowInputState";
import { TeamSuggestionBar } from "@/components/agent/chat/components/TeamSuggestionBar";
import { getTeamSuggestion } from "@/components/agent/chat/utils/teamSuggestion";
import type { MessageImage } from "../../../types";
@@ -42,10 +42,10 @@ interface UseInputbarControllerParams {
toolStates?: Partial<InputbarToolStates>;
onToolStatesChange?: (states: InputbarToolStates) => void;
activeTheme?: string;
variant?: "default" | "theme_workbench";
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
workflowSteps?: ThemeWorkbenchWorkflowStep[];
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
variant?: "default" | "workspace";
workflowGate?: WorkflowGateState | null;
workflowSteps?: WorkflowStep[];
workflowRunState?: "idle" | "auto_running" | "await_user_decision";
onEnableSuggestedTeam?: (suggestedPresetId?: string) => void;
}
@@ -65,9 +65,9 @@ export function useInputbarController({
onToolStatesChange,
activeTheme,
variant = "default",
themeWorkbenchGate,
workflowGate,
workflowSteps = [],
themeWorkbenchRunState,
workflowRunState,
onEnableSuggestedTeam,
skills,
serviceSkills,
@@ -93,7 +93,7 @@ export function useInputbarController({
openFileDialog,
} = useImageAttachments();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isThemeWorkbenchVariant = variant === "theme_workbench";
const isWorkspaceVariant = variant === "workspace";
const {
activeTools,
@@ -150,14 +150,14 @@ export function useInputbarController({
});
const {
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
} = useThemeWorkbenchInputState({
isThemeWorkbenchVariant,
themeWorkbenchGate,
workflowQuickActions,
workflowQueueItems,
renderWorkflowGeneratingPanel,
} = useWorkflowInputState({
isWorkspaceVariant,
workflowGate,
workflowSteps,
themeWorkbenchRunState,
workflowRunState,
isSending: inputAdapter.state.isSending,
});
@@ -229,7 +229,7 @@ export function useInputbarController({
return {
textareaRef,
isThemeWorkbenchVariant,
isWorkspaceVariant,
pendingImages,
fileInputRef,
handleFileSelect,
@@ -248,9 +248,9 @@ export function useInputbarController({
handleSend,
inputAdapter,
topExtra,
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
workflowQuickActions,
workflowQueueItems,
renderWorkflowGeneratingPanel,
skillSelection,
setActiveBuiltinCommand: (command: BuiltinInputCommand | null) => {
if (command) {
@@ -11,7 +11,7 @@ import {
} from "@/lib/api/asrProvider";
import { useVoiceSound } from "@/hooks/useVoiceSound";
export type InputbarDictationState =
type InputbarDictationState =
| "idle"
| "listening"
| "transcribing"
@@ -730,9 +730,9 @@ describe("Inputbar", () => {
);
});
it("主题工作台模式应启用 PRD 浮层输入配置", async () => {
it("工作区工作流模式应启用 PRD 浮层输入配置", async () => {
renderInputbar({
variant: "theme_workbench",
variant: "workspace",
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-4.1",
@@ -757,10 +757,10 @@ describe("Inputbar", () => {
expect(latestCall.leftExtra).toBeDefined();
});
it("主题工作台在待启动状态下不应显示闸门条", async () => {
it("工作区工作流在待启动状态下不应显示闸门条", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
themeWorkbenchGate: {
variant: "workspace",
workflowGate: {
key: "draft_start",
title: "编排待启动",
status: "idle",
@@ -777,12 +777,12 @@ describe("Inputbar", () => {
expect(container.textContent).not.toContain("待启动");
});
it("主题工作台闸门快捷操作应能快速填充输入", async () => {
it("工作区工作流闸门快捷操作应能快速填充输入", async () => {
const setInput = vi.fn();
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
setInput,
themeWorkbenchGate: {
workflowGate: {
key: "topic_select",
title: "选题闸门",
status: "waiting",
@@ -815,10 +815,10 @@ describe("Inputbar", () => {
);
});
it("主题工作台生成中应展示任务面板并支持停止", async () => {
it("工作区工作流生成中应展示任务面板并支持停止", async () => {
const onStop = vi.fn();
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: true,
onStop,
workflowSteps: [
@@ -837,7 +837,7 @@ describe("Inputbar", () => {
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull();
const stopButton = container.querySelector(
'[data-testid="theme-workbench-stop"]',
'[data-testid="workflow-stop"]',
) as HTMLButtonElement | null;
expect(stopButton).toBeTruthy();
act(() => {
@@ -846,9 +846,9 @@ describe("Inputbar", () => {
expect(onStop).toHaveBeenCalledTimes(1);
});
it("主题工作台生成中应支持折叠与展开待办列表", async () => {
it("工作区工作流生成中应支持折叠与展开待办列表", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: true,
workflowSteps: [
{ id: "research", title: "检索项目素材", status: "active" },
@@ -886,11 +886,11 @@ describe("Inputbar", () => {
expect(container.textContent).toContain("检索项目素材");
});
it("主题工作台在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => {
it("工作区工作流在 auto_running 状态下应展示生成面板(不依赖 isLoading)", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: false,
themeWorkbenchRunState: "auto_running",
workflowRunState: "auto_running",
workflowSteps: [
{ id: "research", title: "检索项目素材", status: "active" },
{ id: "write", title: "编写正文草稿", status: "pending" },
@@ -907,11 +907,11 @@ describe("Inputbar", () => {
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull();
});
it("主题工作台在 await_user_decision 状态下应显示输入框", async () => {
it("工作区工作流在 await_user_decision 状态下应显示输入框", async () => {
const { container } = renderInputbar({
variant: "theme_workbench",
variant: "workspace",
isLoading: true,
themeWorkbenchRunState: "await_user_decision",
workflowRunState: "await_user_decision",
workflowSteps: [
{ id: "topic", title: "等待用户确认选题", status: "pending" },
],
@@ -13,9 +13,9 @@ import { TaskFilesPanel } from "./components/TaskFilesPanel";
import { InputbarSurface } from "./components/InputbarSurface";
import type { SkillSelectionSourceProps } from "../../skill-selection/skillSelectionBindings";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchWorkflowStep,
} from "../../utils/themeWorkbenchInputState";
WorkflowGateState,
WorkflowStep,
} from "../../utils/workflowInputState";
import { type InputbarToolStates } from "./hooks/useInputbarToolState";
import { useInputbarController } from "./hooks/useInputbarController";
import type { TeamDefinition } from "../../utils/teamDefinitions";
@@ -86,10 +86,10 @@ interface InputbarProps extends SkillSelectionSourceProps {
onToolStatesChange?: (states: InputbarToolStates) => void;
activeTheme?: string;
onManageProviders?: () => void;
variant?: "default" | "theme_workbench";
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
workflowSteps?: ThemeWorkbenchWorkflowStep[];
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
variant?: "default" | "workspace";
workflowGate?: WorkflowGateState | null;
workflowSteps?: WorkflowStep[];
workflowRunState?: "idle" | "auto_running" | "await_user_decision";
queuedTurns?: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
@@ -136,9 +136,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
activeTheme,
onManageProviders,
variant = "default",
themeWorkbenchGate,
workflowGate,
workflowSteps = [],
themeWorkbenchRunState,
workflowRunState,
queuedTurns = [],
onPromoteQueuedTurn,
onRemoveQueuedTurn,
@@ -150,7 +150,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
}) => {
const {
textareaRef,
isThemeWorkbenchVariant,
isWorkspaceVariant,
pendingImages,
fileInputRef,
handleFileSelect,
@@ -169,9 +169,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
handleSend,
inputAdapter,
topExtra,
themeWorkbenchQuickActions,
themeWorkbenchQueueItems,
renderThemeWorkbenchGeneratingPanel,
workflowQuickActions,
workflowQueueItems,
renderWorkflowGeneratingPanel,
skillSelection,
setActiveBuiltinCommand,
} = useInputbarController({
@@ -190,9 +190,9 @@ export const Inputbar: React.FC<InputbarProps> = ({
onToolStatesChange,
activeTheme,
variant,
themeWorkbenchGate,
workflowGate,
workflowSteps,
themeWorkbenchRunState,
workflowRunState,
onEnableSuggestedTeam,
skills,
serviceSkills,
@@ -238,12 +238,10 @@ export const Inputbar: React.FC<InputbarProps> = ({
onChange={handleFileSelect}
/>
<InputbarComposerSection
renderThemeWorkbenchGeneratingPanel={
renderThemeWorkbenchGeneratingPanel
}
themeWorkbenchGate={themeWorkbenchGate}
themeWorkbenchQuickActions={themeWorkbenchQuickActions}
themeWorkbenchQueueItems={themeWorkbenchQueueItems}
renderWorkflowGeneratingPanel={renderWorkflowGeneratingPanel}
workflowGate={workflowGate}
workflowQuickActions={workflowQuickActions}
workflowQueueItems={workflowQueueItems}
inputAdapter={inputAdapter}
characters={characters}
skillSelection={skillSelection}
@@ -263,7 +261,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
onRemoveImage={handleRemoveImage}
onPaste={handlePaste}
isFullscreen={isFullscreen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
isWorkspaceVariant={isWorkspaceVariant}
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
@@ -444,12 +444,6 @@ export const ToolButton = styled.button`
}
`;
export const Divider = styled.div`
width: 1px;
height: 16px;
background-color: rgba(148, 163, 184, 0.28);
`;
export const InputIconButton = styled.button<{
$primary?: boolean;
$destructive?: boolean;
@@ -2,10 +2,11 @@ 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 { IMAGE_WORKBENCH_FOCUS_EVENT } from "@/lib/imageWorkbenchEvents";
import { MessageList } from "./MessageList";
import type { Message } from "../types";
const IMAGE_WORKBENCH_FOCUS_EVENT = "lime:image-workbench-focus";
vi.mock("./MarkdownRenderer", () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content || "<empty>"}</div>
@@ -138,6 +139,19 @@ function render(
}
describe("MessageList", () => {
it("自动恢复任务中心时应展示恢复占位而不是空白引导", () => {
const container = render([], { isRestoringSession: true });
expect(
container.querySelector('[data-testid="message-list-restoring-session"]'),
).not.toBeNull();
expect(container.textContent).toContain("正在恢复任务中心...");
expect(container.textContent).toContain(
"正在同步最近一次任务会话,请稍候。",
);
expect(container.textContent).not.toContain("开始一段新的对话吧");
});
it("应过滤空白 user 消息,避免渲染空白气泡", () => {
const now = new Date();
const messages: Message[] = [
@@ -246,6 +260,167 @@ describe("MessageList", () => {
window.removeEventListener(IMAGE_WORKBENCH_FOCUS_EVENT, handleFocus);
});
it("视频任务消息卡应在聊天区渲染预览并支持打开工作区查看", () => {
const now = new Date();
const onOpenMessagePreview = vi.fn();
const messages: Message[] = [
{
id: "msg-assistant-video-task",
role: "assistant",
content: "视频任务已提交,正在生成。",
timestamp: now,
taskPreview: {
kind: "video_generate",
taskId: "task-video-1",
taskType: "video_generate",
prompt: "新品发布会短视频,镜头缓慢推进主角产品",
status: "running",
progress: 42,
durationSeconds: 15,
aspectRatio: "16:9",
resolution: "720p",
projectId: "project-video-1",
contentId: "content-video-1",
},
},
];
const container = render(messages, { onOpenMessagePreview });
const previewCard = container.querySelector(
'[data-testid="task-message-preview-task-video-1"]',
) as HTMLButtonElement | null;
expect(previewCard?.textContent).toContain("视频生成");
expect(previewCard?.textContent).toContain("16:9");
expect(previewCard?.textContent).toContain("720p");
expect(previewCard?.textContent).toContain("42%");
act(() => {
previewCard?.click();
});
expect(onOpenMessagePreview).toHaveBeenCalledWith(
{
kind: "task",
preview: expect.objectContaining({
kind: "video_generate",
taskId: "task-video-1",
}),
},
expect.objectContaining({
id: "msg-assistant-video-task",
}),
);
});
it("通用任务消息卡应在聊天区渲染预览并支持打开对应产物", () => {
const now = new Date();
const onOpenMessagePreview = vi.fn();
const messages: Message[] = [
{
id: "msg-assistant-resource-task",
role: "assistant",
content: "素材检索任务已提交。",
timestamp: now,
taskPreview: {
kind: "modal_resource_search",
taskId: "task-resource-1",
taskType: "modal_resource_search",
prompt: "咖啡馆木桌背景",
title: "公众号头图素材",
status: "running",
artifactPath:
".lime/tasks/modal_resource_search/task-resource-1.json",
metaItems: ["image", "公众号头图", "8 个候选"],
},
},
];
const container = render(messages, { onOpenMessagePreview });
const previewCard = container.querySelector(
'[data-testid="task-message-preview-task-resource-1"]',
) as HTMLButtonElement | null;
expect(previewCard?.textContent).toContain("素材检索");
expect(previewCard?.textContent).toContain("公众号头图素材");
expect(previewCard?.textContent).toContain("8 个候选");
act(() => {
previewCard?.click();
});
expect(onOpenMessagePreview).toHaveBeenCalledWith(
{
kind: "task",
preview: expect.objectContaining({
kind: "modal_resource_search",
taskId: "task-resource-1",
}),
},
expect.objectContaining({
id: "msg-assistant-resource-task",
}),
);
});
it("联网搜图结果消息卡应展示缩略图候选", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-resource-search-preview",
role: "assistant",
content: "已找到一组图片素材候选。",
timestamp: now,
taskPreview: {
kind: "modal_resource_search",
taskId: "resource-search:tool-1",
taskType: "modal_resource_search",
prompt: "cozy coffee table",
title: "Pexels 图片候选",
status: "complete",
artifactPath: ".lime/runtime/resource-search/tool-1.md",
metaItems: ["Pexels", "3 个候选"],
imageCandidates: [
{
id: "hit-1",
thumbnailUrl: "https://pexels.example/1-thumb.jpg",
contentUrl: "https://pexels.example/1.jpg",
name: "cozy coffee table 1",
},
{
id: "hit-2",
thumbnailUrl: "https://pexels.example/2-thumb.jpg",
contentUrl: "https://pexels.example/2.jpg",
name: "cozy coffee table 2",
},
{
id: "hit-3",
thumbnailUrl: "https://pexels.example/3-thumb.jpg",
contentUrl: "https://pexels.example/3.jpg",
name: "cozy coffee table 3",
},
],
},
},
];
const container = render(messages);
const media = container.querySelector(
'[data-testid="task-message-preview-media-resource-search:tool-1"]',
);
expect(media).not.toBeNull();
expect(
container.querySelector('img[src="https://pexels.example/1-thumb.jpg"]'),
).toBeTruthy();
expect(
container.querySelector('img[src="https://pexels.example/2-thumb.jpg"]'),
).toBeTruthy();
expect(
container.querySelector('img[src="https://pexels.example/3-thumb.jpg"]'),
).toBeTruthy();
});
it("修图任务消息卡应展示来源图区域与修图语义", () => {
const now = new Date();
const messages: Message[] = [
@@ -1355,17 +1530,16 @@ describe("MessageList", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-assistant-browser",
id: "msg-assistant-action",
role: "assistant",
content: "请先完成浏览器登录。",
content: "请先确认文章标题。",
timestamp: now,
actionRequests: [
{
requestId: "req-browser",
requestId: "req-ask-title",
actionType: "ask_user",
uiKind: "browser_preflight",
browserPrepState: "awaiting_user",
prompt: "请先在浏览器完成登录",
prompt: "请先确认文章标题",
questions: [{ question: "这篇文章的最终标题是什么?" }],
},
],
},
@@ -1374,9 +1548,9 @@ describe("MessageList", () => {
render(messages, {
turns: [
{
id: "turn-browser",
id: "turn-action",
thread_id: "thread-1",
prompt_text: "发布到公众号",
prompt_text: "确认文章标题",
status: "aborted",
started_at: "2026-03-15T09:00:00Z",
completed_at: "2026-03-15T09:00:05Z",
@@ -1386,9 +1560,9 @@ describe("MessageList", () => {
],
threadItems: [
{
id: "item-browser-1",
id: "item-action-1",
thread_id: "thread-1",
turn_id: "turn-browser",
turn_id: "turn-action",
sequence: 1,
status: "completed",
started_at: "2026-03-15T09:00:01Z",
@@ -1,4 +1,10 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import React, {
useState,
useRef,
useEffect,
useMemo,
useLayoutEffect,
} from "react";
import {
Copy,
Quote,
@@ -23,6 +29,7 @@ import { StreamingRenderer } from "./StreamingRenderer";
import { TokenUsageDisplay } from "./TokenUsageDisplay";
import { AgentThreadTimeline } from "./AgentThreadTimeline";
import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview";
import { TaskMessagePreview } from "./TaskMessagePreview";
import {
formatArtifactWritePhaseLabel,
resolveArtifactPreviewText,
@@ -38,6 +45,7 @@ import {
type ActionRequired,
type AgentThreadItem,
type AgentThreadTurn,
type MessagePreviewTarget,
type SiteSavedContentTarget,
type WriteArtifactContext,
type PendingA2UISource,
@@ -89,6 +97,8 @@ interface MessageListProps {
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void;
/** Artifact 点击回调 */
onArtifactClick?: (artifact: Artifact) => void;
/** 打开消息结果预览 */
onOpenMessagePreview?: (target: MessagePreviewTarget, message: Message) => void;
/** 打开子代理会话 */
onOpenSubagentSession?: (sessionId: string) => void;
/** 权限确认响应回调 */
@@ -101,6 +111,8 @@ interface MessageListProps {
onCodeBlockClick?: (language: string, code: string) => void;
/** 是否将待处理问答提升为输入区 A2UI 表单 */
promoteActionRequestsToA2UI?: boolean;
/** 会话是否仍在自动恢复 */
isRestoringSession?: boolean;
/** 中断当前执行 */
onInterruptCurrentTurn?: () => void | Promise<void>;
/** 恢复当前线程排队执行 */
@@ -232,12 +244,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onArtifactClick,
onOpenMessagePreview,
onOpenSubagentSession,
onPermissionResponse,
collapseCodeBlocks,
shouldCollapseCodeBlock,
onCodeBlockClick,
promoteActionRequestsToA2UI = false,
isRestoringSession = false,
compactLeadingSpacing = false,
focusedTimelineItemId = null,
timelineFocusRequestKey = 0,
@@ -245,6 +259,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
}) => {
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousVisibleMessageCountRef = useRef<number | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
@@ -321,12 +336,26 @@ const MessageListInner: React.FC<MessageListProps> = ({
};
}, []);
// 智能自动滚动:只在用户没有手动滚动且在底部时才自动滚动
useEffect(() => {
if (shouldAutoScroll && !isUserScrolling && scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth" });
// 恢复历史会话时需要在首帧前把视口定位到底部,避免先闪顶部空白再平滑滚动。
useLayoutEffect(() => {
const previousVisibleMessageCount = previousVisibleMessageCountRef.current;
previousVisibleMessageCountRef.current = visibleMessages.length;
if (!shouldAutoScroll || isUserScrolling || !scrollRef.current) {
return;
}
}, [visibleMessages, shouldAutoScroll, isUserScrolling]);
const shouldAnimateScroll =
!isRestoringSession &&
previousVisibleMessageCount !== null &&
previousVisibleMessageCount > 0 &&
visibleMessages.length <= previousVisibleMessageCount + 1;
scrollRef.current.scrollIntoView({
behavior: shouldAnimateScroll ? "smooth" : "auto",
block: "end",
});
}, [visibleMessages, shouldAutoScroll, isUserScrolling, isRestoringSession]);
const handleCopy = async (content: string, id: string) => {
try {
@@ -517,6 +546,35 @@ const MessageListInner: React.FC<MessageListProps> = ({
{msg.imageWorkbenchPreview ? (
<ImageWorkbenchMessagePreview
preview={msg.imageWorkbenchPreview}
onOpen={
onOpenMessagePreview
? (preview) =>
onOpenMessagePreview(
{
kind: "image_workbench",
preview,
},
msg,
)
: undefined
}
/>
) : null}
{msg.taskPreview ? (
<TaskMessagePreview
preview={msg.taskPreview}
onOpen={
onOpenMessagePreview
? (preview) =>
onOpenMessagePreview(
{
kind: "task",
preview,
},
msg,
)
: undefined
}
/>
) : null}
</>
@@ -700,16 +758,36 @@ const MessageListInner: React.FC<MessageListProps> = ({
: "mx-auto flex w-full max-w-[1040px] flex-col gap-4 py-4 pl-4 pr-4"
}
>
{messageGroups.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-50">
<img
src={logoImg}
alt="Lime"
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
</div>
)}
{messageGroups.length === 0 &&
(isRestoringSession ? (
<div
className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground"
data-testid="message-list-restoring-session"
role="status"
aria-live="polite"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full border border-border/70 bg-background/80 shadow-sm">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
<div className="space-y-1 text-center">
<p className="text-lg font-medium text-foreground">
正在恢复任务中心...
</p>
<p className="text-sm text-muted-foreground">
正在同步最近一次任务会话,请稍候。
</p>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground opacity-50">
<img
src={logoImg}
alt="Lime"
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
</div>
))}
{messageGroups.map((group, groupIndex) => {
return (
@@ -0,0 +1,406 @@
import React from "react";
import {
ArrowUpRight,
Clapperboard,
FileText,
Link2,
LoaderCircle,
Mic,
PlayCircle,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { MessageTaskPreview } from "../types";
interface TaskMessagePreviewProps {
preview: MessageTaskPreview;
onOpen?: (preview: MessageTaskPreview) => void;
}
function resolveTaskLabel(preview: MessageTaskPreview): string {
switch (preview.kind) {
case "video_generate":
return "视频生成";
case "broadcast_generate":
return "播报整理";
case "modal_resource_search":
return "素材检索";
case "transcription_generate":
return "内容转写";
case "url_parse":
return "链接解析";
case "typesetting":
return "排版优化";
}
const exhaustiveCheck: never = preview;
return exhaustiveCheck;
}
function resolveStatusLabel(preview: MessageTaskPreview): string {
switch (preview.status) {
case "complete":
return "已完成";
case "partial":
return "部分完成";
case "failed":
return "执行失败";
case "cancelled":
return "已取消";
case "running":
default:
if ((preview.phase || "").trim().toLowerCase() === "queued") {
return "排队中";
}
return "执行中";
}
}
function resolveStatusTone(preview: MessageTaskPreview): string {
switch (preview.status) {
case "complete":
return "border-emerald-200 bg-emerald-50 text-emerald-700";
case "partial":
return "border-amber-200 bg-amber-50 text-amber-700";
case "failed":
return "border-rose-200 bg-rose-50 text-rose-700";
case "cancelled":
return "border-slate-200 bg-slate-100 text-slate-600";
case "running":
default:
return "border-sky-200 bg-sky-50 text-sky-700";
}
}
function resolveDescription(preview: MessageTaskPreview): string {
const statusMessage = preview.statusMessage?.trim();
if (statusMessage) {
return statusMessage;
}
if (preview.kind === "video_generate") {
switch (preview.status) {
case "complete":
return "视频已经生成完成,打开查看即可继续预览和管理任务。";
case "partial":
return "任务返回了部分结果,打开查看可继续确认可用片段。";
case "failed":
return "这次没有拿到可用视频结果,请调整参数后重试。";
case "cancelled":
return "任务已经取消,当前不会继续生成新的结果。";
case "running":
default:
return "任务已提交到异步队列,工作区会继续同步最新生成状态。";
}
}
switch (preview.status) {
case "complete":
case "partial":
return "任务结果已同步,打开查看即可继续处理。";
case "failed":
return "任务执行失败,请调整输入后重试。";
case "cancelled":
return "任务已经取消,当前不会继续执行。";
case "running":
default:
return "任务已进入统一执行主链,工作区会继续同步最新状态。";
}
}
function formatDurationLabel(durationSeconds?: number): string | null {
if (
typeof durationSeconds !== "number" ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return null;
}
return `${durationSeconds} 秒`;
}
function formatProgressLabel(progress?: number | null): string | null {
if (
typeof progress !== "number" ||
!Number.isFinite(progress) ||
progress <= 0
) {
return null;
}
return `${Math.max(0, Math.min(100, Math.round(progress)))}%`;
}
function buildMetaItems(preview: MessageTaskPreview): string[] {
if (preview.kind === "video_generate") {
const items = [
preview.aspectRatio?.trim(),
preview.resolution?.trim(),
formatDurationLabel(preview.durationSeconds),
formatProgressLabel(preview.progress),
].filter((item): item is string => Boolean(item));
if (preview.model?.trim()) {
items.push(preview.model.trim());
}
return items;
}
const items = [...(preview.metaItems || [])];
if (preview.model?.trim()) {
items.push(preview.model.trim());
}
return items.filter((item) => item.trim().length > 0);
}
function resolveGenericTaskIcon(
preview: Exclude<MessageTaskPreview, { kind: "video_generate" }>,
) {
switch (preview.kind) {
case "broadcast_generate":
return Mic;
case "modal_resource_search":
return Search;
case "url_parse":
return Link2;
case "transcription_generate":
case "typesetting":
default:
return FileText;
}
}
function renderGenericTaskMedia(
preview: Exclude<MessageTaskPreview, { kind: "video_generate" }>,
Icon: ReturnType<typeof resolveGenericTaskIcon>,
) {
if (
preview.kind === "modal_resource_search" &&
preview.imageCandidates &&
preview.imageCandidates.length > 0
) {
return (
<div
data-testid={`task-message-preview-media-${preview.taskId}`}
className="grid h-20 w-28 shrink-0 grid-cols-2 gap-1 overflow-hidden rounded-[18px] border border-slate-200 bg-slate-100 p-1"
>
{preview.imageCandidates.slice(0, 4).map((candidate, index) => (
<div
key={`${candidate.id}-${index}`}
className={cn(
"overflow-hidden rounded-xl bg-slate-200",
preview.imageCandidates!.length === 1 ? "col-span-2" : "",
preview.imageCandidates!.length === 3 && index === 0
? "row-span-2"
: "",
)}
>
<img
src={candidate.thumbnailUrl}
alt={candidate.name || "素材候选"}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
);
}
return (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-[18px] border border-slate-200 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] text-sky-600">
<Icon className="h-7 w-7" />
</div>
);
}
function renderVideoTaskPreview(
preview: Extract<MessageTaskPreview, { kind: "video_generate" }>,
onOpen?: (preview: MessageTaskPreview) => void,
metaItems: string[] = [],
) {
return (
<button
type="button"
onClick={() => onOpen?.(preview)}
data-testid={`task-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
>
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:shadow-slate-950/10">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusTone(preview),
)}
>
{preview.status === "running" ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Clapperboard className="h-3.5 w-3.5" />
)}
{resolveStatusLabel(preview)}
</span>
<span className="truncate text-[11px] font-semibold text-slate-500">
{resolveTaskLabel(preview)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<span>打开查看</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</div>
<div className="grid gap-3 px-4 pb-4 sm:grid-cols-[220px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50">
{preview.thumbnailUrl ? (
<img
src={preview.thumbnailUrl}
alt={preview.prompt || "视频任务封面"}
className="aspect-[16/10] h-full w-full object-cover"
/>
) : (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="flex flex-col items-center gap-2 text-slate-500">
{preview.videoUrl ? (
<PlayCircle className="h-9 w-9 text-sky-500" />
) : (
<Clapperboard className="h-9 w-9 text-sky-500" />
)}
<span className="text-sm font-medium">
{preview.videoUrl
? "已同步视频结果"
: resolveStatusLabel(preview)}
</span>
</div>
</div>
)}
</div>
<div className="flex min-w-0 flex-col gap-3 py-1">
<div className="space-y-1.5">
<div className="line-clamp-2 text-sm font-semibold leading-6 text-slate-900">
{preview.prompt || "视频任务"}
</div>
<p className="text-sm leading-6 text-slate-600">
{resolveDescription(preview)}
</p>
</div>
{metaItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{metaItems.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600"
>
{item}
</span>
))}
</div>
) : null}
{preview.providerId?.trim() ? (
<div className="text-xs text-slate-500">
服务商: {preview.providerId.trim()}
</div>
) : null}
</div>
</div>
</div>
</button>
);
}
function renderGenericTaskPreview(
preview: Exclude<MessageTaskPreview, { kind: "video_generate" }>,
onOpen?: (preview: MessageTaskPreview) => void,
metaItems: string[] = [],
) {
const Icon = resolveGenericTaskIcon(preview);
const media = renderGenericTaskMedia(preview, Icon);
return (
<button
type="button"
onClick={() => onOpen?.(preview)}
data-testid={`task-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
>
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:shadow-slate-950/10">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusTone(preview),
)}
>
{preview.status === "running" ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Icon className="h-3.5 w-3.5" />
)}
{resolveStatusLabel(preview)}
</span>
<span className="truncate text-[11px] font-semibold text-slate-500">
{resolveTaskLabel(preview)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<span>打开查看</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</div>
<div className="flex gap-3 px-4 pb-4">
{media}
<div className="min-w-0 flex-1 space-y-2.5 py-1">
<div className="space-y-1.5">
<div className="line-clamp-2 text-sm font-semibold leading-6 text-slate-900">
{preview.title?.trim() ||
preview.prompt ||
resolveTaskLabel(preview)}
</div>
<p className="text-sm leading-6 text-slate-600">
{resolveDescription(preview)}
</p>
</div>
{metaItems.length > 0 ? (
<div className="flex flex-wrap gap-2">
{metaItems.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs font-medium text-slate-600"
>
{item}
</span>
))}
</div>
) : null}
{preview.artifactPath?.trim() ? (
<div className="truncate text-xs text-slate-500">
任务文件: {preview.artifactPath.trim()}
</div>
) : null}
</div>
</div>
</div>
</button>
);
}
export const TaskMessagePreview: React.FC<TaskMessagePreviewProps> = ({
preview,
onOpen,
}) => {
const metaItems = buildMetaItems(preview);
if (preview.kind === "video_generate") {
return renderVideoTaskPreview(preview, onOpen, metaItems);
}
return renderGenericTaskPreview(preview, onOpen, metaItems);
};
@@ -1,65 +0,0 @@
import React, { memo, useState } from "react";
import {
ThemeWorkbenchSidebarShell,
type ThemeWorkbenchSidebarTab,
} from "./ThemeWorkbenchSidebarShell";
import { ThemeWorkbenchSidebarPanels } from "./ThemeWorkbenchSidebarPanels";
import { buildThemeWorkbenchSidebarOrchestrationSource } from "./buildThemeWorkbenchSidebarOrchestrationSource";
import { createThemeWorkbenchSidebarOrchestrationInput } from "./themeWorkbenchSidebarOrchestrationContract";
import { type ThemeWorkbenchSidebarProps } from "./themeWorkbenchSidebarContract";
import { areThemeWorkbenchSidebarPropsEqual } from "./themeWorkbenchSidebarComparator";
import { useThemeWorkbenchSidebarOrchestration } from "./useThemeWorkbenchSidebarOrchestration";
function ThemeWorkbenchSidebarComponent({
branchMode = "version",
onRequestCollapse,
headerActionSlot,
topSlot,
...props
}: ThemeWorkbenchSidebarProps) {
const [activeTab, setActiveTab] = useState<ThemeWorkbenchSidebarTab>("context");
const isVersionMode = branchMode === "version";
const orchestrationInput = createThemeWorkbenchSidebarOrchestrationInput(
buildThemeWorkbenchSidebarOrchestrationSource({
isVersionMode,
props,
}),
);
const {
branchCount,
activeContextCount,
visibleExecLogCount,
contextPanelProps,
workflowPanelProps,
execLogProps,
} = useThemeWorkbenchSidebarOrchestration({
activeTab,
input: orchestrationInput,
});
return (
<ThemeWorkbenchSidebarShell
activeTab={activeTab}
isVersionMode={isVersionMode}
activeContextCount={activeContextCount}
branchCount={branchCount}
visibleExecLogCount={visibleExecLogCount}
onTabChange={setActiveTab}
onRequestCollapse={onRequestCollapse}
headerActionSlot={headerActionSlot}
topSlot={topSlot}
>
<ThemeWorkbenchSidebarPanels
activeTab={activeTab}
contextPanelProps={contextPanelProps}
workflowPanelProps={workflowPanelProps}
execLogProps={execLogProps}
/>
</ThemeWorkbenchSidebarShell>
);
}
export const ThemeWorkbenchSidebar = memo(
ThemeWorkbenchSidebarComponent,
areThemeWorkbenchSidebarPropsEqual,
);
@@ -1,28 +0,0 @@
import { ThemeWorkbenchContextPanel } from "./ThemeWorkbenchContextPanel";
import { ThemeWorkbenchExecLog } from "./ThemeWorkbenchExecLog";
import { ThemeWorkbenchWorkflowPanel } from "./ThemeWorkbenchWorkflowPanel";
import type { ThemeWorkbenchSidebarTab } from "./ThemeWorkbenchSidebarShell";
import type { ThemeWorkbenchSidebarContentProps } from "./themeWorkbenchSidebarContentContract";
export interface ThemeWorkbenchSidebarPanelsProps
extends ThemeWorkbenchSidebarContentProps {
activeTab: ThemeWorkbenchSidebarTab;
}
export function ThemeWorkbenchSidebarPanels({
activeTab,
contextPanelProps,
workflowPanelProps,
execLogProps,
}: ThemeWorkbenchSidebarPanelsProps) {
if (activeTab === "context") {
return <ThemeWorkbenchContextPanel {...contextPanelProps} />;
}
if (activeTab === "workflow") {
return <ThemeWorkbenchWorkflowPanel {...workflowPanelProps} />;
}
if (activeTab === "log") {
return <ThemeWorkbenchExecLog {...execLogProps} />;
}
return null;
}
@@ -1,140 +0,0 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeWorkbenchSkillsPanel } from "./ThemeWorkbenchSkillsPanel";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderPanel(
props?: Partial<React.ComponentProps<typeof ThemeWorkbenchSkillsPanel>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof ThemeWorkbenchSkillsPanel> = {
skills: [
{
key: "content_post_with_cover",
name: "content_post_with_cover",
description: "内容文案与封面生成",
directory: "content_post_with_cover",
installed: true,
sourceKind: "builtin",
},
{
key: "research",
name: "research",
description: "信息检索与趋势分析",
directory: "research",
installed: true,
sourceKind: "builtin",
},
{
key: "typesetting",
name: "typesetting",
description: "主稿排版与润色",
directory: "typesetting",
installed: true,
sourceKind: "builtin",
},
],
currentGate: {
key: "topic_select",
title: "选题闸门",
status: "waiting",
description: "请确认本轮选题方向",
},
workspaceSummary: {
activeContextCount: 2,
searchResultCount: 5,
versionCount: 3,
runState: "await_user_decision",
},
onTriggerSkill: vi.fn(),
};
act(() => {
root.render(<ThemeWorkbenchSkillsPanel {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return {
container,
props: { ...defaultProps, ...props },
};
}
describe("ThemeWorkbenchSkillsPanel", () => {
it("传入折叠回调时应显示折叠按钮并可触发", () => {
const onRequestCollapse = vi.fn();
const { container } = renderPanel({ onRequestCollapse });
const collapseButton = container.querySelector(
'button[aria-label="折叠操作面板"]',
) as HTMLButtonElement | null;
expect(collapseButton).toBeTruthy();
if (collapseButton) {
act(() => {
collapseButton.click();
});
}
expect(onRequestCollapse).toHaveBeenCalledTimes(1);
});
it("应显示操作面板、阶段摘要、推荐动作与统计信息", () => {
const { container } = renderPanel();
expect(container.textContent).toContain("操作面板");
expect(container.textContent).toContain("阶段摘要");
expect(container.textContent).toContain("选题闸门");
expect(container.textContent).toContain("推荐动作");
expect(container.textContent).toContain("可执行能力");
expect(container.textContent).toContain("启用上下文");
expect(container.textContent).toContain("搜索结果");
expect(container.textContent).toContain("版本快照");
expect(container.textContent).toContain("待决策");
expect(container.textContent).toContain("research");
expect(container.textContent).toContain("content_post_with_cover");
});
it("点击推荐技能应触发 onTriggerSkill 回调", () => {
const onTriggerSkill = vi.fn();
const { container } = renderPanel({ onTriggerSkill });
const skillButton = container.querySelector(
'button[aria-label="执行技能 research"]',
) as HTMLButtonElement | null;
expect(skillButton).not.toBeNull();
if (skillButton) {
act(() => {
skillButton.click();
});
}
expect(onTriggerSkill).toHaveBeenCalledTimes(1);
expect(onTriggerSkill.mock.calls[0][0]?.key).toBe("research");
});
});
@@ -1,492 +0,0 @@
import { useMemo } from "react";
import {
ChevronRight,
FileText,
Image,
PanelRightClose,
Search,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Skill } from "@/lib/api/skills";
import { CONTENT_POST_SKILL_KEY } from "../utils/contentPostSkill";
type SkillGroupKey = "text" | "visual" | "audio" | "video" | "resource";
type ThemeWorkbenchRunState = "idle" | "auto_running" | "await_user_decision";
interface SkillGroup {
key: SkillGroupKey;
title: string;
items: Skill[];
}
interface CurrentGate {
key: string;
title: string;
status: "running" | "waiting" | "idle" | "done";
description: string;
}
interface ThemeWorkbenchWorkspaceSummary {
activeContextCount: number;
searchResultCount: number;
versionCount: number;
runState: ThemeWorkbenchRunState;
}
const PANEL_CLASSNAME =
"flex h-full w-[320px] min-w-[320px] flex-col overflow-hidden border-l border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.94)_0%,rgba(241,245,249,0.82)_100%)]";
const HEADER_CLASSNAME =
"border-b border-slate-200/80 bg-white/88 px-4 py-3 backdrop-blur-sm";
const SECTION_CLASSNAME = "border-b border-slate-200/80 px-4 py-3";
const SCROLL_SECTION_CLASSNAME =
"flex-1 min-h-0 overflow-y-auto px-4 py-3 [scrollbar-gutter:stable]";
const SECTION_TITLE_CLASSNAME =
"mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-slate-500";
const CARD_CLASSNAME =
"rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.92)_100%)] p-3 shadow-sm shadow-slate-950/5";
const METRIC_CARD_CLASSNAME =
"rounded-[16px] border border-slate-200/80 bg-white/94 p-3";
const ACTION_CARD_CLASSNAME =
"rounded-[18px] border p-3 shadow-sm shadow-slate-950/5 transition-colors";
const ACTION_BUTTON_CLASSNAME =
"mt-3 inline-flex h-9 w-full items-center justify-center rounded-full border px-3 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-50";
function getGateStatusClassName(status: CurrentGate["status"]) {
return cn(
"ml-auto inline-flex min-h-6 items-center rounded-full border px-2.5 text-[10px] font-semibold",
status === "waiting" &&
"border-amber-200 bg-amber-50/90 text-amber-700",
status === "running" && "border-sky-200 bg-sky-50/90 text-sky-700",
status === "idle" && "border-slate-200 bg-slate-100/90 text-slate-600",
status === "done" &&
"border-emerald-200 bg-emerald-50/90 text-emerald-700",
);
}
function getActionCardClassName(featured = false) {
return cn(
ACTION_CARD_CLASSNAME,
featured
? "border-emerald-200/90 bg-[linear-gradient(180deg,rgba(236,253,245,0.92)_0%,rgba(255,255,255,0.98)_100%)]"
: "border-slate-200/80 bg-white/92 hover:border-slate-300 hover:bg-white",
);
}
function getActionIconClassName(featured = false) {
return cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl border",
featured
? "border-emerald-200 bg-emerald-100/90 text-emerald-700"
: "border-slate-200 bg-slate-100/90 text-slate-600",
);
}
function getActionButtonClassName(featured = false) {
return cn(
ACTION_BUTTON_CLASSNAME,
featured
? "border-emerald-200 bg-emerald-50 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-100/80"
: "border-slate-200/80 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900",
);
}
function resolveSkillGroup(skill: Skill): SkillGroupKey {
const feature = `${skill.key} ${skill.name} ${skill.description}`.toLowerCase();
if (
feature.includes("cover") ||
feature.includes("image") ||
feature.includes("illustration")
) {
return "visual";
}
if (
feature.includes("broadcast") ||
feature.includes("audio") ||
feature.includes("podcast")
) {
return "audio";
}
if (feature.includes("video")) {
return "video";
}
if (
feature.includes("resource") ||
feature.includes("research") ||
feature.includes("library") ||
feature.includes("url") ||
feature.includes("search")
) {
return "resource";
}
return "text";
}
function getGroupTitle(groupKey: SkillGroupKey): string {
if (groupKey === "text") return "文字能力";
if (groupKey === "visual") return "视觉能力";
if (groupKey === "audio") return "音频能力";
if (groupKey === "video") return "视频能力";
return "检索与资源";
}
function resolveGateStatusText(status: CurrentGate["status"]): string {
if (status === "waiting") return "等待决策";
if (status === "running") return "自动执行";
if (status === "idle") return "待启动";
return "已完成";
}
function resolveRunStateText(runState: ThemeWorkbenchRunState): string {
if (runState === "auto_running") return "执行中";
if (runState === "await_user_decision") return "待决策";
return "空闲";
}
function resolveSkillIcon(skill: Skill): LucideIcon {
const group = resolveSkillGroup(skill);
if (group === "resource") {
return Search;
}
if (group === "visual") {
return Image;
}
if (group === "text") {
return FileText;
}
return Sparkles;
}
function resolveSkillActionLabel(skill: Skill): string {
const group = resolveSkillGroup(skill);
if (group === "resource") {
return "开始检索";
}
if (group === "visual") {
return "生成素材";
}
return "立即执行";
}
function buildSkillFeatureProbe(skill: Skill): string {
return `${skill.key} ${skill.name} ${skill.description || ""}`.toLowerCase();
}
function pickRecommendedSkills(skills: Skill[], gateKey: string): Skill[] {
const tagsByGate: Record<string, string[]> = {
topic_select: ["research", CONTENT_POST_SKILL_KEY],
write_mode: [CONTENT_POST_SKILL_KEY, "typesetting", "cover"],
publish_confirm: ["typesetting", "cover", CONTENT_POST_SKILL_KEY],
};
const preferredTags = tagsByGate[gateKey] || [CONTENT_POST_SKILL_KEY, "research"];
const selected: Skill[] = [];
preferredTags.forEach((tag) => {
const found = skills.find((skill) => {
if (selected.some((item) => item.key === skill.key)) {
return false;
}
return buildSkillFeatureProbe(skill).includes(tag);
});
if (found) {
selected.push(found);
}
});
if (selected.length < 2) {
skills.forEach((skill) => {
if (selected.length >= 2) {
return;
}
if (!selected.some((item) => item.key === skill.key)) {
selected.push(skill);
}
});
}
return selected.slice(0, 2);
}
interface ThemeWorkbenchSkillsPanelProps {
skills: Skill[];
currentGate: CurrentGate;
disabled?: boolean;
workspaceSummary?: ThemeWorkbenchWorkspaceSummary;
onTriggerSkill?: (skill: Skill) => void;
onRequestCollapse?: () => void;
}
export function ThemeWorkbenchSkillsPanel({
skills,
currentGate,
disabled = false,
workspaceSummary,
onTriggerSkill,
onRequestCollapse,
}: ThemeWorkbenchSkillsPanelProps) {
const fallbackSkills: Skill[] = useMemo(
() => [
{
key: CONTENT_POST_SKILL_KEY,
name: CONTENT_POST_SKILL_KEY,
description: "内容主稿与封面图生成",
directory: CONTENT_POST_SKILL_KEY,
installed: true,
sourceKind: "builtin",
},
{
key: "cover_generate",
name: "cover_generate",
description: "封面图生成",
directory: "cover_generate",
installed: true,
sourceKind: "builtin",
},
{
key: "research",
name: "research",
description: "信息检索与趋势分析",
directory: "research",
installed: true,
sourceKind: "builtin",
},
{
key: "typesetting",
name: "typesetting",
description: "主稿排版与润色",
directory: "typesetting",
installed: true,
sourceKind: "builtin",
},
],
[],
);
const availableSkills = useMemo(() => {
const installed = skills.filter((skill) => skill.installed);
return installed.length > 0 ? installed : fallbackSkills;
}, [fallbackSkills, skills]);
const recommendedSkills = useMemo(
() => pickRecommendedSkills(availableSkills, currentGate.key),
[availableSkills, currentGate.key],
);
const groupedSkills = useMemo<SkillGroup[]>(() => {
const recommendedSkillKeys = new Set(
recommendedSkills.map((skill) => skill.key),
);
const buckets: Record<SkillGroupKey, Skill[]> = {
text: [],
visual: [],
audio: [],
video: [],
resource: [],
};
availableSkills.forEach((skill) => {
if (recommendedSkillKeys.has(skill.key)) {
return;
}
buckets[resolveSkillGroup(skill)].push(skill);
});
return (Object.keys(buckets) as SkillGroupKey[])
.map((key) => ({
key,
title: getGroupTitle(key),
items: buckets[key],
}))
.filter((group) => group.items.length > 0);
}, [availableSkills, recommendedSkills]);
return (
<aside className={PANEL_CLASSNAME}>
<div className={HEADER_CLASSNAME}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.06em] text-slate-500">
Theme Workbench
</div>
<div className="mt-1 text-base font-semibold text-slate-900">
操作面板
</div>
</div>
{onRequestCollapse ? (
<button
type="button"
aria-label="折叠操作面板"
onClick={onRequestCollapse}
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl border border-slate-200/80 bg-white text-slate-500 shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
<PanelRightClose size={16} />
</button>
) : null}
</div>
<div className="mt-2 text-[12px] leading-5 text-slate-500">
右侧聚焦当前阶段推荐动作,中间主稿区保持结果优先,减少来回跳转。
</div>
</div>
<section className={SECTION_CLASSNAME}>
<div className={SECTION_TITLE_CLASSNAME}>阶段摘要</div>
<div className={CARD_CLASSNAME}>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-xl border border-slate-200 bg-slate-100/90 text-slate-600">
<ChevronRight size={14} />
</div>
<div className="min-w-0 text-sm font-semibold text-slate-900">
{currentGate.title}
</div>
<span className={getGateStatusClassName(currentGate.status)}>
{resolveGateStatusText(currentGate.status)}
</span>
</div>
<div className="mt-2 text-[12px] leading-5 text-slate-500">
{currentGate.description}
</div>
{workspaceSummary ? (
<div className="mt-3 grid grid-cols-2 gap-2">
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{workspaceSummary.activeContextCount}
</div>
<div className="mt-1 text-[11px] text-slate-500">
启用上下文
</div>
</div>
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{workspaceSummary.searchResultCount}
</div>
<div className="mt-1 text-[11px] text-slate-500">
搜索结果
</div>
</div>
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{workspaceSummary.versionCount}
</div>
<div className="mt-1 text-[11px] text-slate-500">
版本快照
</div>
</div>
<div className={METRIC_CARD_CLASSNAME}>
<div className="text-base font-semibold leading-none text-slate-900">
{resolveRunStateText(workspaceSummary.runState)}
</div>
<div className="mt-1 text-[11px] text-slate-500">
运行状态
</div>
</div>
</div>
) : null}
<div className="mt-3 text-[11px] leading-5 text-slate-500">
{disabled
? "当前有任务执行中,建议等当前处理完成后再触发新的技能。"
: "先看推荐动作,再按需要选择更多能力,避免重复操作。"}
</div>
</div>
</section>
<section className={SCROLL_SECTION_CLASSNAME}>
<div className={SECTION_TITLE_CLASSNAME}>推荐动作</div>
<div className="flex flex-col gap-3">
{recommendedSkills.map((skill) => {
const Icon = resolveSkillIcon(skill);
return (
<div key={skill.key} className={getActionCardClassName(true)}>
<div className="flex items-start gap-3">
<div className={getActionIconClassName(true)}>
<Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold leading-5 text-slate-900">
{skill.name}
</div>
<div className="mt-1 text-[12px] leading-5 text-slate-500">
{skill.description || "使用当前能力继续推进当前工作台任务。"}
</div>
<span className="mt-2 inline-flex min-h-6 items-center rounded-full border border-emerald-200 bg-emerald-50/90 px-2.5 text-[10px] font-semibold text-emerald-700">
推荐优先执行
</span>
</div>
</div>
<button
type="button"
aria-label={`执行技能 ${skill.key}`}
disabled={disabled}
onClick={() => onTriggerSkill?.(skill)}
className={getActionButtonClassName(true)}
>
{resolveSkillActionLabel(skill)}
</button>
</div>
);
})}
</div>
<div className="mt-4 text-[12px] font-semibold text-slate-900">
可执行能力
</div>
{groupedSkills.length === 0 ? (
<div className="mt-2 text-[11px] leading-5 text-slate-500">
当前可用技能已全部展示在推荐动作中,可直接开始执行。
</div>
) : (
<div className="mt-3 space-y-4">
{groupedSkills.map((group) => (
<div key={group.key}>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.05em] text-slate-500">
{group.title}
</div>
<div className="flex flex-col gap-3">
{group.items.map((skill) => {
const Icon = resolveSkillIcon(skill);
return (
<div key={skill.key} className={getActionCardClassName()}>
<div className="flex items-start gap-3">
<div className={getActionIconClassName()}>
<Icon size={16} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold leading-5 text-slate-900">
{skill.name}
</div>
<div className="mt-1 text-[12px] leading-5 text-slate-500">
{skill.description ||
"使用当前能力继续处理工作台内容。"}
</div>
</div>
</div>
<button
type="button"
aria-label={`执行技能 ${skill.key}`}
disabled={disabled}
onClick={() => onTriggerSkill?.(skill)}
className={getActionButtonClassName()}
>
{resolveSkillActionLabel(skill)}
</button>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</section>
</aside>
);
}

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