mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.4.0
This commit is contained in:
+30
-25
@@ -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`
|
||||
|
||||
@@ -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
@@ -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` 手动压缩。
|
||||
|
||||
@@ -126,6 +126,7 @@ Lime 的整体界面应当接近以下气质:
|
||||
- 一个页面只保留一个主标题中心
|
||||
- 子页面不要重复出现“标题 + 同标题卡片标题”双重堆叠
|
||||
- 标题负责定性,说明负责补充,不要写两句意思相同的话
|
||||
- 首屏静态解释文案默认不要整段铺开;标题旁优先放 help / tips 入口,把写法建议、卡片 hint、快捷键说明等收进悬浮提示,避免说明文字压过主操作
|
||||
|
||||
### 3. 统计卡文字
|
||||
|
||||
|
||||
@@ -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 与运行时封装 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
Generated
+21
-21
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(×tamp);
|
||||
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 个关键问题,不要假装已经读到不存在的内容。
|
||||
|
||||
## 输出格式(固定)
|
||||
|
||||
# 翻译结果
|
||||
|
||||
## 译文
|
||||
{译文正文}
|
||||
|
||||
## 说明(可选)
|
||||
- {仅在用户要求保留术语、双语对照或需要解释翻译取舍时输出}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-15
@@ -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);
|
||||
+10
-9
@@ -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(() => {
|
||||
+29
-29
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
+3
-3
@@ -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);
|
||||
+6
-6
@@ -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} 个产物` : "暂无产物";
|
||||
+17
-17
@@ -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;
|
||||
}
|
||||
+16
-14
@@ -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>
|
||||
+17
-17
@@ -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"
|
||||
>
|
||||
|
||||
+31
-31
@@ -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}
|
||||
|
||||
+11
-11
@@ -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
Reference in New Issue
Block a user