diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 937f85814..63ac456e6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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` diff --git a/docs/aiprompts/command-runtime.md b/docs/aiprompts/command-runtime.md index 6c4d4a06a..b2a4e63b8 100644 --- a/docs/aiprompts/command-runtime.md +++ b/docs/aiprompts/command-runtime.md @@ -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 主文档。 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 0fea64ff7..a245f8a4d 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -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//*.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` 手动压缩。 diff --git a/docs/aiprompts/design-language.md b/docs/aiprompts/design-language.md index b36703eea..e08138da4 100644 --- a/docs/aiprompts/design-language.md +++ b/docs/aiprompts/design-language.md @@ -126,6 +126,7 @@ Lime 的整体界面应当接近以下气质: - 一个页面只保留一个主标题中心 - 子页面不要重复出现“标题 + 同标题卡片标题”双重堆叠 - 标题负责定性,说明负责补充,不要写两句意思相同的话 +- 首屏静态解释文案默认不要整段铺开;标题旁优先放 help / tips 入口,把写法建议、卡片 hint、快捷键说明等收进悬浮提示,避免说明文字压过主操作 ### 3. 统计卡文字 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index ebdb0c161..ff757d5ae 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -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 与运行时封装 | diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index d6d941e20..2c6c201fc 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -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` diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 3522a42fe..3628404ad 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -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 续测里确认连接器页能打开、目录可选、扩展状态可读。 diff --git a/docs/prd/skills/async-image-skill-task-prd.md b/docs/prd/skills/async-image-skill-task-prd.md index da4e57f4f..14a418944 100644 --- a/docs/prd/skills/async-image-skill-task-prd.md +++ b/docs/prd/skills/async-image-skill-task-prd.md @@ -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 - diff --git a/package.json b/package.json index 4e00315ef..d5edf4b17 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index 105d37409..f16d09460 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -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" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index b0286ed9b..a2c46bbcc 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.3.0", + "version": "1.4.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 59f6793fb..3a915eb7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bfb3d2fdf..d68e911c0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/crates/agent/src/credential_bridge.rs b/src-tauri/crates/agent/src/credential_bridge.rs index ffdcd462d..84067f564 100644 --- a/src-tauri/crates/agent/src/credential_bridge.rs +++ b/src-tauri/crates/agent/src/credential_bridge.rs @@ -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) diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index d8efab825..317d4feea 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -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| -> Option { + 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("发布确认")); diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs index a72fd7055..944a6b64d 100644 --- a/src-tauri/crates/agent/src/turn_input_envelope.rs +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -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, diff --git a/src-tauri/crates/core/src/models/mod.rs b/src-tauri/crates/core/src/models/mod.rs index 2302b4b84..86150fb46 100644 --- a/src-tauri/crates/core/src/models/mod.rs +++ b/src-tauri/crates/core/src/models/mod.rs @@ -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}; diff --git a/src-tauri/crates/core/src/models/skill_model.rs b/src-tauri/crates/core/src/models/skill_model.rs index 6123ce1b7..c944f23aa 100644 --- a/src-tauri/crates/core/src/models/skill_model.rs +++ b/src-tauri/crates/core/src/models/skill_model.rs @@ -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 )); diff --git a/src-tauri/crates/providers/proptest-regressions/providers/antigravity.txt b/src-tauri/crates/providers/proptest-regressions/providers/antigravity.txt deleted file mode 100644 index bc590a026..000000000 --- a/src-tauri/crates/providers/proptest-regressions/providers/antigravity.txt +++ /dev/null @@ -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 diff --git a/src-tauri/crates/services/src/aster_session_store.rs b/src-tauri/crates/services/src/aster_session_store.rs index 2f8b99847..2073d0cde 100644 --- a/src-tauri/crates/services/src/aster_session_store.rs +++ b/src-tauri/crates/services/src/aster_session_store.rs @@ -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>, } 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 { + 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 { + 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, model: &str) -> SessionType { let parsed_model = model.parse::().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 { - 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(); diff --git a/src-tauri/resources/default-skills/analysis/SKILL.md b/src-tauri/resources/default-skills/analysis/SKILL.md new file mode 100644 index 000000000..7e303c817 --- /dev/null +++ b/src-tauri/resources/default-skills/analysis/SKILL.md @@ -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 个关键问题,不要假装已经读到不存在的内容。 + +## 输出格式(固定) + +# 分析结果 + +## 结论 +{核心判断} + +## 依据 +- {支持判断的事实或上下文依据} + +## 待确认项(可选) +- {存在歧义、缺失证据或需要用户补充的点} diff --git a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md index bb6782723..8b97049aa 100644 --- a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md +++ b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md @@ -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`。 -## 输出格式(固定) +## 输出格式 + +### 图片直搜命中 + +仅输出图片候选摘要(不要再写 ``): + +- 检索来源:Pexels +- 检索关键词:{query} +- 候选数量:{returnedCount} +- 候选: + 1. {name} | {width}x{height} | {hostPageUrl} + +### 回退异步任务 仅输出任务提交摘要(不要再写 ``): diff --git a/src-tauri/resources/default-skills/pdf_read/SKILL.md b/src-tauri/resources/default-skills/pdf_read/SKILL.md new file mode 100644 index 000000000..e99822fd7 --- /dev/null +++ b/src-tauri/resources/default-skills/pdf_read/SKILL.md @@ -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 的关键事实、段落或数据} + +## 待确认项(可选) +- {文件缺失、证据不足或存在歧义的部分} diff --git a/src-tauri/resources/default-skills/report_generate/SKILL.md b/src-tauri/resources/default-skills/report_generate/SKILL.md new file mode 100644 index 000000000..6fd8f385f --- /dev/null +++ b/src-tauri/resources/default-skills/report_generate/SKILL.md @@ -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 条高价值来源;来源不足时要明确写出“不确定项”。 +- 结论、证据、推断、风险要分开写,不把推断冒充事实。 +- 输出应适合继续进入工作区加工,不要只返回零散片段。 + +## 输出格式(固定) + + +# 研究报告 + +## 研究主题 +{主题} + +## 核心结论 +- {结论 1} +- {结论 2} +- {结论 3} + +## 关键证据与来源 +- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要} +- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要} +- {来源名称/站点}(日期:{YYYY-MM-DD}):{一句证据摘要} + +## 关键风险与待确认项 +- {风险或不确定项 1} +- {风险或不确定项 2} + +## 建议动作 +- {建议 1} +- {建议 2} + +## 附录 +- 检索关键词:{关键词列表} +- 目标时间范围:{时间范围} +- 目标站点:{站点列表} + diff --git a/src-tauri/resources/default-skills/summary/SKILL.md b/src-tauri/resources/default-skills/summary/SKILL.md new file mode 100644 index 000000000..2c7aa9767 --- /dev/null +++ b/src-tauri/resources/default-skills/summary/SKILL.md @@ -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} + +## 关键细节(可选) +- {仅在用户要求更详细时输出} + +## 待确认项(可选) +- {仅在原文信息不完整或存在歧义时输出} diff --git a/src-tauri/resources/default-skills/translation/SKILL.md b/src-tauri/resources/default-skills/translation/SKILL.md new file mode 100644 index 000000000..5fc3436dd --- /dev/null +++ b/src-tauri/resources/default-skills/translation/SKILL.md @@ -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 个关键问题,不要假装已经读到不存在的内容。 + +## 输出格式(固定) + +# 翻译结果 + +## 译文 +{译文正文} + +## 说明(可选) +- {仅在用户要求保留术语、双语对照或需要解释翻译取舍时输出} diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index 3481c9137..fd58d4617 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -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::>(); - 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 diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 957f3e7df..551368a14 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -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::>(); - 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, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 4fe95ca40..b39d2732d 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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, diff --git a/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs new file mode 100644 index 000000000..035a80359 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs @@ -0,0 +1,186 @@ +use super::*; + +const ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_analysis_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/broadcast_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/broadcast_skill_launch.rs new file mode 100644 index 000000000..eefaffdb0 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/broadcast_skill_launch.rs @@ -0,0 +1,205 @@ +use super::*; + +const BROADCAST_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_broadcast_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs index 01c5e21cb..a7a165b83 100644 --- a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs +++ b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs @@ -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") ) } diff --git a/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs new file mode 100644 index 000000000..89855bc6c --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/deep_search_skill_launch.rs @@ -0,0 +1,203 @@ +use super::*; + +const DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_deep_search_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 9acef21c8..50baab31b 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -98,7 +98,7 @@ pub struct AsterChatRequest { /// 前端传入的 System Prompt(可选,优先级低于项目上下文) #[serde(default, alias = "systemPrompt")] pub system_prompt: Option, - /// 请求级元数据(可选,用于 harness / 主题工作台状态对齐) + /// 请求级元数据(可选,用于 harness / 工作区编排状态对齐) #[serde(default)] pub metadata: Option, /// 回合 ID(可选,由前端提供时透传到 Aster runtime) diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index e34a6045b..e35c7f5b9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -258,22 +258,33 @@ fn normalize_optional_text(value: Option) -> Option { } 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; diff --git a/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs new file mode 100644 index 000000000..d972bd1e6 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/pdf_read_skill_launch.rs @@ -0,0 +1,196 @@ +use super::*; + +const PDF_READ_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_pdf_read_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index 7f3011ef9..102f0467e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -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 { + 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 { + 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, request_metadata: Option<&serde_json::Value>, diff --git a/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs new file mode 100644 index 000000000..d114d680b --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs @@ -0,0 +1,201 @@ +use super::*; + +const REPORT_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_report_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index bea662417..222ab8fb9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -13,6 +13,7 @@ struct ProviderResolutionContext { registry_provider_ids: Vec, alias_key: String, custom_models: Vec, + is_custom_provider: bool, } fn normalize_identifier(value: &str) -> String { @@ -224,6 +225,8 @@ fn build_provider_resolution_context( provider_selector: &str, ) -> Result { 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::>(); + + 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( diff --git a/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs new file mode 100644 index 000000000..c4e725fd1 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs @@ -0,0 +1,200 @@ +use super::*; + +const RESEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_research_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/resource_search_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/resource_search_skill_launch.rs new file mode 100644 index 000000000..c86486bd4 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/resource_search_skill_launch.rs @@ -0,0 +1,241 @@ +use super::*; + +const RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = + "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_resource_search_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 232814e87..27d515bb6 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -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"), diff --git a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs index e36abc7d8..4da6a8a42 100644 --- a/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/service_skill_launch.rs @@ -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, + pub(crate) tenant_id: Option, + pub(crate) session_token: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ServiceSceneLaunchContext { + pub(crate) raw_text: Option, + pub(crate) user_input: Option, + pub(crate) scene_key: Option, + pub(crate) command_prefix: Option, + pub(crate) service_skill_id: String, + pub(crate) service_skill_key: Option, + pub(crate) skill_title: Option, + pub(crate) skill_summary: Option, + pub(crate) project_id: Option, + pub(crate) content_id: Option, + pub(crate) entry_source: Option, + pub(crate) oem_runtime: ServiceSceneLaunchOemRuntimeContext, +} + fn extract_object_string( object: &serde_json::Map, 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 { @@ -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 { + 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 { + 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 { diff --git a/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs new file mode 100644 index 000000000..29023ddf9 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/site_search_skill_launch.rs @@ -0,0 +1,196 @@ +use super::*; + +const SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_site_search_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs new file mode 100644 index 000000000..6641a9e5d --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs @@ -0,0 +1,195 @@ +use super::*; + +const SUMMARY_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_summary_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 3ae003108..8a37abc87 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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("<>")); + 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!({ diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index 4020564ef..a315abc44 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -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); } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/resource_search_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/resource_search_tools.rs new file mode 100644 index 000000000..34b4d9df1 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/resource_search_tools.rs @@ -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, + #[serde(default)] + aspect: Option, + #[serde(default)] + page: Option, +} + +#[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 { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + + fn normalize_search_count(count: Option) -> u32 { + count + .unwrap_or(DEFAULT_WEB_IMAGE_SEARCH_COUNT) + .clamp(1, MAX_WEB_IMAGE_SEARCH_COUNT) + } + + fn normalize_search_page(page: Option) -> 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, 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 { + 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::(); + 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 + ); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs new file mode 100644 index 000000000..f93ff0b3a --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/service_skill_tools.rs @@ -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, + #[serde(default)] + wait_for_completion: Option, + #[serde(default)] + poll_attempts: Option, + #[serde(default)] + poll_interval_ms: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct ServiceSkillRunRecord { + id: String, + #[serde(default)] + status: String, + #[serde(default)] + run_type: Option, + #[serde(default)] + scene_id: Option, + #[serde(default)] + service_skill_id: Option, + #[serde(default)] + service_skill_key: Option, + #[serde(default)] + executor_kind: Option, + #[serde(default)] + input_summary: Option, + #[serde(default)] + output_summary: Option, + #[serde(default)] + output_text: Option, + #[serde(default)] + error_code: Option, + #[serde(default)] + error_message: Option, + #[serde(default)] + fallback_applied: Option, + #[serde(default)] + fallback_kind: Option, + #[serde(default)] + started_at: Option, + #[serde(default)] + finished_at: Option, + #[serde(default)] + updated_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ServiceSkillRunEnvelope { + #[serde(default)] + code: Option, + #[serde(default)] + message: Option, + #[serde(default)] + data: Option, +} + +#[derive(Clone)] +pub(crate) struct LimeRunServiceSkillTool; + +impl LimeRunServiceSkillTool { + fn new() -> Self { + Self + } + + fn normalize_optional_text(value: Option<&str>) -> Option { + 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, + ) -> Option { + if metadata.is_empty() { + return None; + } + + let map = metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + Some(serde_json::Value::Object(map)) + } + + fn extract_launch_context_from_runtime_snapshot( + snapshot: &SessionRuntimeSnapshot, + ) -> Option { + 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 { + SERVICE_SCENE_LAUNCH_CONTEXT_ENV_KEYS + .iter() + .find_map(|key| { + let raw = context.environment.get(*key)?; + let parsed = serde_json::from_str::(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 { + 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 { + 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 { + 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 { + 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, + ) -> Result { + 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::() + .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 { + 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 { + 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") + ); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs new file mode 100644 index 000000000..31ed45a60 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs @@ -0,0 +1,193 @@ +use super::*; + +const TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_translation_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/typesetting_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/typesetting_skill_launch.rs new file mode 100644 index 000000000..4ce6eaf3e --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/typesetting_skill_launch.rs @@ -0,0 +1,204 @@ +use super::*; + +const TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; + +fn extract_object_string( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + 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::(); + format!("{truncated}...(已截断,原始长度 {total_chars} 字)") +} + +pub(crate) fn prepare_typesetting_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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, + request_metadata: Option<&serde_json::Value>, +) -> Option { + 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 { + 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")) +} diff --git a/src-tauri/src/commands/content_cmd.rs b/src-tauri/src/commands/content_cmd.rs index fcf2f27b1..232ebbf54 100644 --- a/src-tauri/src/commands/content_cmd.rs +++ b/src-tauri/src/commands/content_cmd.rs @@ -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 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, + pub versions: Vec, } 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 { +) -> Option { 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 = versions_raw + let versions: Vec = 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, String> { +) -> Result, 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"); } } diff --git a/src-tauri/src/commands/execution_run_cmd.rs b/src-tauri/src/commands/execution_run_cmd.rs index 046bebaf3..e5c68ac5d 100644 --- a/src-tauri/src/commands/execution_run_cmd.rs +++ b/src-tauri/src/commands/execution_run_cmd.rs @@ -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, pub session_id: Option, @@ -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, pub session_id: Option, @@ -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, - pub latest_terminal: Option, - pub recent_terminals: Vec, + pub queue_items: Vec, + pub latest_terminal: Option, + pub recent_terminals: Vec, pub updated_at: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case")] -pub struct ThemeWorkbenchRunHistoryPage { - pub items: Vec, +pub struct GeneralWorkbenchRunHistoryPage { + pub items: Vec, pub has_more: bool, pub next_offset: Option, } @@ -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 { .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 { +) -> Vec { 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, -) -> Result { +) -> Result { 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 = runs + let queue_items: Vec = 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, offset: Option, -) -> Result { +) -> Result { 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::>(); - 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, diff --git a/src-tauri/src/commands/image_search_cmd.rs b/src-tauri/src/commands/image_search_cmd.rs index 8e1800dc4..9b3cabbfc 100644 --- a/src-tauri/src/commands/image_search_cmd.rs +++ b/src-tauri/src/commands/image_search_cmd.rs @@ -6,6 +6,22 @@ use crate::app::AppState; use serde::{Deserialize, Serialize}; use tauri::State; +fn normalize_non_empty_api_key(raw: Option) -> Option { + 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, env_key: &str) -> Option { + 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 { 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) -> Option { + 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 { +pub(crate) async fn get_pexels_api_key_from_app_state(app_state: &AppState) -> Option { 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, req: WebImageSearchRequest, ) -> Result { - 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 { + 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::*; diff --git a/src-tauri/src/dev_bridge/dispatcher/content.rs b/src-tauri/src/dev_bridge/dispatcher/content.rs index deee554fb..ffc47e8a9 100644 --- a/src-tauri/src/dev_bridge/dispatcher/content.rs +++ b/src-tauri/src/dev_bridge/dispatcher/content.rs @@ -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 = content.and_then(|item| { - parse_theme_workbench_document_state(&item.id, item.metadata.as_ref()) + let document_state: Option = content.and_then(|item| { + parse_general_workbench_document_state(&item.id, item.metadata.as_ref()) }); serde_json::to_value(document_state)? } diff --git a/src-tauri/src/services/artifact_document_service.rs b/src-tauri/src/services/artifact_document_service.rs index 5702a059c..372cec7a5 100644 --- a/src-tauri/src/services/artifact_document_service.rs +++ b/src-tauri/src/services/artifact_document_service.rs @@ -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, - pub theme_workbench_document_state: Map, + pub general_workbench_document_state: Map, 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 { @@ -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), diff --git a/src-tauri/src/services/artifact_request_metadata_service.rs b/src-tauri/src/services/artifact_request_metadata_service.rs index a7e9410b0..1a7f155ff 100644 --- a/src-tauri/src/services/artifact_request_metadata_service.rs +++ b/src-tauri/src/services/artifact_request_metadata_service.rs @@ -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 { .map(str::to_string) } +fn normalize_session_mode_text(value: Option<&str>) -> Option { + 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> { 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 { + 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 { 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" } }); diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index c9bd4765f..3e8d3569f 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -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] diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index 784efac8c..8efbe88be 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -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; diff --git a/src-tauri/src/skills/runtime.rs b/src-tauri/src/skills/runtime.rs index d488fe1b0..17d04c83f 100644 --- a/src-tauri/src/skills/runtime.rs +++ b/src-tauri/src/skills/runtime.rs @@ -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, }) diff --git a/src-tauri/src/skills/social_post.rs b/src-tauri/src/skills/social_post.rs index c67840d7d..419ac8738 100644 --- a/src-tauri/src/skills/social_post.rs +++ b/src-tauri/src/skills/social_post.rs @@ -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") diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 2c80cf8a8..dd772dd35 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.3.0", + "version": "1.4.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cadd9c72..14cf01e0b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.3.0", + "version": "1.4.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 50cfd2a7c..315c2fb90 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -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, +): "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, +): 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("split"); - const [browserTaskPreflight, setBrowserTaskPreflight] = - useState(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) | 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, diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 72f112e9e..71a70e597 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -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("已中断"); }); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 1385448e5..9f39389c7 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -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, diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index b01c3a8dd..8f1cae64a 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -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("通用对话项目选择器应启用管理能力", () => { diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index e07548fe6..6bf5b53dc 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -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, -): string { - if (attentionLevel === "warning") { - return "恢复浏览器工作台"; - } - - if (attentionLevel === "info") { - return "查看浏览器工作台状态"; - } - - return "打开浏览器工作台"; } const toolbarGroupClassName = @@ -102,17 +81,8 @@ export const ChatNavbar: React.FC = ({ 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 = ({ 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 = ({ ) : null} - {showContextCompactionAction && - (shouldShowBrowserAssistEntry || showHarnessToggle) ? ( -