diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 63ac456e6..347fb2801 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,52 +1,52 @@ -## Lime v1.4.0 +## Lime v1.5.0 ### ✨ 主要更新 -- **通用工作台成为当前主壳**: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` +- **创建型工作台技能继续扩容**:在现有 `analysis / summary / translation / pdf_read / report_generate / research / site_search / image / cover / video / broadcast / url_parse / typesetting` 主链基础上,新增并收口 `form_generate`、`presentation_generate`、`webpage_generate`;前后端围绕 `*_skill_launch`、tool runtime、artifact 输出、任务预览和权限边界继续统一到当前 harness 主路径 +- **Agent 聊天工作台进一步收口到当前主链**:`AgentChatWorkspace`、`MessageList`、`EmptyState`、`Inputbar`、`CharacterMention`、`WorkspaceConversationScene`、`useWorkspaceSendActions`、`useAsterAgentChat`、`serviceSkillSceneLaunch` 等界面与运行时继续围绕通用工作台和服务技能入口收拢;消息预览、任务时间线、视频工作台、Token 使用展示与 slash / mention 入口同步增强 +- **站点适配器与浏览器运行时增强**:bundled site adapter 目录新增 `x/article-export`,站点能力选择、现有浏览器会话复用、保存到当前内容/项目、导入外部 YAML 适配器、server-synced catalog 回落策略与推荐链路进一步完善;扩展侧同步新增 site adapter runner 生成入口 +- **运行时状态与协议事实源继续收敛**:Agent session store、turn input envelope、tool io offload、runtime turn metadata、team / subagent 偏好、site capability、chat history、service skill catalog、artifact protocol 与 DevBridge HTTP client 一批边界继续补齐 current 语义和回归测试 +- **文档与治理目录同步更新**:`docs/aiprompts/command-runtime.md`、`commands.md`、`playwright-e2e.md`、`quality-workflow.md` 等工程文档已围绕当前服务技能、site adapter、GUI smoke 与运行时事实源刷新;默认技能目录同步纳入 `form / presentation / webpage / x article export` 相关说明 ### ⚠️ 发布与兼容性说明 -- 本次发布 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 仍仅作为开发联调手段,不属于发布事实源 +- 本次发布 tag 为 `v1.5.0`,应用内版本号保持为 `1.5.0` +- `@limecloud/lime-cli@1.5.0` 要求 `Node >= 18`,支持 `darwin / linux / win32` 与 `x64 / arm64` +- 当前 Agent GUI 主路径继续以 `GeneralWorkbench*` 与服务技能启动边界为准;旧表面不应再作为 current surface 扩展 +- 当前默认技能目录已覆盖 `analysis`、`broadcast_generate`、`form_generate`、`image_generate`、`modal_resource_search`、`pdf_read`、`presentation_generate`、`report_generate`、`research`、`site_search`、`summary`、`translation`、`typesetting`、`url_parse`、`video_generate`、`webpage_generate` +- `aster-rust` 依赖已固定到远程 tag `v0.27.1`;本地 `.cargo/config.toml` patch override 仍仅用于开发联调,不属于发布事实源 ### 🔗 依赖与版本同步 -- 应用版本已同步提升到 `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` +- 应用版本已同步提升到 `1.5.0`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `packages/lime-cli-npm/package.json` 与 README 发布示例已同步更新到 `1.5.0` +- `src-tauri/Cargo.lock` 已刷新:工作区内部 crate 版本快照已对齐到 `1.5.0`,`aster-core` / `aster-models` 已对齐到 `0.27.1` ### 🧪 发布前校验 - `npm run verify:app-version` - `cargo fmt --manifest-path src-tauri/Cargo.toml --all` +- `CARGO_TARGET_DIR=src-tauri/target/codex-v1_5_0 cargo test --manifest-path src-tauri/Cargo.toml` +- `CARGO_TARGET_DIR=src-tauri/target/codex-v1_5_0 cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` - `npm run lint` - `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` +- `CARGO_TARGET_DIR=src-tauri/target/codex-v1_5_0 npm run verify:gui-smoke` - 当前结果: - `npm run verify:app-version`:通过 - `cargo fmt --manifest-path src-tauri/Cargo.toml --all`:通过 + - `cargo test --manifest-path src-tauri/Cargo.toml`:通过,`852` 个单测全部通过,2 个真实联网 smoke 用例按预期忽略 + - `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`:通过 - `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`:通过 + - 说明:首次复用现有 headless 环境时,`browser-runtime` smoke 出现一次 CDP 标签页读取瞬时失败;单独重跑 `smoke:browser-runtime` 后通过,随后整条 `verify:gui-smoke` 复跑通过,未见持续性故障 ### 📝 文档同步 -- 发布说明已切换到当前这次 `v1.4.0` 稳定版发布内容,可直接作为 GitHub Release note 使用 -- 服务技能主链、命令运行时、GUI 续测与工程质量文档已与当前实现同步 -- 通用工作台命名、默认技能目录与命令运行时事实源已围绕当前实现完成收口 +- 发布说明已切换到当前这次 `v1.5.0` 稳定版发布内容,可直接作为 GitHub Release note 使用 +- 默认技能目录、site adapter catalog 与运行时事实源文档已同步到当前实现 +- 服务技能主链、站点能力主链、GUI smoke 与契约边界文档均已围绕当前实现刷新 --- -**完整变更**: `v1.3.0` -> `v1.4.0` +**完整变更**: `v1.4.0` -> `v1.5.0` diff --git a/docs/aiprompts/command-runtime.md b/docs/aiprompts/command-runtime.md index b2a4e63b8..7ff642cc1 100644 --- a/docs/aiprompts/command-runtime.md +++ b/docs/aiprompts/command-runtime.md @@ -69,7 +69,16 @@ Lime 的命令体系固定按以下关系理解: 对图片任务再补一条固定约束: -`@配图/@修图/@重绘` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;文稿 inline 配图、封面位、图片工作台编辑/变体这类显式图片动作也一样,必须先组装 `image_task` 上下文后再复用统一发送主线。不要把 current 主链重新改回前端预翻 slash skill、前端直建任务或“按钮直调 task API”。 +`@配图/@修图/@重绘` 原始文本必须先进入 Agent turn,再由 `harness.image_skill_launch` 辅助首刀 `Skill(image_generate)`;文稿 inline 配图、封面位、图片工作台编辑/变体这类显式图片动作也一样,必须先组装 `image_task` 上下文后再复用统一发送主线。不要把 current 主链重新改回前端预翻 slash skill、前端直建任务或“按钮直调 task API”。图片 launch 还必须显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用偏航工具,并在必要时直接从当前 session tool surface 移除这些 detour tools,避免模型在“搜技能目录”里空转或把权限错误暴露给用户。默认 `Bash -> lime media image generate --json` 入口也必须把 task file 真正推进到完成态;兼容入口 `lime task create image --json` 现在也必须复用同一条图片执行链,不能再只停在“任务已创建 / pending_submit”。即使退回 compat 的 `lime_create_image_generation_task`,也必须委托同一条 task artifact + worker 执行链,并禁止把任务改写到 `outputPath` / markdown 文稿。 +- 显式图片动作允许先在前端补 `image_skill_launch` metadata,但发送前的 `session_id` 绑定仍必须走统一发送边界;如果 metadata 里暂时还是本地 draft key,必须在真正发起 send 时替换成真实会话 ID,而不是在图片动作入口提前额外建一个图片专用会话。 +- `.lime/tasks/**/*.json` 继续作为图片主链的唯一恢复事实源,但它们属于内部任务快照,默认不应直接渲染成用户可见 artifact 卡片或时间线文件卡;用户面看到的应该是轻结果卡、工具过程和右侧查看。 + +图片结果进入 UI 时还必须遵守以下 viewer 收口规则: + +- 图片任务的主结果事实源是 `image task preview + 图片工作台 outputs`,不是通用文本 artifact +- 空内容的二进制图片文件(如 `output_image.jpg`)不能再镜像成通用 artifact 卡片,否则会出现“重复文件卡 + 点不开”的假结果 +- `tool_result` 产物在 general workspace 中默认后台入库,不自动选中、不自动展开右侧工作台;抢焦点只允许发生在用户显式点击或仍在流式写入的文档类产物上 +- 同一产物路径的 `basename / 相对路径 / 绝对路径` 必须在前端视为同一文件,避免一张图被重复挂成多份结果 不要再把命令能力直接叙述成: @@ -108,6 +117,11 @@ Lime 的命令体系固定按以下关系理解: - `lime_run_service_skill` 再根据当前 turn 绑定的 `serviceSkillId + OEM runtime` 发起服务端 run / 短轮询,保证 slash scene 也走 `Agent -> tool -> timeline` 主链 - 未命中统一目录的 slash 文本必须继续回到普通 slash 流程,不能被错误吞成“未找到本地 Skill” +如果 `scene` 绑定的是 `site_adapter / browser_assist` 型技能,还要额外遵守两条边界: + +- 用户可见入口继续以 `entries.kind=scene` 为准,不要求把底层 site skill 强行暴露成首页技能卡;但运行时解析 `scene -> linkedSkillId` 时,不能只依赖首页可见 skill 列表,必须能回退完整 `ServiceSkill` 目录做绑定解析,否则会出现“slash 菜单里能选、发送时却找不到 skill”的假入口 +- 参数补齐协议继续只落在 `slotSchema`;如果未来要在 slash 场景或技能入口里弹参数表单,可以在渲染层把 `slotSchema` 映射成 `a2ui`,但不要把 `a2ui` 结构写进 `SkillCatalog`、`request_metadata` 或 runtime 协议 + 一句话: > 目录发现要服务端优先,但体验稳定性必须由客户端 seeded/fallback 托底。 @@ -142,6 +156,7 @@ Lime 的命令体系固定按以下关系理解: - 文稿 inline 配图、封面位、图片工作台编辑/变体等显式动作也必须补成同构的 `harness.image_skill_launch`,而不是绕过 Agent 直建任务 - 前端只负责补 `harness.image_skill_launch` 这类结构化上下文,不负责预翻成 slash skill 或偷偷发起 task - Agent 首刀优先调用 `Skill(image_generate)`,再由 skill / CLI / task file 链路继续执行 +- 不要为了“找技能”再先走 `ToolSearch`;如果运行时发现 `@配图` 在 `ToolSearch / WebSearch / Read / Glob / Grep` 上空转,应视为图片主链断裂 - 聊天区轻卡与 viewer 只消费后端真实运行态,不伪造“已完成” `@素材` 在这个分型里是一个混合分流特例: diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index a245f8a4d..7de3daa32 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -88,7 +88,7 @@ - `CharacterMention`、`builtinCommands`、场景 slash 补全不得再各自维护一套业务命令静态常量 - 服务端尚未返回 `entries` 时,允许网关层从 legacy `items` 兼容投影出 `entries` -- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@搜索`、`@深搜`、`@研报`、`@站点搜索`、`@读PDF`、`@总结`、`@翻译`、`@分析`、`@转写` 这类主链入口失能 +- 客户端必须保留 seeded fallback,不能因为服务端暂时不可用就让 `@配图`、`@PPT`、`@表单`、`@网页`、`@代码`、`@发布`、`@搜索`、`@深搜`、`@研报`、`@站点搜索`、`@读PDF`、`@总结`、`@翻译`、`@分析`、`@转写` 这类主链入口失能 - `src/components/agent/chat/commands/catalog.ts` 只继续承接 Lime 本地 / Codex 原生命令;产品型 `/` 场景不应再长期硬编码在这里 - 若服务端下发的 `renderContract` 超出 Lime 当前支持范围,优先由服务端回退到已支持类型,客户端也必须退化到通用 timeline / artifact 展示 @@ -100,6 +100,8 @@ - `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 不存在 +- 如果某个 scene 背后绑定的是 `site_adapter / browser_assist` 型技能,前端可以继续只暴露 `scene`,不必把底层 site skill 再平铺成首页目录项;但运行时解析 `scene -> linkedSkillId` 时必须能回退完整 `ServiceSkill` 目录,而不是只看首页可见 skill 列表,否则会出现目录可见但执行找不到 skill 的协议漂移 +- scene 或技能补参继续只声明 `slotSchema`;若后续要在 GUI 里补 `a2ui` 表单,也只能作为渲染层实现细节,不能把 `a2ui` 类型耦合进 `SkillCatalog`、`request_metadata.harness` 或 Tauri 命令契约 如果这轮改动触达了 `client/skills` 协议,不仅要改 Lime 前端 selector,还要同步检查 `limecore` 的: @@ -118,7 +120,10 @@ `Claw` 的图片任务当前已经收敛到同一条 current 主链: -- 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。 +- 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)` 的系统提示。当前图片 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型把 `@配图` 卡死在“先搜工具目录”或把权限报错直接暴露给用户。后续默认 skill 继续优先走 `Bash -> lime media image generate --json`;该 CLI 当前主链必须创建 task artifact 后同步推进到 `queued/running/succeeded|partial|failed`,而不是停在 `pending_submit`。兼容入口 `lime task create image --json` 也必须委托同一条执行链,不能再只写 task file 不执行。CLI 不可用时再回退 `lime_create_image_generation_task`,但 compat tool 也必须复用同一个 `image_generate` task artifact + worker 执行链,且忽略 `outputPath` 这类非标准落盘出口,最终仍只落到标准 task file。 +- 显式图片动作如果先在前端补好了 `image_skill_launch` metadata,也必须继续复用统一发送边界去绑定真实 `session_id`。不要在图片动作侧为了拿 `session_id` 再额外 `createFreshSession(...)`,否则一次 `@配图` 会被拆成两个对话;当前正确做法是允许 metadata 先带本地 draft key,再在发送前统一替换成真实会话 ID。 +- 图片结果展示固定继续走 `聊天轻卡 -> 图片工作台` 主链:通用 `tool_result` 只保留 timeline 与轻卡,不应把空内容的 `.jpg/.png/.webp` 二进制结果再镜像成通用 artifact 卡片;否则会出现重复 `output_image.jpg`、路径不一致导致去重失败、以及点击后无法在文本 workbench 打开的错误体验。 +- 通用 artifact 层对同一路径必须做等价归一:`basename / 相对路径 / 绝对路径` 在前端应视作同一文件;`tool_result` 来源的产物默认后台更新,不自动选中、不自动展开工作台,避免命令执行过程中抢焦点。 - 图片 task 控制面:`src/lib/api/mediaTasks.ts` 继续承接 task control / replay / recovery,而不是首发入口: - `create_image_generation_task_artifact` @@ -127,6 +132,7 @@ - `cancel_media_task_artifact` 无论入口来自纯文本命令、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` 为唯一事实源,不允许重新回到前端直连图片服务。 +- `.lime/tasks/**/*.json` 本身是内部任务状态快照,不是面向用户的正式产物。聊天区 artifact 卡片、时间线 file artifact 与默认文件面板都应把这类 JSON 隐藏掉;它们只服务恢复、轮询、取消、重试和诊断,真正给用户看的应该是轻量结果卡、tool timeline 与右侧 viewer。 Workspace `Bash` 运行时在当前主链中应优先解析同名 `lime` 入口:开发态优先回落到 `cargo run -p lime-cli`,打包态优先使用随应用提供的 CLI 二进制。默认 skill 若已经切到 `Bash -> lime media ...`,仍应保留 compat tool 作为兜底,避免在 CLI 暂不可用时把用户流量打断。 @@ -140,64 +146,84 @@ Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/li `Claw` 的纯文本封面命令也应沿同一条 current 主链收敛: -- Agent 驱动的封面命令:`@封面` / `@cover` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `cover_task` 写入 `request_metadata.harness.cover_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/cover_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(cover_generate)` 的系统提示。后续默认 skill 继续优先走 `social_generate_cover_image + Bash -> lime task create cover --json`,CLI 不可用时再回退 `lime_create_cover_generation_task`,最终仍只允许落到标准 `cover_generate` task file。 +- Agent 驱动的封面命令:`@封面` / `@cover` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `cover_task` 写入 `request_metadata.harness.cover_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/cover_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(cover_generate)` 的系统提示;当前封面 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@封面` 首刀前先去搜索工具目录。后续默认 skill 继续优先走 `social_generate_cover_image + Bash -> lime task create cover --json`,CLI 不可用时再回退 `lime_create_cover_generation_task`,最终仍只允许落到标准 `cover_generate` task file。 `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` 任务主链。 +- 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)` 的系统提示;当前视频 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@视频` 首刀前先去搜索工具目录。后续默认 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 个关键问题,但不能伪造“播报已完成”。 +- 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)` 的系统提示;当前播报 launch 还会在 session permission 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类偏航工具,并在当前 session registry 中直接移除这些 detour tools,避免模型在 `@播报` 首刀前先去搜索工具目录。后续默认 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 个关键问题,但不能伪造“素材已检索完成”。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型卡在“先搜技能/工具目录”而不是立刻进素材技能主链。若 `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 个关键问题,但不能伪造“已完成搜索”,也不能直接凭记忆跳过检索。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / Read / Glob / Grep` 这类“工具目录发现/本地文件偏航”工具,避免模型在 `@搜索` 首刀前先去查工具名或误读本地文件,但会保留真实联网检索主链。后续默认 skill 必须沿 `research` prompt skill -> `search_query` / `WebSearch` 主链先真实联网检索,再输出结论、来源与建议;当前上下文缺少明确搜索主题时,允许 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 个关键问题,但不能伪造“已完成深搜”,也不能退化成只搜一次的普通搜索。 +- 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)`、且至少执行多轮扩搜的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / Read / Glob / Grep` 这类“工具目录发现/本地文件偏航”工具,避免模型在 `@深搜` 首刀前先去查工具名或误读本地文件,但会保留真实联网检索主链。后续默认 skill 仍必须沿 `research` prompt skill -> `search_query` / `WebSearch` 主链先真实联网检索,再输出事实、推断与待确认项;当前上下文缺少明确搜索主题时,允许 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 个关键问题,但不能伪造“研报已完成”,也不能直接退回普通聊天长文。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / Read / Glob / Grep` 这类“工具目录发现/本地文件偏航”工具,避免模型在 `@研报` 首刀前先去查工具名或误读本地文件,但会保留真实联网检索主链。后续默认 skill 必须沿 `report_generate` prompt skill -> `search_query` / `WebSearch` 主链先真实联网检索,再写出结构化研究报告;当前上下文缺少明确研报主题时,允许 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`。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用搜索/本地文件偏航工具,同时拦住 `mcp__lime-browser__* / browser_* / mcp__playwright__*` 这类底层浏览器兼容面,避免模型在 `@站点搜索` 首刀前先去搜工具目录或退回浏览器底层执行。后续默认 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 已读完”,也不能退回普通聊天总结。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Grep` 这类工具目录发现、联网搜索或内容检索偏航工具,但会保留 `Read / Glob` 这类本地 PDF 读取主链能力。后续默认 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。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Grep` 这类工具目录发现、联网检索或内容检索 detour tools,但会保留 `Read / Glob` 这类显式路径读取主链能力。后续默认 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。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Grep` 这类工具目录发现、联网检索或内容检索 detour tools,但会保留 `Read / Glob` 这类显式路径读取主链能力。后续默认 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。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Grep` 这类工具目录发现、联网检索或内容检索 detour tools,但会保留 `Read / Glob` 这类显式路径读取主链能力。后续默认 skill 必须沿 `analysis` prompt skill 主链先分析显式正文或当前对话相关上下文;只有当用户显式给出本地路径或目录时,才允许最小化使用 `list_directory / read_file` 读取必要内容并保留真实 tool timeline。当前上下文缺少显式正文时,允许 Agent 优先分析当前对话;只有在显式正文和对话上下文都不足时,才最多追问 1 个关键问题,但不能伪造“已完成分析”,也不能在前端直接生成分析结论绕过 skill。 `Claw` 的纯文本转写命令也应沿同一条 current 主链收敛: -- Agent 驱动的转写命令:`@转写` / `@transcribe` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `transcription_task` 写入 `request_metadata.harness.transcription_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/transcription_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(transcription_generate)` 的系统提示。后续默认 skill 继续优先走 `Bash -> lime task create transcription --json`,CLI 不可用时再回退 `lime_create_transcription_task`,最终仍只允许落到标准 `transcription_generate` task file;若当前上下文缺少 `source_url` / `source_path`,允许 Agent 最多追问 1 个关键问题,但不能伪造“已完成转写”。 +- Agent 驱动的转写命令:`@转写` / `@transcribe` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `transcription_task` 写入 `request_metadata.harness.transcription_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/transcription_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(transcription_generate)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型在 `@转写` 首刀前先去搜工具目录、联网检索或误读本地文件。后续默认 skill 继续优先走 `Bash -> lime task create transcription --json`,CLI 不可用时再回退 `lime_create_transcription_task`,最终仍只允许落到标准 `transcription_generate` task file;若当前上下文缺少 `source_url` / `source_path`,允许 Agent 最多追问 1 个关键问题,但不能伪造“已完成转写”。 `Claw` 的纯文本链接解析命令也应沿同一条 current 主链收敛: -- 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 个关键问题,但不能伪造“链接已解析完成”。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型在 `@链接解析` 首刀前先去搜工具目录、联网检索或误读本地文件。后续默认 skill 继续优先走 `Bash -> lime task create url-parse --json`,CLI 不可用时再回退 `lime_create_url_parse_task`,最终仍只允许落到标准 `url_parse` task file;若当前上下文缺少 URL,允许 Agent 最多追问 1 个关键问题,但不能伪造“链接已解析完成”。 `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 个关键问题,但不能伪造“排版已完成”。 +- 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)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型在 `@排版` 首刀前先去搜工具目录、联网检索或误读本地文件。后续默认 skill 继续优先走 `Bash -> lime task create typesetting --json`,CLI 不可用时再回退 `lime_create_typesetting_task`,最终仍只允许落到标准 `typesetting` task file;若当前上下文缺少待排版正文,允许 Agent 最多追问 1 个关键问题,但不能伪造“排版已完成”。 + +`Claw` 的纯文本网页命令也应沿同一条 current 主链收敛: + +- Agent 驱动的网页命令:`@网页` / `@webpage` / `@landing` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `webpage_request` 写入 `request_metadata.harness.webpage_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/webpage_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(webpage_generate)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型在 `@网页` 首刀前先去搜工具目录、联网检索或误读本地文件。后续默认 skill 必须沿 `webpage_generate` prompt skill 主链直接产出单文件 HTML artifact,并通过 `` 落到工作区;当前上下文缺少明确网页目标时,允许 Agent 最多追问 1 个关键问题,但不能只给口头方案、不能伪造“网页已生成”却没有真实 `.html` 文件。 + +`Claw` 的纯文本 PPT 命令也应沿同一条 current 主链收敛: + +- Agent 驱动的演示稿命令:`@PPT` / `@ppt` / `@slides` / `@演示` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `presentation_request` 写入 `request_metadata.harness.presentation_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/presentation_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(presentation_generate)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型在 `@PPT` 首刀前先去搜工具目录、联网检索或误读本地文件。后续默认 skill 必须沿 `presentation_generate` prompt skill 主链直接产出单文件 Markdown 演示稿 artifact,并通过 `` 落到工作区;当前上下文缺少明确演示目标时,允许 Agent 最多追问 1 个关键问题,但不能只给口头提纲、不能伪造“PPT 已生成”却没有真实演示稿文件。 + +`Claw` 的纯文本表单命令也应沿同一条 current 主链收敛: + +- Agent 驱动的表单命令:`@表单` / `@form` / `@survey` / `@问卷` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `form_request` 写入 `request_metadata.harness.form_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/form_skill_launch.rs` 会给当前 turn 注入只允许首刀优先调用 `Skill(form_generate)` 的系统提示,并在当前 session permission 与 registry 中显式压制 `ToolSearch / WebSearch / Read / Glob / Grep` 这类 detour tools,避免模型在 `@表单` 首刀前先去搜工具目录、联网检索或误读本地文件。后续默认 skill 必须沿 `form_generate` prompt skill 主链直接产出一份可被现有 A2UI parser 识别的 simple form JSON,并以 ` ```a2ui ` 代码块回到聊天流;current render contract 必须是 `form + json`,不能回退成单文件 HTML artifact,也不能再发明另一套表单 DSL。当前上下文缺少明确表单目标时,允许 Agent 最多追问 1 个关键问题,但不能只给口头字段建议、不能伪造“表单已生成”却没有真实 A2UI 表单结果。 + +`Claw` 的纯文本代码命令也应沿同一条 current 主链收敛: + +- Agent 驱动的代码命令:`@代码` / `@code` / `@coding` / `@开发` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本发送。聊天发送边界会把结构化 `code_command` 写入 `request_metadata.harness.code_command`,并把本次发送的 `execution_strategy` 强制切到 `code_orchestrated`,同时把 `request_metadata.harness.preferred_team_preset_id` 设为 `code-triage-team`,且把 `harness.preferences.task/subagent` 打开。当前主链不新增 prompt skill,也不新增 HTML / artifact 协议,而是直接复用现有 `code_orchestrated -> code_execution / tools / team runtime`。这意味着 `@代码` 首刀应优先进入真实代码工具与协作编排,而不是退回普通聊天、先做 `ToolSearch` 目录探索,或把代码任务伪装成一段口头建议;若当前上下文只够做解释或评审,允许 Agent 在同一主链中按 `code_command.kind` 调整策略,但不能绕回另一套命令体系。 + +`Claw` 的纯文本发布命令当前应收敛到现有发布工作流,而不是新开一条平行 runtime: + +- 工作流入口型命令:`@发布` / `@publish` / `@发文` / `@投稿` 在 `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` 中保留原始用户文本展示,但把实际 dispatch 改写到现有 `/content_post_with_cover ...` 主链,并把结构化 `publish_command` 写入 `request_metadata.harness.publish_command`。当前实现优先复用已有 `content_post_with_cover` 发布工作流、`content-posts/*.md` / `*.publish-pack.json` 产物链,以及 `detectBrowserTaskRequirement(...)` 推导出的浏览器门禁,而不是再发明新的 `publish_task` 协议。若输入里已明确平台后台,如微信公众号后台,必须继续写入 `browser_requirement=required_with_user_step` 与平台 launch URL;若只是整理发布稿而未指定平台,则允许先在同一工作流里生成发布稿与发布前检查,不强行要求浏览器。后续若统一 agent/workflow runtime 成熟,可以把 `@发布` 从当前 slash workflow 迁走,但在那之前不得同时维护第二套发布入口真相。 这些命令除了 Tauri `generate_handler!` 之外,也必须继续保持 DevBridge dispatcher 已桥接,避免浏览器模式、headless smoke 或 Playwright 续测时回退成 unknown command。 @@ -450,6 +476,7 @@ npm run verify:local - **站点 Agent 工具主链**:继续收敛到 `lime_site_list / lime_site_recommend / lime_site_search / lime_site_info / lime_site_run` - **站点技能首页入口主链**:首页 / 工作区弹窗只负责补参数、组装 `initialUserPrompt + harness.service_skill_launch` 上下文并进入 `Claw`;真正执行统一收口到 `Claw` 首回合,不再由首页弹窗或工作区挂载副作用直接调用 `site_run_adapter` - **站点结果沉淀主线**:`site_run_adapter` / `lime_site_run` 优先透传 `content_id` 写回当前主稿;只有缺少 `content_id` 时,才回退到 `project_id` 新建结果文档 +- **`markdown_bundle` 落盘回传主线**:当站点结果是 `markdown_bundle` 时,`saved_content` 除了 `content_id / project_id / title`,还应继续回传 `project_root_path / markdown_relative_path / images_relative_dir / meta_relative_path / image_count`,让聊天轻卡与 tool timeline 都能直接说明 Markdown 和图片实际保存到哪里 - **Claw 站点直跑门禁主链**:`site_get_adapter_launch_readiness` 只负责检测“是否存在已附着的真实浏览器会话 + 目标站点上下文”;`site_run_adapter.require_attached_session = true` 时,后端必须拒绝 managed/default fallback,不能后台偷偷起 Chrome - **attached-session 执行主链**:真实浏览器附着场景下,Bridge `run_adapter` 只允许下发 `adapter_name + args`,禁止继续透传原始脚本文本到扩展 content script,以免触发站点 CSP 的 `unsafe-eval` - **站点运行失败语义**:`SiteAdapterRunResult` 至少统一输出 `auth_required / no_matching_context / adapter_runtime_error`,并在前端与 Agent 结果里保留 `report_hint` diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index 2c6c201fc..369d3132c 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -184,7 +184,7 @@ npm run test:contracts 1. 在 `Claw` 对话框输入 `@配图 生成 ...` 2. 确认聊天区先进入 skill 执行态,并能看到 `image_generate` 相关工具轨迹,而不是前端静默直接创建任务 -3. 如当前环境走 `Bash -> lime media image generate --json`,确认工具标题与结果摘要对应这条 CLI 主链;CLI 不可用时,才允许回退 `lime_create_image_generation_task` +3. 如当前环境走 `Bash -> lime media image generate --json`,确认工具标题与结果摘要对应这条 CLI 主链;若实际走到兼容入口 `lime task create image --json`,也要确认它继续推进到真实完成态,而不是只创建任务文件;CLI 不可用时,才允许回退 `lime_create_image_generation_task` 4. 等待 task file 回流后,确认同一条卡片被替换为成功或失败状态,而不是额外再插一条前端本地伪造结果 5. 刷新页面或切换会话再返回原话题,确认最近图片任务会从 `.lime/tasks` 恢复 6. 如手动打开右侧查看器,确认任务卡状态与聊天区一致,且不会自动展开独立图片画布 @@ -223,6 +223,42 @@ npm run test:contracts 4. 等待结果完成后,确认最终输出包含结论、来源、风险/待确认项与建议动作,而不是一段无来源的纯主观总结 5. 如果输入里没有明确主题,确认 Agent 最多只追问 1 个关键问题,而不是直接伪造研报完成态 +### Claw `@PPT` Prompt Skill 验证 + +1. 在 `Claw` 对话框输入 `@PPT 类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿` +2. 确认聊天区先进入 skill 执行态,并能看到 `presentation_generate` 相关工具轨迹,而不是前端本地直接伪造演示提纲 +3. 确认首刀不会卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,而是直接进入 `Skill(presentation_generate)` +4. 等待产物回流后,确认当前话题下出现一份真实演示稿 artifact,而不是只有文本解释 +5. 打开右侧查看区,确认演示稿能够作为 Markdown artifact 正常预览,并保留封面、目录、核心论点、案例和结论结构 +6. 刷新页面或切换会话后再返回原话题,确认演示稿 artifact 仍能恢复,不依赖前端内存态 + +### Claw `@表单` Prompt Skill 验证 + +1. 在 `Claw` 对话框输入 `@表单 类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表` +2. 确认聊天区先进入 skill 执行态,并能看到 `form_generate` 相关工具轨迹,而不是前端本地直接伪造字段列表 +3. 确认首刀不会卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,而是直接进入 `Skill(form_generate)` +4. 等待结果回流后,确认同一条对话消息里出现可渲染的 A2UI 表单,而不是单文件 HTML artifact 或一段纯文本建议 +5. 打开右侧查看区或表单详情时,确认结果仍然是 simple form JSON / A2UI 预览,不会切到另一套自定义表单 DSL +6. 刷新页面或切换会话后再返回原话题,确认表单结果仍能恢复,不依赖前端内存态 + +### Claw `@代码` 编排主链验证 + +1. 在 `Claw` 对话框输入 `@代码 修复消息历史切换后图片卡片丢失的问题,并补一个回归测试` +2. 确认聊天区先进入代码编排执行态,而不是普通聊天回答 +3. 确认真正出现代码工具或协作步骤时间线,且不会先卡在无意义的 `ToolSearch` +4. 确认执行策略切到 `code_orchestrated`,同时 `task/subagent` 偏好已被打开 +5. 如当前页面可查看运行时摘要或请求详情,确认 `preferred_team_preset_id=code-triage-team` 与 `code_command.kind` 已注入 +6. 刷新页面或切换会话后再返回原话题,确认代码任务对话仍保留在同一条消息主链,不会裂成另一套旁路会话 + +### Claw `@发布` 工作流验证 + +1. 在 `Claw` 对话框输入 `@发布 平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本` +2. 确认聊天区显示的仍是原始 `@发布 ...` 文本,而不是直接把 slash skill 暴露给用户 +3. 如页面可查看发送详情或运行时摘要,确认实际 dispatch 已导向 `content_post_with_cover`,且 `publish_command` 元数据存在 +4. 确认当前回合如果命中平台后台,会出现真实浏览器门禁提示,而不是直接退化成联网搜索或普通聊天 +5. 等待工作流继续推进后,确认产物仍落在现有 `content-posts/*.md` / `*.publish-pack.json` 主链,而不是另一套发布任务协议 +6. 刷新页面或切换会话后再返回原话题,确认发布稿与发布包仍可恢复 + ### Claw `@读PDF` Prompt Skill 验证 1. 在 `Claw` 对话框输入 `@读PDF /tmp/agent-report.pdf 提炼三点结论并标注关键证据` @@ -256,6 +292,9 @@ npm run test:contracts 4. 打开时间线,确认首个执行器是 `lime_run_service_skill`,而不是前端本地直接产出结果卡 5. 如当前 OEM 会话可用,确认工具结果会回流 run 状态或摘要;若当前会话缺失,确认聊天区明确提示需要登录或注入会话,而不是伪造成功 6. 未命中 scene 目录时,确认 `/unknown-scene ...` 仍回到普通 slash / Codex 流程,不会被误报为本地技能异常 +7. 如果当前 scene 绑定的是站点型 skill,例如 `/x文章转存 https://x.com/.../article/...`,确认 slash 菜单可见后仍能成功解析到底层 site skill,而不是因为首页未暴露 site skill 就在发送时失配 +8. 对 `markdown_bundle` 型站点场景,确认成功后项目目录里同时出现 `index.md`、`images/` 和 `meta.json`,且正文中的图片链接已经被改写为项目内相对路径,而不是继续指向远程图片 URL +9. 同时确认聊天轻卡或 tool timeline 会明确展示项目目录、Markdown 相对路径和图片数量,避免用户只能看到“已保存”却不知道文件实际落点 ### 开发者页站点来源导入验证 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 3628404ad..5a4068a22 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -183,19 +183,30 @@ 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` 主链,以及显式图片动作是否也已经走 `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` 主链,而不是退回普通聊天润色 +- 检查纯文本 `Claw @配图` 是否已经走 `原始用户消息 -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> Bash/lime media image generate --json 或 lime_create_image_generation_task -> task/timeline` 主链,以及显式图片动作是否也已经走 `synthetic user message / displayContent -> harness.image_skill_launch -> Agent 首刀 Skill(image_generate) -> task/timeline`,而不是回流前端直连图片服务、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或让 `lime media image generate --json` / `lime task create image --json` 只停在 `pending_submit` +- 检查纯文本 `Claw @封面` 是否已经走 `原始用户消息 -> harness.cover_skill_launch -> Agent 首刀 Skill(cover_generate) -> task file` 主链,而不是回流成普通图片命令、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果 +- 检查纯文本 `Claw @视频` 是否已经走 `原始用户消息 -> harness.video_skill_launch -> Agent 首刀 Skill(video_generate) -> Bash/lime media video generate --json 或 create_video_generation_task -> task/timeline` 主链,而不是卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果 +- 检查纯文本 `Claw @播报` 是否已经走 `原始用户消息 -> harness.broadcast_skill_launch -> Agent 首刀 Skill(broadcast_generate) -> task file` 主链,而不是退回普通聊天改写、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或前端本地伪造结果 +- 检查纯文本 `Claw @素材` 是否已经走 `原始用户消息 -> harness.resource_search_skill_launch -> Agent 首刀 Skill(modal_resource_search) -> 图片直搜时优先 lime_search_web_images / 其余情况走 task file` 主链,而不是回流到前端本地素材页逻辑、卡在 `ToolSearch / WebSearch / Read / Glob / Grep`,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @搜索` 是否已经走 `原始用户消息 -> harness.research_skill_launch -> Agent 首刀 Skill(research) -> search_query / tool timeline` 主链,而不是直接凭模型记忆回答、卡在 `ToolSearch / Read / Glob / Grep` 这类工具目录/本地文件偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @深搜` 是否已经走 `原始用户消息 -> harness.deep_search_skill_launch -> Agent 首刀 Skill(research) -> 多轮 search_query / tool timeline` 主链,而不是退化成一次普通搜索、卡在 `ToolSearch / Read / Glob / Grep` 这类工具目录/本地文件偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @研报` 是否已经走 `原始用户消息 -> harness.report_skill_launch -> Agent 首刀 Skill(report_generate) -> search_query / tool timeline` 主链,而不是直接退回普通聊天长文、卡在 `ToolSearch / Read / Glob / Grep` 这类工具目录/本地文件偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @站点搜索` 是否已经走 `原始用户消息 -> harness.site_search_skill_launch -> Agent 首刀 Skill(site_search) -> lime_site_* / tool timeline` 主链,而不是先退回 `research / WebSearch`、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用搜索/本地文件偏航,或把浏览器兼容工具权限拒绝直接暴露给用户 +- 检查纯文本 `Claw @读PDF` 是否已经走 `原始用户消息 -> harness.pdf_read_skill_launch -> Agent 首刀 Skill(pdf_read) -> list_directory / read_file / tool timeline` 主链,而不是退回普通聊天总结或前端本地解析、卡在 `ToolSearch / WebSearch / Grep` 这类工具目录/联网检索偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @总结` 是否已经走 `原始用户消息 -> harness.summary_skill_launch -> Agent 首刀 Skill(summary) -> 可选 list_directory / read_file / tool timeline` 主链,而不是退回普通聊天总结、卡在 `ToolSearch / WebSearch / Grep` 这类工具目录/联网检索偏航,或把 session permission 拒绝直接暴露给用户;同时确认 `Read / Glob` 仍保留给显式路径场景 +- 检查纯文本 `Claw @翻译` 是否已经走 `原始用户消息 -> harness.translation_skill_launch -> Agent 首刀 Skill(translation) -> 可选 list_directory / read_file / tool timeline` 主链,而不是退回普通聊天翻译、卡在 `ToolSearch / WebSearch / Grep` 这类工具目录/联网检索偏航,或把 session permission 拒绝直接暴露给用户;同时确认 `Read / Glob` 仍保留给显式路径场景 +- 检查纯文本 `Claw @分析` 是否已经走 `原始用户消息 -> harness.analysis_skill_launch -> Agent 首刀 Skill(analysis) -> 可选 list_directory / read_file / tool timeline` 主链,而不是退回普通聊天分析、卡在 `ToolSearch / WebSearch / Grep` 这类工具目录/联网检索偏航,或把 session permission 拒绝直接暴露给用户;同时确认 `Read / Glob` 仍保留给显式路径场景 +- 检查纯文本 `Claw @转写` 是否已经走 `原始用户消息 -> harness.transcription_skill_launch -> Agent 首刀 Skill(transcription_generate) -> task file` 主链,而不是回流到前端直连旧 ASR 接口、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @链接解析` 是否已经走 `原始用户消息 -> harness.url_parse_skill_launch -> Agent 首刀 Skill(url_parse) -> task file` 主链,而不是退回普通聊天总结、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @排版` 是否已经走 `原始用户消息 -> harness.typesetting_skill_launch -> Agent 首刀 Skill(typesetting) -> task file` 主链,而不是退回普通聊天润色、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或把 session permission 拒绝直接暴露给用户 +- 检查纯文本 `Claw @网页` 是否已经走 `原始用户消息 -> harness.webpage_skill_launch -> Agent 首刀 Skill(webpage_generate) -> write_file HTML artifact` 主链,而不是退回普通聊天口头方案、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或没有真实 `.html` 文件就宣布完成 +- 检查纯文本 `Claw @PPT` 是否已经走 `原始用户消息 -> harness.presentation_skill_launch -> Agent 首刀 Skill(presentation_generate) -> write_file Markdown artifact` 主链,而不是退回普通聊天口头提纲、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或没有真实演示稿文件就宣布完成 +- 检查纯文本 `Claw @表单` 是否已经走 `原始用户消息 -> harness.form_skill_launch -> Agent 首刀 Skill(form_generate) -> ```a2ui simple form JSON` 主链,而不是退回普通聊天字段建议、卡在 `ToolSearch / WebSearch / Read / Glob / Grep` 这类通用工具偏航,或回流成单文件 HTML 表单原型;同时确认 render contract 已收敛为 `form + json` +- 检查纯文本 `Claw @代码` 是否已经走 `原始用户消息 -> harness.code_command + preferred_team_preset_id -> code_orchestrated -> code_execution / tools / team runtime` 主链,而不是继续停留在普通聊天、没有打开 `task/subagent` 偏好,或把代码任务改写成另一套 prompt / workflow 旁路 +- 检查纯文本 `Claw @发布` 是否已经走 `原始用户消息 -> displayContent 保留 -> dispatch /content_post_with_cover -> content_post workflow` 主链,而不是直接把 `@发布` 文本原样当普通聊天发送,或重新造一套 `publish_task` 协议;同时确认平台后台类输入会继续触发 `browser_requirement` - 检查产品型 `/scene-key` 是否已经走 `原始用户消息 -> harness.service_scene_launch -> Agent 首刀 lime_run_service_skill -> OEM run/timeline` 主链,而不是前端直接调用云端 run API +- 如果某个 `/scene-key` 绑定的是 `site_adapter` 型技能,还要额外检查 `scene -> linkedSkillId -> 完整 ServiceSkill 目录 -> harness.service_skill_launch` 这条绑定链是否仍然成立,避免首页隐藏 site skill 后 slash scene 变成“目录可见但执行找不到 skill” +- 如果某个 `site_adapter` 结果开始返回 `markdown_bundle`,还要确认保存链会把 Markdown、图片和 `meta.json` 一起落到项目导出目录,并把重写后的相对图片路径写回内容 metadata;同时确认聊天轻卡或 tool timeline 能显示项目目录、Markdown 路径和图片数量,不能只把远程图片 URL 或临时 DOM 文本留在聊天结果里 高频场景: @@ -215,7 +226,7 @@ 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`、`useWorkspaceImageWorkbenchActionRuntime`、`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/timeline` 的异步图片任务主链 - 修改 `@封面` 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` 主链 @@ -230,6 +241,10 @@ npm run bridge:health -- --timeout-ms 120000 - 修改 `@转写` 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` 主链 +- 修改 `@网页` parser、`useWorkspaceSendActions`、`runtime_turn`、`webpage_skill_launch`、`webpage_generate` skill 或 HTML artifact 预览链路,尤其是调整 `Claw @网页 -> harness.webpage_skill_launch -> Agent 首刀 Skill(webpage_generate) -> write_file HTML artifact` 主链 +- 修改 `@PPT` parser、`useWorkspaceSendActions`、`runtime_turn`、`presentation_skill_launch`、`presentation_generate` skill 或演示稿 artifact 预览链路,尤其是调整 `Claw @PPT -> harness.presentation_skill_launch -> Agent 首刀 Skill(presentation_generate) -> write_file Markdown artifact` 主链 +- 修改 `@代码` parser、`useWorkspaceSendActions`、mention builtin command 或 `code_orchestrated` 发送边界,尤其是调整 `Claw @代码 -> harness.code_command -> code_orchestrated -> tools / team runtime` 主链 +- 修改 `@发布` parser、`useWorkspaceSendActions`、content post workflow 入口或浏览器门禁推导,尤其是调整 `Claw @发布 -> displayContent/raw -> /content_post_with_cover -> publish workflow` 主链 - 修改 `/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/` @@ -239,6 +254,8 @@ npm run bridge:health -- --timeout-ms 120000 如果本轮修改了 `Claw @配图` 或图片任务 artifact 回填语义,最低校验至少包含: - `npm run test:contracts` +- `cd src-tauri && cargo test test_merge_system_prompt_with_image_skill_launch_appends_prompt` +- `cd src-tauri && cargo test test_append_image_skill_launch_session_permissions_blocks_detour_tools` - `imageWorkbenchCommand`、`useWorkspaceSendActions`、受影响 skill / image task Hook 单测,以及 `aster_agent_cmd` 图片主链定向测试 - 如果本轮还改了显式图片动作入口,例如文稿 inline 配图、封面位或图片工作台编辑/重绘,额外覆盖 `useWorkspaceImageWorkbenchActionRuntime` 或对应发送桥接回归 - 若本轮还改了显式 `execute_skill` 的 `images / requestContext` 透传或 compat 续接,额外覆盖 `skillCommand` 回归 @@ -255,95 +272,137 @@ npm run bridge:health -- --timeout-ms 120000 如果本轮修改了 `Claw @封面` 或封面任务协议,最低校验至少包含: -- `coverWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 封面主链定向测试 +- `coverWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 封面主链 / detour tool 限制定向测试 - `lime-cli` 封面任务创建回归、受影响的默认 skill / tool catalog 测试 - `npm run test:contracts` - `npm run verify:gui-smoke` +如果本轮修改了 `Claw @视频` 或视频任务协议,最低校验至少包含: + +- `videoWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 视频主链 / detour tool 限制定向测试 +- `lime media video generate` 回归、受影响的默认 skill / tool catalog 测试 +- `npm run test:contracts` +- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` + 如果本轮修改了 `Claw @播报` 或播报任务协议,最低校验至少包含: -- `broadcastWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 播报主链定向测试 +- `broadcastWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 播报主链 / detour tool 限制定向测试 - `lime-cli` 播报任务创建测试、受影响的默认 skill / tool catalog 测试 - `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` 如果本轮修改了 `Claw @素材` 或素材检索任务协议,最低校验至少包含: -- `resourceSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 素材检索主链定向测试 +- `resourceSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 素材检索主链 / detour tool 限制定向测试 - `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` 搜索主链定向测试 +- `searchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 搜索主链 / detour tool 限制定向测试 - `research` 默认 skill / tool catalog 相关回归 - `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` 如果本轮修改了 `Claw @深搜` 或深搜 prompt skill 协议,最低校验至少包含: -- `deepSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 深搜主链定向测试 +- `deepSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 深搜主链 / detour tool 限制定向测试 - `research` 默认 skill / tool catalog 相关回归,且要确认没有退化成只执行一轮浅搜 - `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` 如果本轮修改了 `Claw @研报` 或研报 prompt skill 协议,最低校验至少包含: -- `reportWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 研报主链定向测试 +- `reportWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 研报主链 / detour tool 限制定向测试 - `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` 站点搜索主链定向测试 +- `siteSearchWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 站点搜索主链 / detour tool 限制定向测试 - `site_search` 默认 skill / `lime_site_*` tool catalog 相关回归 - `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` +如果本轮修改了 `Claw @读PDF` 或读 PDF prompt skill 协议,最低校验至少包含: + +- `pdfWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 读 PDF 主链 / detour tool 限制定向测试 +- `pdf_read` 默认 skill / `skillCatalog` 相关回归;若支持相对路径,还要确认没有跳过真实 `list_directory / read_file` timeline +- `npm run test:contracts` +- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` + 如果本轮修改了 `Claw @总结` 或总结 prompt skill 协议,最低校验至少包含: -- `summaryWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 总结主链定向测试 +- `summaryWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 总结主链 / detour tool 限制定向测试 - `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` 翻译主链定向测试 +- `translationWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 翻译主链 / detour tool 限制定向测试 - `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` 分析主链定向测试 +- `analysisWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 分析主链 / detour tool 限制定向测试 - `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` 转写主链定向测试 +- `transcriptionWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 转写主链 / detour tool 限制定向测试 - `lime-cli` 转写任务创建测试、`media-runtime` 任务类型回归、受影响的默认 skill / tool catalog 测试 - `npm run test:contracts` -- `npm run verify:gui-smoke` +- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` 如果本轮修改了 `Claw @链接解析` 或链接解析任务协议,最低校验至少包含: -- `urlParseWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 链接解析主链定向测试 +- `urlParseWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 链接解析主链 / detour tool 限制定向测试 - `lime-cli` 链接解析任务创建测试、受影响的默认 skill / tool catalog 测试 - `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` 如果本轮修改了 `Claw @排版` 或排版任务协议,最低校验至少包含: -- `typesettingWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 排版主链定向测试 +- `typesettingWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 排版主链 / detour tool 限制定向测试 - `lime-cli` 排版任务创建测试、受影响的默认 skill / tool catalog 测试 - `npm run test:contracts` - 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` +如果本轮修改了 `Claw @网页` 或网页生成协议,最低校验至少包含: + +- `webpageWorkbenchCommand`、`useWorkspaceSendActions`、提及面板 builtin command 回归,以及 `aster_agent_cmd` 网页主链 / detour tool 限制定向测试 +- `webpage_generate` 默认 skill、默认 skill 安装或 `lime-cli skill show webpage_generate` 相关回归 + +如果本轮修改了 `Claw @PPT` 或演示稿生成协议,最低校验至少包含: + +- `npx vitest run "src/components/agent/chat/utils/presentationWorkbenchCommand.test.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` +- `cargo test presentation_skill_launch` +- `cargo test -p lime-cli skill_show_presentation_generate_returns_builtin_skill` +- `presentation_generate` 默认 skill、默认 skill 安装或 `lime-cli skill show presentation_generate` 相关回归 +- `npm run test:contracts` +- 若 HTML artifact 预览主路径受影响,再补 `npm run verify:gui-smoke` + +如果本轮修改了 `Claw @代码` 或代码编排发送协议,最低校验至少包含: + +- `npx vitest run "src/components/agent/chat/utils/codeWorkbenchCommand.test.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/lib/api/skillCatalog.test.ts"` +- 如有改动扩散到 runtime/team/tool 协议,再补对应 `agentStream*` / runtime team / tool display 定向回归 +- 若命令边界或 harness 协议继续扩散,再补 `npm run test:contracts` +- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` + +如果本轮修改了 `Claw @发布` 或发布工作流接线,最低校验至少包含: + +- `npx vitest run "src/components/agent/chat/utils/publishWorkbenchCommand.test.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/lib/api/skillCatalog.test.ts"` +- 如有改动扩散到 `content_post_with_cover` slash skill 或写文件回流,再补 `skillCommand` / `MessageList` / general workbench 相关定向回归 +- 若浏览器门禁或 harness 协议继续扩散,再补 `npm run test:contracts` +- 若 GUI 主路径受影响,再补 `npm run verify:gui-smoke` + 如果本轮修改了 Provider 模型真相源或设置页中的“支持的模型”展示逻辑,还应额外确认: - 资源索引损坏时,GUI 会明确提示“模型真相源异常” diff --git a/docs/prd/skills/async-image-skill-task-prd.md b/docs/prd/skills/async-image-skill-task-prd.md index 14a418944..0bdc2d595 100644 --- a/docs/prd/skills/async-image-skill-task-prd.md +++ b/docs/prd/skills/async-image-skill-task-prd.md @@ -295,7 +295,8 @@ skill 输出的语义是: 标准 CLI 主链: -- `lime task create image --json` +- `lime media image generate --json` +- `lime task create image --json`(兼容入口,但必须复用同一条执行链) - `lime task status ` - `lime task result ` - `lime task retry ` @@ -522,7 +523,7 @@ skill 输出的语义是: ### 11.2 协议验收 1. `image_generate` skill 输出稳定任务字段。 -2. `lime task create image --json` 返回稳定 JSON。 +2. `lime media image generate --json` 与 `lime task create image --json` 都返回稳定 JSON,且共享同一条图片执行链。 3. `lime task status` 能读到标准状态。 4. `lime task result` 能读到标准结果结构。 5. `retry / cancel / list` 行为符合任务协议。 diff --git a/extensions/lime-chrome/background.js b/extensions/lime-chrome/background.js index f499a2975..cde137d91 100644 --- a/extensions/lime-chrome/background.js +++ b/extensions/lime-chrome/background.js @@ -1179,7 +1179,7 @@ async function injectContentScript(tabId) { await ensureCapturableTab(tabId); await chrome.scripting.executeScript({ target: { tabId }, - files: ["content_script.js"], + files: ["site_adapter_runners.generated.js", "content_script.js"], }); } diff --git a/extensions/lime-chrome/content_script.js b/extensions/lime-chrome/content_script.js index b0d291f4a..aa997e0ef 100644 --- a/extensions/lime-chrome/content_script.js +++ b/extensions/lime-chrome/content_script.js @@ -267,7 +267,14 @@ }, }; - const siteAdapterRunners = { + const generatedSiteAdapterRunners = + window.__LIME_SITE_ADAPTER_RUNNERS__ && + typeof window.__LIME_SITE_ADAPTER_RUNNERS__ === "object" && + !Array.isArray(window.__LIME_SITE_ADAPTER_RUNNERS__) + ? window.__LIME_SITE_ADAPTER_RUNNERS__ + : {}; + + const legacySiteAdapterRunners = { "github/search": async (args, helpers) => { const query = String(args.query || "").trim(); const limit = helpers.number(args.limit, 10); @@ -929,6 +936,11 @@ }, }; + const siteAdapterRunners = { + ...legacySiteAdapterRunners, + ...generatedSiteAdapterRunners, + }; + async function executeCommand(commandData) { const command = String(commandData.command || "").trim(); const target = commandData.target; diff --git a/extensions/lime-chrome/site_adapter_runners.generated.js b/extensions/lime-chrome/site_adapter_runners.generated.js new file mode 100644 index 000000000..394b9cac6 --- /dev/null +++ b/extensions/lime-chrome/site_adapter_runners.generated.js @@ -0,0 +1,1050 @@ +// 由 scripts/generate-extension-site-adapter-runners.mjs 自动生成,请勿手改。 +(function () { + const generatedSiteAdapterRunners = { + "36kr/newsflash": (async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => document.querySelectorAll('a[href*="/p/"]').length > 0, + 12000, + 300, + ); + const links = Array.from(document.querySelectorAll('a[href*="/p/"]')); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("div, article"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; +}), + + "bilibili/search": (async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => + document.querySelectorAll( + 'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]', + ).length > 0, + 12000, + 300, + ); + const links = Array.from( + document.querySelectorAll( + 'a[href*="/video/BV"], a[href*="//www.bilibili.com/video/"]', + ), + ); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("div"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + author: helpers.text( + card?.querySelector( + ".bili-video-card__info--author, .up-name, .bili-video-card__info--bottom .bili-video-card__info--author", + ), + ), + summary: helpers.text( + card?.querySelector( + ".bili-video-card__info--desc, .des, .bili-video-card__info--title", + ), + ), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `B 站没有返回 "${query}" 的搜索结果,可能需要先登录。`, + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}), + + "github/issues": (async (args, helpers) => { + const repo = String(args.repo || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => + document.querySelectorAll( + 'a[data-hovercard-type="issue"], a[id^="issue_"]', + ).length > 0, + 12000, + 300, + ); + const links = Array.from( + document.querySelectorAll( + 'a[data-hovercard-type="issue"], a[id^="issue_"]', + ), + ); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const row = anchor.closest("div, li"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + status: helpers.text( + row?.querySelector( + '[aria-label*="Open"], [aria-label*="Closed"], .State', + ), + ), + summary: helpers.text( + row?.querySelector("span.color-fg-muted, .markdown-title + div"), + ), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `GitHub 仓库 ${repo} 的 issue 列表为空,可能需要登录后访问。`, + }; + } + + return { + ok: true, + data: { + repo, + items, + count: items.length, + }, + source_url: location.href, + }; +}), + + "github/search": (async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + await helpers.waitFor( + () => document.querySelectorAll("a.v-align-middle").length > 0, + 12000, + 300, + ); + const links = Array.from(document.querySelectorAll("a.v-align-middle")); + const items = helpers.take( + helpers.uniqueBy( + links + .map((anchor) => { + const card = anchor.closest("li, div.search-title, div.Box-row"); + return { + title: helpers.text(anchor), + url: helpers.absoluteUrl(anchor.getAttribute("href") || ""), + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "GitHub 搜索结果为空,可能需要先登录后再访问。", + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}), + + "linux-do/categories": (async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + + try { + const response = await fetch("/categories.json", { credentials: "include" }); + if (!response.ok) { + return { + ok: false, + error_code: "auth_required", + error_message: "linux.do 分类列表暂不可用,可能需要先登录。", + }; + } + const payload = await response.json(); + const categories = Array.isArray(payload?.category_list?.categories) + ? payload.category_list.categories + : []; + + const items = helpers.take( + categories.map((category) => ({ + name: String(category?.name || "").trim(), + slug: String(category?.slug || "").trim(), + id: category?.id ?? null, + topics: category?.topic_count ?? 0, + description: String(category?.description_text || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 80), + })), + limit, + ).filter((item) => item.name); + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; + } catch (error) { + return { + ok: false, + error_code: "runtime_error", + error_message: + error instanceof Error ? error.message : "读取 linux.do 分类列表失败。", + }; + } +}), + + "linux-do/hot": (async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + const allowedPeriods = new Set(["all", "daily", "weekly", "monthly", "yearly"]); + const period = String(args.period || "weekly").trim().toLowerCase(); + const normalizedPeriod = allowedPeriods.has(period) ? period : "weekly"; + + try { + const response = await fetch( + "/top.json?period=" + encodeURIComponent(normalizedPeriod), + { credentials: "include" }, + ); + if (!response.ok) { + return { + ok: false, + error_code: "auth_required", + error_message: "linux.do 热门话题暂不可用,可能需要先登录。", + }; + } + const payload = await response.json(); + const topics = Array.isArray(payload?.topic_list?.topics) + ? payload.topic_list.topics + : []; + const categories = Array.isArray(payload?.topic_list?.categories) + ? payload.topic_list.categories + : Array.isArray(payload?.categories) + ? payload.categories + : []; + const categoryMap = new Map( + categories.map((category) => [category?.id, String(category?.name || "").trim()]), + ); + + const items = helpers.take( + topics.map((topic, index) => ({ + rank: index + 1, + title: String(topic?.title || "").trim(), + replies: Math.max(0, Number(topic?.posts_count || 1) - 1), + views: Number(topic?.views || 0), + likes: Number(topic?.like_count || 0), + category: + categoryMap.get(topic?.category_id) || + String(topic?.category_id || "").trim(), + })), + limit, + ).filter((item) => item.title); + + return { + ok: true, + data: { + period: normalizedPeriod, + items, + count: items.length, + }, + source_url: location.href, + }; + } catch (error) { + return { + ok: false, + error_code: "runtime_error", + error_message: + error instanceof Error ? error.message : "读取 linux.do 热门话题失败。", + }; + } +}), + + "smzdm/search": (async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + const rowSelector = "li.feed-row-wide"; + + await helpers.waitFor( + () => + document.querySelectorAll(rowSelector).length > 0 || + /搜索结果|相关好价|什么值得买/i.test(document.body?.textContent || ""), + 12000, + 300, + ); + + const rows = Array.from(document.querySelectorAll(rowSelector)); + const items = helpers.take( + helpers.uniqueBy( + rows + .map((row, index) => { + const titleAnchor = + row.querySelector("h5.feed-block-title > a") || row.querySelector("h5 > a"); + const rawHref = + titleAnchor?.getAttribute("href") || titleAnchor?.href || ""; + const url = helpers.absoluteUrl(rawHref); + const title = ( + titleAnchor?.getAttribute("title") || helpers.text(titleAnchor) + ).trim(); + const price = helpers.text(row.querySelector(".z-highlight")); + const mall = helpers.text( + row.querySelector(".z-feed-foot-r .feed-block-extras span") || + row.querySelector(".z-feed-foot-r span"), + ); + const commentsText = helpers.text( + row.querySelector(".feed-btn-comment"), + ).replace(/[^\d]/g, ""); + return { + rank: index + 1, + title, + url, + price, + mall, + comments: commentsText ? Number(commentsText) : 0, + }; + }) + .filter((item) => item.title && item.url), + (item) => item.url, + ), + limit, + ); + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}), + + "yahoo-finance/quote": (async (args, helpers) => { + const symbol = String(args.symbol || "").trim().toUpperCase(); + if (!symbol) { + return { + ok: false, + error_code: "invalid_args", + error_message: "symbol 不能为空。", + }; + } + + const normalizeText = (value) => { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text || null; + }; + const normalizeNumber = (value) => { + if (value === undefined || value === null || value === "") { + return null; + } + const text = String(value).replace(/,/g, "").replace(/%/g, "").trim(); + const number = Number(text); + return Number.isFinite(number) ? number : null; + }; + const buildResult = (item) => ({ + ok: true, + data: { + symbol, + items: [item], + count: 1, + }, + source_url: location.href, + }); + + try { + const chartUrl = + "https://query1.finance.yahoo.com/v8/finance/chart/" + + encodeURIComponent(symbol) + + "?interval=1d&range=1d"; + const response = await fetch(chartUrl, { credentials: "include" }); + if (response.ok) { + const payload = await response.json(); + const chart = payload?.chart?.result?.[0]; + if (chart) { + const meta = chart.meta || {}; + const previousClose = meta.previousClose ?? meta.chartPreviousClose ?? null; + const price = meta.regularMarketPrice ?? null; + const change = + price != null && previousClose != null ? price - previousClose : null; + const changePercent = + change != null && previousClose + ? Number(((change / previousClose) * 100).toFixed(2)) + : null; + return buildResult({ + symbol: meta.symbol || symbol, + name: meta.shortName || meta.longName || symbol, + price: price != null ? Number(price.toFixed(2)) : null, + change: change != null ? Number(change.toFixed(2)) : null, + changePercent, + open: chart.indicators?.quote?.[0]?.open?.[0] ?? null, + high: meta.regularMarketDayHigh ?? null, + low: meta.regularMarketDayLow ?? null, + volume: meta.regularMarketVolume ?? null, + marketCap: meta.marketCap ?? null, + }); + } + } + } catch {} + + await helpers.waitFor( + () => + document.querySelector('[data-testid="qsp-price"]') || + document.querySelector('fin-streamer[data-field="regularMarketPrice"]') || + document.querySelector("h1"), + 12000, + 300, + ); + + const titleText = normalizeText(document.querySelector("h1")?.textContent); + const item = { + symbol, + name: titleText + ? titleText.replace(/\s*\([^)]+\)\s*$/, "").trim() || symbol + : symbol, + price: normalizeNumber( + document.querySelector('[data-testid="qsp-price"]')?.textContent || + document.querySelector('fin-streamer[data-field="regularMarketPrice"]') + ?.textContent, + ), + change: normalizeNumber( + document.querySelector('[data-testid="qsp-price-change"]')?.textContent || + document.querySelector('fin-streamer[data-field="regularMarketChange"]') + ?.textContent, + ), + changePercent: normalizeNumber( + document.querySelector('[data-testid="qsp-price-change-percent"]') + ?.textContent || + document.querySelector( + 'fin-streamer[data-field="regularMarketChangePercent"]', + )?.textContent, + ), + open: normalizeNumber( + document.querySelector('[data-test="OPEN-value"]')?.textContent, + ), + high: normalizeNumber( + document.querySelector('[data-test="DAYS_RANGE-value"]') + ?.textContent?.split(" - ") + ?.at(1), + ), + low: normalizeNumber( + document.querySelector('[data-test="DAYS_RANGE-value"]') + ?.textContent?.split(" - ") + ?.at(0), + ), + volume: normalizeNumber( + document.querySelector('[data-test="TD_VOLUME-value"]')?.textContent, + ), + marketCap: normalizeText( + document.querySelector('[data-test="MARKET_CAP-value"]')?.textContent, + ), + }; + + if (item.price != null || item.name !== symbol) { + return buildResult(item); + } + + return { + ok: true, + data: { + symbol, + items: [], + count: 0, + }, + source_url: location.href, + }; +}), + + "x/article-export": (async (args, helpers) => { + const ARTICLE_ROOT_SELECTOR = '[data-testid="twitterArticleReadView"]'; + const ARTICLE_TITLE_SELECTOR = '[data-testid="twitter-article-title"]'; + const ARTICLE_CONTENT_SELECTOR = + '[data-testid="longformRichTextComponent"] [data-contents="true"], [data-testid="longformRichTextComponent"]'; + const IMAGE_SELECTOR = '[data-testid="tweetPhoto"] img'; + const CODE_BLOCK_SELECTOR = '[data-testid="markdown-code-block"]'; + + function normalizeText(value) { + return String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + function normalizeMultilineText(value) { + return String(value || "") + .replace(/\r\n/g, "\n") + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + function escapeMarkdownText(value) { + return String(value || "").replace(/([\\`*_{}\[\]()#+\-.!|>])/g, "\\$1"); + } + + function normalizeArticleUrl(rawUrl) { + try { + const url = new URL(rawUrl, location.href); + const hostname = url.hostname.replace(/^www\./, "").toLowerCase(); + if (hostname !== "x.com" && hostname !== "twitter.com") { + return null; + } + if (!/\/article\//.test(url.pathname)) { + return null; + } + return url.toString(); + } catch { + return null; + } + } + + function resolveAuthor() { + const titleMatch = document.title.match(/^(.+?) on X/i); + if (titleMatch?.[1]) { + return normalizeText(titleMatch[1]); + } + + const pathMatch = location.pathname.match(/^\/([^/]+)/); + if (pathMatch?.[1]) { + return `@${pathMatch[1]}`; + } + + return null; + } + + function normalizeImageUrl(rawUrl) { + try { + const url = new URL(rawUrl, location.href); + if (url.hostname.includes("pbs.twimg.com")) { + if (url.searchParams.has("format")) { + url.searchParams.set("name", "orig"); + } + } + return url.toString(); + } catch { + return rawUrl || ""; + } + } + + function resolveSuggestedImageName(rawUrl, fallbackIndex) { + try { + const url = new URL(rawUrl, location.href); + const format = normalizeText(url.searchParams.get("format")); + const ext = format || normalizeText(url.pathname.split(".").pop()) || "jpg"; + return `image-${fallbackIndex + 1}.${ext}`; + } catch { + return `image-${fallbackIndex + 1}.jpg`; + } + } + + function resolveCodeLanguage(element) { + const candidates = [ + element.getAttribute("data-language"), + element.querySelector("[data-language]")?.getAttribute("data-language"), + element.querySelector("code")?.getAttribute("data-language"), + element.querySelector("pre")?.getAttribute("data-language"), + element.querySelector("code")?.className, + ]; + + for (const candidate of candidates) { + const normalized = normalizeText(candidate); + if (!normalized) { + continue; + } + const match = normalized.match(/language-([a-z0-9_+-]+)/i); + if (match?.[1]) { + return match[1].toLowerCase(); + } + if (/^[a-z0-9_+-]+$/i.test(normalized)) { + return normalized.toLowerCase(); + } + } + + return ""; + } + + function createState() { + return { + imageUrls: new Set(), + images: [], + }; + } + + function registerImage(state, imageElement) { + const rawUrl = + imageElement.getAttribute("src") || + imageElement.getAttribute("data-src") || + ""; + const normalizedUrl = normalizeImageUrl(rawUrl); + if (!normalizedUrl) { + return ""; + } + + if (!state.imageUrls.has(normalizedUrl)) { + state.imageUrls.add(normalizedUrl); + state.images.push({ + url: normalizedUrl, + alt: normalizeText(imageElement.getAttribute("alt")) || undefined, + suggested_file_name: resolveSuggestedImageName( + normalizedUrl, + state.images.length, + ), + }); + } + + const altText = normalizeText(imageElement.getAttribute("alt")) || "插图"; + return `![${escapeMarkdownText(altText)}](${normalizedUrl})`; + } + + function collectInlineMarkdown(node, state) { + if (!node) { + return ""; + } + + if (node.nodeType === Node.TEXT_NODE) { + return escapeMarkdownText(node.textContent || ""); + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node; + if (element.matches?.(IMAGE_SELECTOR) || element.tagName === "IMG") { + return registerImage(state, element); + } + + if (element.matches?.(CODE_BLOCK_SELECTOR)) { + return ""; + } + + if (element.tagName === "BR") { + return "\n"; + } + + const childMarkdown = Array.from(element.childNodes) + .map((child) => collectInlineMarkdown(child, state)) + .join(""); + const normalized = childMarkdown.replace(/[ \t]+\n/g, "\n"); + + if (element.tagName === "A") { + const href = helpers.absoluteUrl(element.getAttribute("href") || ""); + const label = normalizeMultilineText(normalized); + if (!href) { + return label; + } + return label ? `[${label}](${href})` : href; + } + + if (element.tagName === "CODE") { + const codeText = normalizeMultilineText(element.textContent || ""); + return codeText ? `\`${codeText}\`` : ""; + } + + if (element.tagName === "STRONG" || element.tagName === "B") { + const text = normalized.trim(); + return text ? `**${text}**` : ""; + } + + if (element.tagName === "EM" || element.tagName === "I") { + const text = normalized.trim(); + return text ? `*${text}*` : ""; + } + + return normalized; + } + + function hasMeaningfulText(element) { + return normalizeText(element.textContent || "").length > 0; + } + + function serializeList(element, state, depth = 0) { + const items = Array.from(element.children) + .filter((child) => child.tagName === "LI") + .map((item, index) => { + const marker = element.tagName === "OL" ? `${index + 1}.` : "-"; + const nestedLists = Array.from(item.children).filter((child) => + ["UL", "OL"].includes(child.tagName), + ); + const clonedItem = item.cloneNode(true); + nestedLists.forEach((list) => { + const nestedClone = clonedItem.querySelector(list.tagName.toLowerCase()); + nestedClone?.remove(); + }); + + const text = normalizeMultilineText( + collectInlineMarkdown(clonedItem, state), + ); + const lines = []; + if (text) { + lines.push(`${" ".repeat(depth)}${marker} ${text}`); + } + nestedLists.forEach((list) => { + const nestedMarkdown = serializeList(list, state, depth + 1); + if (nestedMarkdown) { + lines.push(nestedMarkdown); + } + }); + return lines.join("\n"); + }) + .filter(Boolean); + + return items.join("\n"); + } + + function serializeBlock(element, state) { + if (!(element instanceof Element)) { + return []; + } + + if (element.matches(ARTICLE_TITLE_SELECTOR)) { + return []; + } + + if (element.matches(CODE_BLOCK_SELECTOR)) { + const codeText = normalizeMultilineText(element.innerText || element.textContent || ""); + if (!codeText) { + return []; + } + const language = resolveCodeLanguage(element); + return [`\`\`\`${language}\n${codeText}\n\`\`\``]; + } + + if (element.matches(IMAGE_SELECTOR) || element.matches('[data-testid="tweetPhoto"], figure')) { + const images = element.matches(IMAGE_SELECTOR) + ? [element] + : Array.from(element.querySelectorAll("img")); + return images + .map((image) => registerImage(state, image)) + .filter(Boolean); + } + + if (element.matches("ul,ol")) { + const listMarkdown = serializeList(element, state); + return listMarkdown ? [listMarkdown] : []; + } + + if (element.matches("blockquote")) { + const text = normalizeMultilineText(collectInlineMarkdown(element, state)); + if (!text) { + return []; + } + return [text.split("\n").map((line) => `> ${line}`).join("\n")]; + } + + if (element.matches("h1,h2,h3,h4,h5,h6")) { + const level = Number(element.tagName.slice(1)) || 2; + const text = normalizeMultilineText(collectInlineMarkdown(element, state)); + return text ? [`${"#".repeat(Math.min(level, 6))} ${text}`] : []; + } + + if (element.matches("hr")) { + return ["---"]; + } + + const directBlockChildren = Array.from(element.children).filter((child) => { + if (child.matches(CODE_BLOCK_SELECTOR)) { + return true; + } + if (child.matches(IMAGE_SELECTOR) || child.matches('[data-testid="tweetPhoto"], figure')) { + return true; + } + if (child.matches("ul,ol,blockquote,h1,h2,h3,h4,h5,h6,hr")) { + return true; + } + return child.children.length > 0 && !["A", "SPAN", "EM", "STRONG", "I", "B", "CODE"].includes(child.tagName); + }); + + if (directBlockChildren.length > 0 && !element.matches("p,li")) { + return directBlockChildren.flatMap((child) => serializeBlock(child, state)); + } + + if (!hasMeaningfulText(element) && !element.querySelector("img")) { + return []; + } + + const paragraph = normalizeMultilineText(collectInlineMarkdown(element, state)); + return paragraph ? [paragraph] : []; + } + + function serializeArticle(root, state) { + const blocks = Array.from(root.children) + .flatMap((child) => serializeBlock(child, state)) + .map((block) => normalizeMultilineText(block)) + .filter(Boolean); + + if (blocks.length > 0) { + return blocks.join("\n\n"); + } + + const fallback = normalizeMultilineText(root.innerText || root.textContent || ""); + return fallback; + } + + const requestedUrl = String(args.url || "").trim(); + if (requestedUrl && !normalizeArticleUrl(requestedUrl)) { + return { + ok: false, + error_code: "invalid_args", + error_message: "url 必须是 x.com 或 twitter.com 的 article 链接。", + }; + } + + const articleRoot = await helpers.waitFor( + () => document.querySelector(ARTICLE_ROOT_SELECTOR), + 15000, + 250, + ); + if (!articleRoot) { + if (helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "未能读取 X 长文内容,可能需要先登录 X 后再访问。", + }; + } + return { + ok: false, + error_code: "adapter_runtime_error", + error_message: "未找到 X 长文阅读视图。", + }; + } + + const title = + normalizeText(articleRoot.querySelector(ARTICLE_TITLE_SELECTOR)?.textContent) || + normalizeText(document.title.replace(/\s+on X.*$/i, "")); + const contentRoot = articleRoot.querySelector(ARTICLE_CONTENT_SELECTOR); + if (!contentRoot) { + return { + ok: false, + error_code: helpers.looksLikeLoginWall() ? "auth_required" : "adapter_runtime_error", + error_message: "未找到 X 长文正文区域。", + }; + } + + const state = createState(); + const markdown = serializeArticle(contentRoot, state); + if (!markdown) { + return { + ok: false, + error_code: helpers.looksLikeLoginWall() ? "auth_required" : "adapter_runtime_error", + error_message: "正文为空,无法导出 Markdown。", + }; + } + + const publishedAt = + articleRoot.querySelector("time[datetime]")?.getAttribute("datetime") || + document.querySelector("time[datetime]")?.getAttribute("datetime") || + undefined; + const sourceUrl = normalizeArticleUrl(location.href) || location.href; + + return { + ok: true, + source_url: sourceUrl, + data: { + export_kind: "markdown_bundle", + title: title || undefined, + source_url: sourceUrl, + author: resolveAuthor() || undefined, + published_at: publishedAt || undefined, + markdown, + images: state.images, + }, + }; +}), + + "zhihu/hot": (async (args, helpers) => { + const limit = helpers.number(args.limit, 10); + const hrefTokens = ["/question/"]; + const maxCandidates = Math.max(limit * 6, 30); + const main = document.querySelector("main") || document.body; + const collectCandidates = (maxCount) => { + if (!main) { + return []; + } + const results = []; + const seen = new Set(); + for (const anchor of main.getElementsByTagName("a")) { + const href = anchor.getAttribute("href") || ""; + if (!hrefTokens.some((token) => href.includes(token))) { + continue; + } + const title = helpers.text(anchor); + const url = helpers.absoluteUrl(href); + if (!title || !url || seen.has(url)) { + continue; + } + seen.add(url); + results.push({ anchor, title, url }); + if (results.length >= maxCount) { + break; + } + } + return results; + }; + await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300); + const items = helpers.take( + collectCandidates(maxCandidates) + .map(({ anchor, title, url }, index) => { + const card = anchor.closest("section, div"); + return { + rank: index + 1, + title, + url, + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "当前页面没有返回知乎热榜内容,可能需要先登录知乎。", + }; + } + + return { + ok: true, + data: { + items, + count: items.length, + }, + source_url: location.href, + }; +}), + + "zhihu/search": (async (args, helpers) => { + const query = String(args.query || "").trim(); + const limit = helpers.number(args.limit, 10); + const hrefTokens = ["/question/", "/answer/", "/zvideo/"]; + const maxCandidates = Math.max(limit * 8, 40); + const main = document.querySelector("main") || document.body; + const collectCandidates = (maxCount) => { + if (!main) { + return []; + } + const results = []; + const seen = new Set(); + for (const anchor of main.getElementsByTagName("a")) { + const href = anchor.getAttribute("href") || ""; + if (!hrefTokens.some((token) => href.includes(token))) { + continue; + } + const title = helpers.text(anchor); + const url = helpers.absoluteUrl(href); + if (!title || !url || seen.has(url)) { + continue; + } + seen.add(url); + results.push({ anchor, title, url }); + if (results.length >= maxCount) { + break; + } + } + return results; + }; + await helpers.waitFor(() => collectCandidates(1)[0], 12000, 300); + const items = helpers.take( + collectCandidates(maxCandidates) + .map(({ anchor, title, url }) => { + const card = anchor.closest("div"); + return { + title, + url, + summary: helpers.text(card?.querySelector("p")), + }; + }) + .filter((item) => item.title && item.url), + limit, + ); + + if (items.length === 0 && helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: `知乎没有返回 "${query}" 的搜索结果,可能需要先登录。`, + }; + } + + return { + ok: true, + data: { + query, + items, + count: items.length, + }, + source_url: location.href, + }; +}) + }; + + const existingSiteAdapterRunners = + window.__LIME_SITE_ADAPTER_RUNNERS__ && + typeof window.__LIME_SITE_ADAPTER_RUNNERS__ === "object" && + !Array.isArray(window.__LIME_SITE_ADAPTER_RUNNERS__) + ? window.__LIME_SITE_ADAPTER_RUNNERS__ + : {}; + + window.__LIME_SITE_ADAPTER_RUNNERS__ = { + ...generatedSiteAdapterRunners, + ...existingSiteAdapterRunners, + }; +})(); diff --git a/package.json b/package.json index d5edf4b17..21d7993df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.4.0", + "version": "1.5.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -12,16 +12,18 @@ }, "homepage": "https://github.com/aiclientproxy/lime", "scripts": { + "generate:extension-site-adapters": "node scripts/generate-extension-site-adapter-runners.mjs", + "check:extension-site-adapters": "node scripts/generate-extension-site-adapter-runners.mjs --check", "heatmap:project": "node scripts/project-heatmap.mjs", "governance:graph": "node scripts/governance-graph.mjs", - "predev": "npm run verify:app-version && node scripts/ensure-dev-port.mjs", + "predev": "npm run generate:extension-site-adapters && npm run verify:app-version && node scripts/ensure-dev-port.mjs", "dev": "vite", - "build": "npm run verify:app-version && tsc && vite build", + "build": "npm run generate:extension-site-adapters && npm run verify:app-version && tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "tauri:dev": "CARGO_TARGET_DIR=target tauri dev", - "tauri:dev:headless": "CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", - "tauri:dev:nowatch": "CARGO_TARGET_DIR=target tauri dev --no-watch", + "tauri:dev": "npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev", + "tauri:dev:headless": "npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json", + "tauri:dev:nowatch": "npm run generate:extension-site-adapters && CARGO_TARGET_DIR=target tauri dev --no-watch", "tauri:dev:profile:trace": "node scripts/run-tauri-profile.mjs trace", "tauri:dev:profile:trace:devtools": "node scripts/run-tauri-profile.mjs trace --open-devtools", "tauri:dev:profile:trace:headless": "node scripts/run-tauri-profile.mjs trace --headless", diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index f16d09460..d5180f67a 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -14,6 +14,7 @@ Lime 的官方命令行入口,面向统一任务编排。 当前主线提供: +- `lime media image generate` - `lime task create image` - `lime task create cover` - `lime task create video` @@ -45,7 +46,7 @@ npm install -g @limecloud/lime-cli 示例: ```bash -lime task create image \ +lime media image generate \ --prompt "未来城市插图,蓝色电影感" \ --size "1024x1024" \ --workspace "." \ @@ -83,8 +84,9 @@ lime doctor 说明: -- `lime media image|cover|video generate` 仍保留为兼容别名。 -- 新主线统一收敛到 `lime task create ...`。 +- 图片主线推荐使用 `lime media image generate`,它会在创建 task artifact 后继续推进真实图片执行链。 +- `lime task create image` 作为兼容入口仍可使用,但现在也会复用同一条图片执行链,不会只停在 `pending_submit`。 +- `lime media cover|video generate` 仍保留为兼容别名。 - 如果你现在只发布 npm、不发布 GitHub Release,请至少准备一种运行方式: - 设置 `LIME_CLI_BINARY_PATH` - 或在 Lime 源码仓库内使用该 wrapper,让它自动回退到 `cargo run` @@ -110,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.4.0" \ + --version "1.5.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index a2c46bbcc..7c2e4821d 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.4.0", + "version": "1.5.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/generate-extension-site-adapter-runners.mjs b/scripts/generate-extension-site-adapter-runners.mjs new file mode 100644 index 000000000..1258eaf11 --- /dev/null +++ b/scripts/generate-extension-site-adapter-runners.mjs @@ -0,0 +1,114 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const bundledIndexPath = path.join( + projectRoot, + "src-tauri/resources/site-adapters/bundled/index.json", +); +const bundledScriptsRoot = path.join( + projectRoot, + "src-tauri/resources/site-adapters/bundled", +); +const outputPath = path.join( + projectRoot, + "extensions/lime-chrome/site_adapter_runners.generated.js", +); + +async function readBundledCatalog() { + const raw = await fs.readFile(bundledIndexPath, "utf8"); + const parsed = JSON.parse(raw); + const adapters = Array.isArray(parsed?.adapters) ? parsed.adapters : []; + if (adapters.length === 0) { + throw new Error("bundled site adapter catalog 为空"); + } + return adapters; +} + +async function readRunnerSource(scriptFile) { + if (typeof scriptFile !== "string" || scriptFile.trim().length === 0) { + throw new Error("adapter 缺少 script_file"); + } + const scriptPath = path.join(bundledScriptsRoot, scriptFile); + return fs.readFile(scriptPath, "utf8"); +} + +function buildOutput(entries) { + const body = entries + .map(({ name, source }) => { + const normalizedSource = source + .trim() + .replace(/\r\n/g, "\n") + .replace(/;\s*$/, ""); + return ` ${JSON.stringify(name)}: (${normalizedSource})`; + }) + .join(",\n\n"); + + return `// 由 scripts/generate-extension-site-adapter-runners.mjs 自动生成,请勿手改。 +(function () { + const generatedSiteAdapterRunners = { +${body} + }; + + const existingSiteAdapterRunners = + window.__LIME_SITE_ADAPTER_RUNNERS__ && + typeof window.__LIME_SITE_ADAPTER_RUNNERS__ === "object" && + !Array.isArray(window.__LIME_SITE_ADAPTER_RUNNERS__) + ? window.__LIME_SITE_ADAPTER_RUNNERS__ + : {}; + + window.__LIME_SITE_ADAPTER_RUNNERS__ = { + ...generatedSiteAdapterRunners, + ...existingSiteAdapterRunners, + }; +})(); +`; +} + +async function collectEntries() { + const adapters = await readBundledCatalog(); + const entries = []; + for (const adapter of adapters) { + const name = String(adapter?.name || "").trim(); + if (!name) { + throw new Error("bundled adapter 缺少 name"); + } + const source = await readRunnerSource(adapter?.script_file); + entries.push({ name, source }); + } + return entries; +} + +async function main() { + const entries = await collectEntries(); + const output = buildOutput(entries); + const checkOnly = process.argv.includes("--check"); + let current = ""; + try { + current = await fs.readFile(outputPath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + if (checkOnly) { + if (current !== output) { + throw new Error( + "site_adapter_runners.generated.js 已过期,请先运行 npm run generate:extension-site-adapters", + ); + } + return; + } + + await fs.writeFile(outputPath, output, "utf8"); +} + +main().catch((error) => { + console.error( + `[generate-extension-site-adapter-runners] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3a915eb7b..8320bb5ac 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.27.0" +version = "0.27.1" dependencies = [ "ahash", "anyhow", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.27.0" +version = "0.27.1" dependencies = [ "serde", "serde_json", @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", "arboard", @@ -5206,7 +5206,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", "aster-core", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.4.0" +version = "1.5.0" dependencies = [ "chrono", "futures", @@ -5252,17 +5252,19 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.4.0" +version = "1.5.0" dependencies = [ "clap", + "lime-core", "lime-media-runtime", "serde_json", "tempfile", + "tokio", ] [[package]] name = "lime-config" -version = "1.4.0" +version = "1.5.0" dependencies = [ "async-trait", "lime-core", @@ -5278,7 +5280,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.4.0" +version = "1.5.0" dependencies = [ "aster-models", "async-trait", @@ -5318,7 +5320,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.4.0" +version = "1.5.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5353,7 +5355,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.4.0" +version = "1.5.0" dependencies = [ "aes", "axum 0.7.9", @@ -5383,7 +5385,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.4.0" +version = "1.5.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5403,7 +5405,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.4.0" +version = "1.5.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5419,13 +5421,16 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.4.0" +version = "1.5.0" dependencies = [ + "axum 0.7.9", "chrono", + "reqwest 0.12.28", "serde", "serde_json", "tempfile", "thiserror 1.0.69", + "tokio", "uuid", ] @@ -5447,7 +5452,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.4.0" +version = "1.5.0" dependencies = [ "async-trait", "lime-core", @@ -5466,7 +5471,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", "async-stream", @@ -5521,7 +5526,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.4.0" +version = "1.5.0" dependencies = [ "aster-core", "async-stream", @@ -5566,7 +5571,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.4.0" +version = "1.5.0" dependencies = [ "axum 0.7.9", "futures", @@ -5581,7 +5586,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.4.0" +version = "1.5.0" dependencies = [ "anyhow", "aster-core", @@ -5623,7 +5628,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.4.0" +version = "1.5.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5641,7 +5646,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.4.0" +version = "1.5.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5668,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.4.0" +version = "1.5.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d68e911c0..a0f78d764 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "1.4.0" +version = "1.5.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.27.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.27.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.27.1" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.27.1" } # 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.4.0" +version = "1.5.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 269ce3b2c..d583b8e7f 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -1074,6 +1074,7 @@ pub fn convert_to_tauri_message(message: &Message) -> TauriMessage { role: format!("{:?}", message.role).to_lowercase(), content, timestamp: message.created, + usage: None, } } diff --git a/src-tauri/crates/agent/src/protocol.rs b/src-tauri/crates/agent/src/protocol.rs index 82c6ded4c..7c6f50458 100644 --- a/src-tauri/crates/agent/src/protocol.rs +++ b/src-tauri/crates/agent/src/protocol.rs @@ -68,6 +68,8 @@ pub struct AgentMessage { pub role: String, pub content: Vec, pub timestamp: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 3413fd0d1..f332f0810 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -989,6 +989,38 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result Option { + match (session.input_tokens, session.output_tokens) { + (Some(input_tokens), Some(output_tokens)) if input_tokens >= 0 && output_tokens >= 0 => { + Some(crate::protocol::AgentTokenUsage { + input_tokens: input_tokens as u32, + output_tokens: output_tokens as u32, + }) + } + _ => None, + } +} + +fn apply_runtime_usage_fallback_to_latest_assistant_message( + messages: &mut [RuntimeAgentMessage], + session: &AsterSession, +) -> Option { + let usage = resolve_runtime_usage_from_aster_session(session)?; + let latest_assistant_message = messages + .iter_mut() + .rev() + .find(|message| message.role.eq_ignore_ascii_case("assistant"))?; + + if latest_assistant_message.usage.is_some() { + return None; + } + + latest_assistant_message.usage = Some(usage.clone()); + Some(usage) +} + pub async fn get_runtime_session_detail( db: &DbConnection, session_id: &str, @@ -1017,6 +1049,38 @@ pub async fn get_runtime_session_detail( } }; + if let Some(session) = session.as_ref() { + if let Some(usage) = + apply_runtime_usage_fallback_to_latest_assistant_message(&mut detail.messages, session) + { + match db.lock() { + Ok(conn) => { + if let Err(error) = + agent_session_repository::update_latest_assistant_message_usage( + &conn, + session_id, + usage.input_tokens, + usage.output_tokens, + ) + { + tracing::warn!( + "[SessionStore] 运行态 usage 回填消息失败,已降级继续: session_id={}, error={}", + session_id, + error + ); + } + } + Err(error) => { + tracing::warn!( + "[SessionStore] 运行态 usage 回填消息时数据库锁定失败,已降级继续: session_id={}, error={}", + session_id, + error + ); + } + } + } + } + detail.execution_runtime = build_session_execution_runtime( session_id, session.as_ref(), @@ -1303,6 +1367,13 @@ fn convert_agent_message( role: message.role.clone(), content, timestamp, + usage: message + .usage + .as_ref() + .map(|usage| crate::protocol::AgentTokenUsage { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + }), }; // 调试日志 @@ -1406,6 +1477,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, ) .expect("add message"); @@ -1805,6 +1877,7 @@ mod tests { }]), tool_call_id: None, reasoning_content: None, + usage: Some(lime_core::agent::types::TokenUsage::new(20_480, 10_240)), }; let assistant_converted = convert_agent_message( @@ -1818,6 +1891,13 @@ mod tests { if id == "call-1" && tool_name == "Write" ) })); + assert_eq!( + assistant_converted + .usage + .as_ref() + .map(|usage| (usage.input_tokens, usage.output_tokens)), + Some((20_480, 10_240)) + ); let tool = AgentMessage { role: "tool".to_string(), @@ -1826,6 +1906,7 @@ mod tests { tool_calls: None, tool_call_id: Some("call-1".to_string()), reasoning_content: None, + usage: None, }; let tool_converted = convert_agent_message( @@ -1845,6 +1926,85 @@ mod tests { })); } + #[test] + fn apply_runtime_usage_fallback_should_fill_latest_assistant_message() { + let mut messages = vec![ + RuntimeAgentMessage { + id: None, + role: "user".to_string(), + content: vec![RuntimeAgentMessageContent::Text { + text: "请先起草内容首稿".to_string(), + }], + timestamp: 1, + usage: None, + }, + RuntimeAgentMessage { + id: None, + role: "assistant".to_string(), + content: vec![RuntimeAgentMessageContent::Text { + text: "# 内容首稿框架".to_string(), + }], + timestamp: 2, + usage: None, + }, + ]; + let session = AsterSession { + id: "session-usage-fallback".to_string(), + input_tokens: Some(3_833), + output_tokens: Some(615), + ..AsterSession::default() + }; + + let applied = + apply_runtime_usage_fallback_to_latest_assistant_message(&mut messages, &session); + + assert_eq!( + applied.map(|usage| (usage.input_tokens, usage.output_tokens)), + Some((3_833, 615)) + ); + assert_eq!( + messages[1] + .usage + .as_ref() + .map(|usage| (usage.input_tokens, usage.output_tokens)), + Some((3_833, 615)) + ); + } + + #[test] + fn apply_runtime_usage_fallback_should_not_override_existing_usage() { + let mut messages = vec![RuntimeAgentMessage { + id: None, + role: "assistant".to_string(), + content: vec![RuntimeAgentMessageContent::Text { + text: "已存在 usage".to_string(), + }], + timestamp: 2, + usage: Some(crate::protocol::AgentTokenUsage { + input_tokens: 20_480, + output_tokens: 10_240, + }), + }]; + let session = AsterSession { + id: "session-usage-existing".to_string(), + input_tokens: Some(3_833), + output_tokens: Some(615), + ..AsterSession::default() + }; + + let applied = + apply_runtime_usage_fallback_to_latest_assistant_message(&mut messages, &session); + + assert!(applied.is_none()); + assert_eq!( + messages[0] + .usage + .as_ref() + .map(|usage| (usage.input_tokens, usage.output_tokens)), + Some((20_480, 10_240)) + ); + } + #[test] fn convert_agent_message_should_keep_image_parts_for_history() { let user_with_image = AgentMessage { @@ -1864,6 +2024,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }; let converted = convert_agent_message( @@ -1891,6 +2052,7 @@ mod tests { tool_calls: None, tool_call_id: Some("call-2".to_string()), reasoning_content: None, + usage: None, }; let converted = convert_agent_message( @@ -1951,6 +2113,7 @@ mod tests { }]), tool_call_id: None, reasoning_content: None, + usage: None, }, AgentMessage { role: "user".to_string(), @@ -1959,6 +2122,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, AgentMessage { role: "assistant".to_string(), @@ -1967,6 +2131,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, ]; @@ -2115,6 +2280,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, ) .expect("add system message"); @@ -2128,6 +2294,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, ) .expect("add user message"); @@ -2141,6 +2308,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, ) .expect("add assistant message"); @@ -2154,6 +2322,7 @@ mod tests { tool_calls: None, tool_call_id: Some("tool-1".to_string()), reasoning_content: None, + usage: None, }, ) .expect("add tool message"); diff --git a/src-tauri/crates/agent/src/tool_io_offload.rs b/src-tauri/crates/agent/src/tool_io_offload.rs index a417b2a10..962ad76f4 100644 --- a/src-tauri/crates/agent/src/tool_io_offload.rs +++ b/src-tauri/crates/agent/src/tool_io_offload.rs @@ -807,6 +807,7 @@ mod tests { }]), tool_call_id: None, reasoning_content: None, + usage: None, }, AgentMessage { role: "user".to_string(), @@ -815,6 +816,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, AgentMessage { role: "assistant".to_string(), @@ -823,6 +825,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: None, + usage: None, }, ]; diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs index 944a6b64d..78dade693 100644 --- a/src-tauri/crates/agent/src/turn_input_envelope.rs +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -76,12 +76,15 @@ pub enum TurnPromptAugmentationStageKind { DeepSearchSkillLaunch, SiteSearchSkillLaunch, PdfReadSkillLaunch, + PresentationSkillLaunch, + FormSkillLaunch, SummarySkillLaunch, TranslationSkillLaunch, AnalysisSkillLaunch, TranscriptionSkillLaunch, UrlParseSkillLaunch, TypesettingSkillLaunch, + WebpageSkillLaunch, ServiceSkillLaunch, ServiceSkillLaunchPreload, Elicitation, diff --git a/src-tauri/crates/core/src/agent/types.rs b/src-tauri/crates/core/src/agent/types.rs index 84df3a250..031788a9b 100644 --- a/src-tauri/crates/core/src/agent/types.rs +++ b/src-tauri/crates/core/src/agent/types.rs @@ -204,6 +204,9 @@ pub struct AgentMessage { /// DeepSeek Reasoner 在 Tool Calls 场景下要求此字段 #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_content: Option, + /// 当前消息的 token 使用量 + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, } /// 消息内容类型 diff --git a/src-tauri/crates/core/src/database/agent_session_repository.rs b/src-tauri/crates/core/src/database/agent_session_repository.rs index 2f9d8116c..69c851aa2 100644 --- a/src-tauri/crates/core/src/database/agent_session_repository.rs +++ b/src-tauri/crates/core/src/database/agent_session_repository.rs @@ -213,3 +213,13 @@ pub fn update_session_provider_config( ) .map_err(|error| format!("更新会话 provider/model 失败: {error}")) } + +pub fn update_latest_assistant_message_usage( + conn: &Connection, + session_id: &str, + input_tokens: u32, + output_tokens: u32, +) -> Result { + AgentDao::update_latest_assistant_message_usage(conn, session_id, input_tokens, output_tokens) + .map_err(|error| format!("更新最新 assistant 消息 usage 失败: {error}")) +} diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index 9ea02e557..2e10798f7 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -924,9 +924,11 @@ impl AgentDao { timestamp, tool_calls_json, tool_call_id, - reasoning_content + reasoning_content, + input_tokens, + output_tokens ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ session_id, message.role, @@ -935,6 +937,8 @@ impl AgentDao { tool_calls_json, message.tool_call_id, message.reasoning_content.as_deref(), + message.usage.as_ref().map(|usage| usage.input_tokens), + message.usage.as_ref().map(|usage| usage.output_tokens), ], )?; @@ -953,7 +957,8 @@ impl AgentDao { session_id: &str, ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content + "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content, + input_tokens, output_tokens FROM agent_messages WHERE session_id = ? ORDER BY id ASC", )?; @@ -964,6 +969,8 @@ impl AgentDao { let tool_calls_json: Option = row.get(3)?; let tool_call_id: Option = row.get(4)?; let reasoning_content: Option = row.get(5)?; + let input_tokens: Option = row.get(6)?; + let output_tokens: Option = row.get(7)?; // 解析 JSON - 支持多种格式 // 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}] @@ -980,12 +987,42 @@ impl AgentDao { tool_calls, tool_call_id, reasoning_content, + usage: match (input_tokens, output_tokens) { + (Some(input_tokens), Some(output_tokens)) => { + Some(crate::agent::types::TokenUsage { + input_tokens, + output_tokens, + }) + } + _ => None, + }, }) })?; messages.collect() } + pub fn update_latest_assistant_message_usage( + conn: &Connection, + session_id: &str, + input_tokens: u32, + output_tokens: u32, + ) -> Result { + let rows = conn.execute( + "UPDATE agent_messages + SET input_tokens = ?1, output_tokens = ?2 + WHERE id = ( + SELECT id FROM agent_messages + WHERE session_id = ?3 AND role = 'assistant' + ORDER BY id DESC + LIMIT 1 + )", + params![input_tokens, output_tokens, session_id], + )?; + + Ok(rows > 0) + } + /// 删除会话的所有消息 pub fn delete_messages(conn: &Connection, session_id: &str) -> Result<(), rusqlite::Error> { conn.execute( @@ -1132,7 +1169,9 @@ mod tests { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, - reasoning_content TEXT + reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER ); ", ) @@ -1474,6 +1513,7 @@ mod tests { tool_calls: None, tool_call_id: None, reasoning_content: Some("先分析参数,再继续请求".to_string()), + usage: Some(crate::agent::types::TokenUsage::new(1200, 300)), }, ) .unwrap(); @@ -1484,5 +1524,57 @@ mod tests { messages[0].reasoning_content.as_deref(), Some("先分析参数,再继续请求") ); + assert_eq!( + messages[0].usage, + Some(crate::agent::types::TokenUsage::new(1200, 300)) + ); + } + + #[test] + fn update_latest_assistant_message_usage_should_only_touch_latest_assistant() { + let conn = setup_pattern_test_db(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir, execution_strategy) + VALUES (?1, ?2, NULL, ?3, ?4, ?5, NULL, ?6)", + params![ + "session-usage", + "gpt-4.1", + "usage 会话", + "2026-03-19T10:00:00+08:00", + "2026-03-19T10:00:00+08:00", + "react" + ], + ) + .unwrap(); + + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["session-usage", "assistant", r#"[{"type":"text","text":"旧助手消息"}]"#, "2026-03-19T10:00:01+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["session-usage", "user", r#"[{"type":"text","text":"用户消息"}]"#, "2026-03-19T10:00:02+08:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)", + params!["session-usage", "assistant", r#"[{"type":"text","text":"最新助手消息"}]"#, "2026-03-19T10:00:03+08:00"], + ) + .unwrap(); + + let updated = + AgentDao::update_latest_assistant_message_usage(&conn, "session-usage", 2048, 512) + .unwrap(); + assert!(updated); + + let messages = AgentDao::get_messages(&conn, "session-usage").unwrap(); + assert_eq!(messages[0].usage, None); + assert_eq!(messages[1].usage, None); + assert_eq!( + messages[2].usage, + Some(crate::agent::types::TokenUsage::new(2048, 512)) + ); } } diff --git a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs index 449eb3f14..1605d6bbe 100644 --- a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs +++ b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs @@ -396,7 +396,9 @@ mod tests { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, - reasoning_content TEXT + reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER ); ", ) @@ -430,7 +432,9 @@ mod tests { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, - reasoning_content TEXT + reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER ); ", ) diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 754e35546..124875b39 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -624,6 +624,8 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { tool_calls_json TEXT, tool_call_id TEXT, reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER, FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE )", [], @@ -633,6 +635,14 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE agent_messages ADD COLUMN reasoning_content TEXT", [], ); + let _ = conn.execute( + "ALTER TABLE agent_messages ADD COLUMN input_tokens INTEGER", + [], + ); + let _ = conn.execute( + "ALTER TABLE agent_messages ADD COLUMN output_tokens INTEGER", + [], + ); // 创建 agent_messages 索引 conn.execute( diff --git a/src-tauri/crates/core/src/models/mod.rs b/src-tauri/crates/core/src/models/mod.rs index 86150fb46..4f37a9cd5 100644 --- a/src-tauri/crates/core/src/models/mod.rs +++ b/src-tauri/crates/core/src/models/mod.rs @@ -42,10 +42,12 @@ pub use skill_model::{ SkillPackageInspection, SkillRepo, SkillResourceSummary, SkillSourceKind, 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, + COVER_GENERATE_SKILL_DIRECTORY, DEFAULT_LIME_SKILL_DIRECTORIES, FORM_GENERATE_SKILL_DIRECTORY, + IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, + PDF_READ_SKILL_DIRECTORY, PRESENTATION_GENERATE_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, + WEBPAGE_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 c944f23aa..e46a45ac9 100644 --- a/src-tauri/crates/core/src/models/skill_model.rs +++ b/src-tauri/crates/core/src/models/skill_model.rs @@ -33,13 +33,16 @@ 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 PRESENTATION_GENERATE_SKILL_DIRECTORY: &str = "presentation_generate"; +pub const FORM_GENERATE_SKILL_DIRECTORY: &str = "form_generate"; 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 WEBPAGE_GENERATE_SKILL_DIRECTORY: &str = "webpage_generate"; pub const CONTENT_POST_WITH_COVER_SKILL_DIRECTORY: &str = "content_post_with_cover"; -pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 17] = [ +pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 20] = [ VIDEO_GENERATE_SKILL_DIRECTORY, TRANSCRIPTION_GENERATE_SKILL_DIRECTORY, BROADCAST_GENERATE_SKILL_DIRECTORY, @@ -52,10 +55,13 @@ pub const DEFAULT_LIME_SKILL_DIRECTORIES: [&str; 17] = [ REPORT_GENERATE_SKILL_DIRECTORY, SITE_SEARCH_SKILL_DIRECTORY, PDF_READ_SKILL_DIRECTORY, + PRESENTATION_GENERATE_SKILL_DIRECTORY, + FORM_GENERATE_SKILL_DIRECTORY, SUMMARY_SKILL_DIRECTORY, TRANSLATION_SKILL_DIRECTORY, ANALYSIS_SKILL_DIRECTORY, TYPESETTING_SKILL_DIRECTORY, + WEBPAGE_GENERATE_SKILL_DIRECTORY, CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, ]; @@ -576,7 +582,9 @@ mod tests { 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(FORM_GENERATE_SKILL_DIRECTORY)); assert!(is_default_lime_skill(SUMMARY_SKILL_DIRECTORY)); + assert!(is_default_lime_skill(WEBPAGE_GENERATE_SKILL_DIRECTORY)); assert!(is_default_lime_skill( CONTENT_POST_WITH_COVER_SKILL_DIRECTORY )); diff --git a/src-tauri/crates/lime-cli/Cargo.toml b/src-tauri/crates/lime-cli/Cargo.toml index a2ab7b704..626b0a134 100644 --- a/src-tauri/crates/lime-cli/Cargo.toml +++ b/src-tauri/crates/lime-cli/Cargo.toml @@ -13,6 +13,8 @@ path = "src/main.rs" clap.workspace = true serde_json.workspace = true lime-media-runtime.workspace = true +lime-core.workspace = true +tokio.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/src-tauri/crates/lime-cli/src/catalog.rs b/src-tauri/crates/lime-cli/src/catalog.rs index c776f800d..a7d4ab90f 100644 --- a/src-tauri/crates/lime-cli/src/catalog.rs +++ b/src-tauri/crates/lime-cli/src/catalog.rs @@ -10,7 +10,7 @@ AI Agent 技能: draft -> pending_submit -> queued -> running -> partial -> succeeded|failed|cancelled 示例: - lime task create image --prompt \"未来城市插图\" --size \"1024x1024\" + lime media image generate --prompt \"未来城市插图\" --size \"1024x1024\" lime task create transcription --source-path \"/tmp/interview.wav\" --output-format srt lime task create broadcast --title \"播客摘要\" --content \"原文内容\" lime task list --family image --status running @@ -21,7 +21,7 @@ AI Agent 技能: pub const TASK_AFTER_HELP: &str = "\ 常用命令: - lime task create image --prompt \"未来城市插图\" + lime media image generate --prompt \"未来城市插图\" lime task create transcription --source-url \"https://example.com/demo.mp4\" lime task create url-parse --url \"https://example.com\" --summary \"摘要\" lime task list --family image @@ -49,10 +49,10 @@ pub const TASK_ENTRIES: &[TaskCatalogEntry] = &[ TaskCatalogEntry { command_name: "image", task_type: TaskType::ImageGenerate, - description: "根据提示词创建普通配图任务。", + description: "根据提示词提交普通配图任务,并推进真实图片执行链。", skill_name: "image_generate", docs_dir: "src-tauri/resources/default-skills/image_generate", - example: "lime task create image --prompt \"未来城市插图\" --size \"1024x1024\"", + example: "lime media image generate --prompt \"未来城市插图\" --size \"1024x1024\"", }, TaskCatalogEntry { command_name: "cover", @@ -147,7 +147,7 @@ pub const SKILL_ENTRIES: &[SkillCatalogEntry] = &[ SkillCatalogEntry { name: "image_generate", description: "普通配图任务技能。", - recommended_command: "lime task create image --prompt \"...\"", + recommended_command: "lime media image generate --prompt \"...\"", skill_path: "src-tauri/resources/default-skills/image_generate/SKILL.md", references: &[], }, @@ -196,6 +196,27 @@ pub const SKILL_ENTRIES: &[SkillCatalogEntry] = &[ skill_path: "tools/lime-cli/domains/typesetting/SKILL.md", references: &["tools/lime-cli/domains/typesetting/references/create.md"], }, + SkillCatalogEntry { + name: "webpage_generate", + description: "单文件 HTML 网页生成技能。", + recommended_command: "lime skill show webpage_generate", + skill_path: "src-tauri/resources/default-skills/webpage_generate/SKILL.md", + references: &[], + }, + SkillCatalogEntry { + name: "presentation_generate", + description: "单文件 Markdown 演示稿生成技能。", + recommended_command: "lime skill show presentation_generate", + skill_path: "src-tauri/resources/default-skills/presentation_generate/SKILL.md", + references: &[], + }, + SkillCatalogEntry { + name: "form_generate", + description: "A2UI 表单生成技能。", + recommended_command: "lime skill show form_generate", + skill_path: "src-tauri/resources/default-skills/form_generate/SKILL.md", + references: &[], + }, SkillCatalogEntry { name: "modal_resource_search", description: "素材检索任务技能。", diff --git a/src-tauri/crates/lime-cli/src/main.rs b/src-tauri/crates/lime-cli/src/main.rs index 451962083..80c45a5a1 100644 --- a/src-tauri/crates/lime-cli/src/main.rs +++ b/src-tauri/crates/lime-cli/src/main.rs @@ -4,10 +4,13 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::{Args, Parser, Subcommand}; +use lime_core::config::load_config; use lime_media_runtime::{ - list_task_outputs, load_task_output, retry_task_artifact, update_task_status, - write_task_artifact, MediaRuntimeError, MediaTaskErrorOutput, TaskRelationships, TaskType, - TaskWriteOptions, DEFAULT_ARTIFACT_ROOT, + build_image_generation_endpoint, execute_image_generation_task as execute_image_task_runtime, + list_task_outputs, load_task_output, patch_task_artifact, retry_task_artifact, + update_task_status, write_task_artifact, ImageGenerationRunnerConfig, MediaRuntimeError, + MediaTaskErrorOutput, TaskArtifactPatch, TaskErrorRecord, TaskProgress, TaskRelationships, + TaskType, TaskWriteOptions, DEFAULT_ARTIFACT_ROOT, IMAGE_TASK_RUNNER_WORKER_ID, }; use serde_json::{json, Value}; @@ -619,7 +622,7 @@ fn run_task_create_command(command: TaskCreateCommand) -> Result Result { match command.command { MediaSubcommand::Image(image) => match image.command { - ImageSubcommand::Generate(args) => create_image_task(args), + ImageSubcommand::Generate(args) => generate_image_task(args), }, MediaSubcommand::Cover(cover) => match cover.command { CoverSubcommand::Generate(args) => create_cover_task(args), @@ -715,41 +718,186 @@ fn run_doctor_command(args: DoctorArgs) -> Result { })) } -fn create_image_task(args: ImageGenerateArgs) -> Result { - let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; - let output = write_task_artifact( - &workspace_root, +fn create_image_task_artifact( + workspace_root: &Path, + args: &ImageGenerateArgs, +) -> Result { + write_task_artifact( + workspace_root, TaskType::ImageGenerate, - args.title, - json!({ - "prompt": args.prompt, - "mode": args.mode, - "raw_text": args.raw_text, - "model": args.model, - "style": args.style, - "size": args.size, - "aspect_ratio": args.aspect_ratio, - "count": args.count, - "usage": args.usage, - "provider_id": args.provider_id, - "session_id": args.session_id, - "project_id": args.project_id, - "content_id": args.content_id, - "entry_source": args.entry_source, - "requested_target": args.requested_target, - "slot_id": args.slot_id, - "anchor_hint": args.anchor_hint, - "anchor_section_title": args.anchor_section_title, - "anchor_text": args.anchor_text, - "target_output_id": args.target_output_id, - "target_output_ref_id": args.target_output_ref_id, - "reference_images": args.reference_images, - }), + args.title.clone(), + build_image_task_payload(args), task_write_options(&args.output), + ) +} + +fn create_image_task(args: ImageGenerateArgs) -> Result { + generate_image_task(args) +} + +fn build_image_task_payload(args: &ImageGenerateArgs) -> Value { + json!({ + "prompt": args.prompt, + "mode": args.mode, + "raw_text": args.raw_text, + "model": args.model, + "style": args.style, + "size": args.size, + "aspect_ratio": args.aspect_ratio, + "count": args.count, + "usage": args.usage, + "provider_id": args.provider_id, + "session_id": args.session_id, + "project_id": args.project_id, + "content_id": args.content_id, + "entry_source": args.entry_source, + "requested_target": args.requested_target, + "slot_id": args.slot_id, + "anchor_hint": args.anchor_hint, + "anchor_section_title": args.anchor_section_title, + "anchor_text": args.anchor_text, + "target_output_id": args.target_output_id, + "target_output_ref_id": args.target_output_ref_id, + "reference_images": args.reference_images, + }) +} + +fn read_non_empty_env(name: &str) -> Option { + std::env::var(name).ok().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn read_env_port(name: &str) -> Result, String> { + let Some(raw) = read_non_empty_env(name) else { + return Ok(None); + }; + raw.parse::() + .map(Some) + .map_err(|error| format!("{name} 不是合法端口: {error}")) +} + +fn resolve_cli_image_generation_runner_config() -> Result { + let endpoint_override = read_non_empty_env("LIME_MEDIA_IMAGE_ENDPOINT"); + let api_key_override = read_non_empty_env("LIME_MEDIA_IMAGE_API_KEY") + .or_else(|| read_non_empty_env("LIME_SERVER_API_KEY")); + let host_override = read_non_empty_env("LIME_SERVER_HOST"); + let port_override = read_env_port("LIME_SERVER_PORT")?; + + let (loaded_config, config_load_error) = match load_config() { + Ok(config) => (Some(config), None), + Err(error) => (None, Some(error.to_string())), + }; + + let host = host_override + .or_else(|| { + loaded_config + .as_ref() + .map(|config| config.server.host.clone()) + }) + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = port_override + .or_else(|| loaded_config.as_ref().map(|config| config.server.port)) + .unwrap_or(9000); + let api_key = api_key_override + .or_else(|| { + loaded_config + .as_ref() + .map(|config| config.server.api_key.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + .ok_or_else(|| match config_load_error { + Some(error) => format!("Lime 本地图片服务未配置 API Key,且加载本地配置失败: {error}"), + None => "Lime 本地图片服务未配置 API Key".to_string(), + })?; + + Ok(ImageGenerationRunnerConfig { + endpoint: endpoint_override.unwrap_or_else(|| build_image_generation_endpoint(&host, port)), + api_key, + }) +} + +fn build_image_task_progress( + phase: &str, + message: impl Into, + percent: Option, +) -> TaskProgress { + TaskProgress { + phase: Some(phase.to_string()), + percent, + message: Some(message.into()), + preview_slots: Vec::new(), + } +} + +fn build_image_task_error( + code: &str, + message: impl Into, + retryable: bool, + stage: &str, +) -> TaskErrorRecord { + TaskErrorRecord { + code: code.to_string(), + message: message.into(), + retryable, + stage: Some(stage.to_string()), + provider_code: None, + occurred_at: None, + } +} + +fn mark_cli_image_task_failed( + workspace_root: &Path, + task_id: &str, + message: impl Into, +) -> Result { + let task_error = + build_image_task_error("image_worker_unavailable", message, false, "bootstrap"); + let output = patch_task_artifact( + workspace_root, + task_id, + None, + TaskArtifactPatch { + status: Some("failed".to_string()), + last_error: Some(Some(task_error.clone())), + progress: Some(build_image_task_progress( + "failed", + task_error.message.clone(), + None, + )), + current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), + ..TaskArtifactPatch::default() + }, )?; Ok(json!(output)) } +fn generate_image_task(args: ImageGenerateArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + let created = create_image_task_artifact(&workspace_root, &args)?; + let runner_config = match resolve_cli_image_generation_runner_config() { + Ok(config) => config, + Err(error_message) => { + return mark_cli_image_task_failed(&workspace_root, &created.task_id, error_message); + } + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| MediaRuntimeError::Io(format!("初始化图片任务运行时失败: {error}")))?; + let output = runtime.block_on(execute_image_task_runtime( + &workspace_root, + &created.task_id, + &runner_config, + ))?; + Ok(json!(output)) +} + fn create_cover_task(args: CoverGenerateArgs) -> Result { let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; let output = write_task_artifact( @@ -1070,45 +1218,119 @@ mod tests { assert_eq!(output["skill"]["name"], "broadcast_generate"); } + #[test] + fn skill_show_image_generate_prefers_media_command() { + let output = run(Cli { + command: Command::Skill(SkillCommand { + command: SkillSubcommand::Show(SkillShowArgs { + name: "image_generate".to_string(), + }), + }), + }) + .expect("show image skill"); + + assert_eq!( + output["skill"]["recommended_command"], + "lime media image generate --prompt \"...\"" + ); + } + + #[test] + fn skill_show_webpage_generate_returns_builtin_skill() { + let output = run(Cli { + command: Command::Skill(SkillCommand { + command: SkillSubcommand::Show(SkillShowArgs { + name: "webpage_generate".to_string(), + }), + }), + }) + .expect("show webpage skill"); + + assert_eq!(output["skill"]["name"], "webpage_generate"); + assert_eq!( + output["skill"]["skill_path"], + "src-tauri/resources/default-skills/webpage_generate/SKILL.md" + ); + } + + #[test] + fn skill_show_presentation_generate_returns_builtin_skill() { + let output = run(Cli { + command: Command::Skill(SkillCommand { + command: SkillSubcommand::Show(SkillShowArgs { + name: "presentation_generate".to_string(), + }), + }), + }) + .expect("show presentation skill"); + + assert_eq!(output["skill"]["name"], "presentation_generate"); + assert_eq!( + output["skill"]["skill_path"], + "src-tauri/resources/default-skills/presentation_generate/SKILL.md" + ); + } + + #[test] + fn skill_show_form_generate_returns_builtin_skill() { + let output = run(Cli { + command: Command::Skill(SkillCommand { + command: SkillSubcommand::Show(SkillShowArgs { + name: "form_generate".to_string(), + }), + }), + }) + .expect("show form skill"); + + assert_eq!(output["skill"]["name"], "form_generate"); + assert_eq!( + output["skill"]["skill_path"], + "src-tauri/resources/default-skills/form_generate/SKILL.md" + ); + } + #[test] fn create_image_task_preserves_extended_context_fields() { let temp_dir = tempfile::tempdir().expect("create temp dir"); - let output = create_image_task(ImageGenerateArgs { - prompt: "城市夜景".to_string(), - title: Some("夜景修图".to_string()), - mode: Some("edit".to_string()), - raw_text: Some("@配图 编辑 #img-2 去掉角标".to_string()), - model: Some("fal-ai/nano-banana-pro".to_string()), - style: Some("写实".to_string()), - size: Some("1024x1024".to_string()), - aspect_ratio: Some("1:1".to_string()), - count: Some(1), - usage: Some("claw-image-workbench".to_string()), - provider_id: Some("fal".to_string()), - session_id: Some("session-1".to_string()), - project_id: Some("project-1".to_string()), - content_id: Some("content-1".to_string()), - entry_source: Some("at_image_command".to_string()), - requested_target: Some("generate".to_string()), - slot_id: Some("slot-1".to_string()), - anchor_hint: Some("section_end".to_string()), - anchor_section_title: Some("核心观点".to_string()), - anchor_text: Some("这里是核心观点段落。".to_string()), - target_output_id: Some("task-image-1:output:1".to_string()), - target_output_ref_id: Some("img-2".to_string()), - reference_images: vec![ - "https://example.com/image-2.png".to_string(), - "/tmp/input-1.png".to_string(), - ], - output: SharedTaskWriteArgs { - workspace: Some(temp_dir.path().to_path_buf()), - output: None, - artifact_dir: None, - idempotency_key: None, - json: true, + let output = json!(create_image_task_artifact( + temp_dir.path(), + &ImageGenerateArgs { + prompt: "城市夜景".to_string(), + title: Some("夜景修图".to_string()), + mode: Some("edit".to_string()), + raw_text: Some("@配图 编辑 #img-2 去掉角标".to_string()), + model: Some("fal-ai/nano-banana-pro".to_string()), + style: Some("写实".to_string()), + size: Some("1024x1024".to_string()), + aspect_ratio: Some("1:1".to_string()), + count: Some(1), + usage: Some("claw-image-workbench".to_string()), + provider_id: Some("fal".to_string()), + session_id: Some("session-1".to_string()), + project_id: Some("project-1".to_string()), + content_id: Some("content-1".to_string()), + entry_source: Some("at_image_command".to_string()), + requested_target: Some("generate".to_string()), + slot_id: Some("slot-1".to_string()), + anchor_hint: Some("section_end".to_string()), + anchor_section_title: Some("核心观点".to_string()), + anchor_text: Some("这里是核心观点段落。".to_string()), + target_output_id: Some("task-image-1:output:1".to_string()), + target_output_ref_id: Some("img-2".to_string()), + reference_images: vec![ + "https://example.com/image-2.png".to_string(), + "/tmp/input-1.png".to_string(), + ], + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, }, - }) - .expect("create image"); + ) + .expect("create image")); assert_eq!(output["record"]["payload"]["mode"], "edit"); assert_eq!(output["record"]["payload"]["target_output_ref_id"], "img-2"); @@ -1159,38 +1381,41 @@ mod tests { #[test] fn task_list_returns_created_items() { let temp_dir = tempfile::tempdir().expect("create temp dir"); - let _ = create_image_task(ImageGenerateArgs { - prompt: "城市".to_string(), - title: None, - mode: None, - raw_text: None, - model: None, - style: None, - size: None, - aspect_ratio: None, - count: None, - usage: None, - provider_id: None, - session_id: None, - project_id: None, - content_id: None, - entry_source: None, - requested_target: None, - slot_id: None, - anchor_hint: None, - anchor_section_title: None, - anchor_text: None, - target_output_id: None, - target_output_ref_id: None, - reference_images: Vec::new(), - output: SharedTaskWriteArgs { - workspace: Some(temp_dir.path().to_path_buf()), - output: None, - artifact_dir: None, - idempotency_key: None, - json: true, + let _ = create_image_task_artifact( + temp_dir.path(), + &ImageGenerateArgs { + prompt: "城市".to_string(), + title: None, + mode: None, + raw_text: None, + model: None, + style: None, + size: None, + aspect_ratio: None, + count: None, + usage: None, + provider_id: None, + session_id: None, + project_id: None, + content_id: None, + entry_source: None, + requested_target: None, + slot_id: None, + anchor_hint: None, + anchor_section_title: None, + anchor_text: None, + target_output_id: None, + target_output_ref_id: None, + reference_images: Vec::new(), + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, }, - }) + ) .expect("create image"); let output = run(Cli { @@ -1217,38 +1442,41 @@ mod tests { #[test] fn task_list_supports_family_filter() { let temp_dir = tempfile::tempdir().expect("create temp dir"); - let _ = create_image_task(ImageGenerateArgs { - prompt: "城市".to_string(), - title: None, - mode: None, - raw_text: None, - model: None, - style: None, - size: None, - aspect_ratio: None, - count: None, - usage: None, - provider_id: None, - session_id: None, - project_id: None, - content_id: None, - entry_source: None, - requested_target: None, - slot_id: None, - anchor_hint: None, - anchor_section_title: None, - anchor_text: None, - target_output_id: None, - target_output_ref_id: None, - reference_images: Vec::new(), - output: SharedTaskWriteArgs { - workspace: Some(temp_dir.path().to_path_buf()), - output: None, - artifact_dir: None, - idempotency_key: None, - json: true, + let _ = create_image_task_artifact( + temp_dir.path(), + &ImageGenerateArgs { + prompt: "城市".to_string(), + title: None, + mode: None, + raw_text: None, + model: None, + style: None, + size: None, + aspect_ratio: None, + count: None, + usage: None, + provider_id: None, + session_id: None, + project_id: None, + content_id: None, + entry_source: None, + requested_target: None, + slot_id: None, + anchor_hint: None, + anchor_section_title: None, + anchor_text: None, + target_output_id: None, + target_output_ref_id: None, + reference_images: Vec::new(), + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, }, - }) + ) .expect("create image"); let _ = create_url_parse_task(UrlParseArgs { url: "https://example.com".to_string(), @@ -1331,6 +1559,32 @@ mod tests { ); } + #[test] + fn create_typesetting_task_preserves_platform_and_rules() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let output = create_typesetting_task(TypesettingArgs { + content: "这是一段待排版正文".to_string(), + target_platform: "小红书".to_string(), + title: Some("小红书排版".to_string()), + rules: vec!["语气:轻快".to_string(), "段落:短句".to_string()], + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, + }) + .expect("create typesetting task"); + + assert_eq!(output["record"]["payload"]["targetPlatform"], "小红书"); + assert_eq!(output["record"]["payload"]["content"], "这是一段待排版正文"); + assert_eq!( + output["record"]["payload"]["rules"], + json!(["语气:轻快", "段落:短句"]) + ); + } + #[test] fn task_attempts_returns_attempt_history() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/src-tauri/crates/media-runtime/Cargo.toml b/src-tauri/crates/media-runtime/Cargo.toml index 772fdc1fe..beb69c5f8 100644 --- a/src-tauri/crates/media-runtime/Cargo.toml +++ b/src-tauri/crates/media-runtime/Cargo.toml @@ -11,6 +11,9 @@ serde_json.workspace = true chrono.workspace = true uuid.workspace = true thiserror.workspace = true +reqwest.workspace = true [dev-dependencies] +axum.workspace = true tempfile.workspace = true +tokio.workspace = true diff --git a/src-tauri/crates/media-runtime/src/lib.rs b/src-tauri/crates/media-runtime/src/lib.rs index 06427b92c..3e22f1a6e 100644 --- a/src-tauri/crates/media-runtime/src/lib.rs +++ b/src-tauri/crates/media-runtime/src/lib.rs @@ -1,14 +1,54 @@ use std::fs; use std::path::{Component, Path, PathBuf}; use std::str::FromStr; +use std::time::Duration; use chrono::Utc; use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use thiserror::Error; use uuid::Uuid; pub const DEFAULT_ARTIFACT_ROOT: &str = ".lime/tasks"; +pub const IMAGE_TASK_RUNNER_WORKER_ID: &str = "lime-image-api-worker"; +pub const IMAGE_TASK_RUNNER_TIMEOUT_SECS: u64 = 300; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageGenerationRunnerConfig { + pub endpoint: String, + pub api_key: String, +} + +#[derive(Debug, Clone)] +struct PreparedImageTaskInput { + prompt: String, + model: String, + size: Option, + count: u32, + style: Option, + provider_id: Option, +} + +pub fn normalize_image_generation_service_host(host: &str) -> String { + let trimmed = host.trim(); + if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" { + return "127.0.0.1".to_string(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + return trimmed.to_string(); + } + if trimmed.contains(':') { + return format!("[{trimmed}]"); + } + trimmed.to_string() +} + +pub fn build_image_generation_endpoint(host: &str, port: u16) -> String { + format!( + "http://{}:{port}/v1/images/generations", + normalize_image_generation_service_host(host) + ) +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -950,6 +990,394 @@ fn normalize_status(status: &str) -> String { } } +fn read_payload_string(payload: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + payload + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn read_payload_positive_u32(payload: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + let value = payload.get(*key)?; + if let Some(number) = value.as_u64() { + return u32::try_from(number).ok().filter(|item| *item > 0); + } + value + .as_str() + .and_then(|item| item.trim().parse::().ok().filter(|parsed| *parsed > 0)) + }) +} + +fn prepare_image_task_input(task: &MediaTaskOutput) -> Result { + let payload = &task.record.payload; + let prompt = read_payload_string(payload, &["prompt"]) + .ok_or_else(|| "图片任务缺少 prompt,无法继续执行".to_string())?; + let count = read_payload_positive_u32(payload, &["count", "image_count"]).unwrap_or(1); + + Ok(PreparedImageTaskInput { + prompt, + model: read_payload_string(payload, &["model"]).unwrap_or_default(), + size: read_payload_string(payload, &["size"]), + count, + style: read_payload_string(payload, &["style"]), + provider_id: read_payload_string(payload, &["provider_id", "providerId"]), + }) +} + +fn collect_generated_images(response_body: &Value) -> Vec { + response_body + .get("data") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let record = item.as_object()?; + let url = record + .get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + record + .get("b64_json") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("data:image/png;base64,{value}")) + })?; + Some(json!({ + "url": url, + "revised_prompt": record + .get("revised_prompt") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()), + })) + }) + .collect() + }) + .unwrap_or_default() +} + +fn summarize_response_body(body: &str) -> String { + let trimmed = body.trim(); + if trimmed.is_empty() { + return "响应体为空".to_string(); + } + + let preview: String = trimmed.chars().take(240).collect(); + if trimmed.chars().count() > preview.chars().count() { + format!("{preview}...") + } else { + preview + } +} + +fn build_image_task_progress(phase: &str, message: String, percent: Option) -> TaskProgress { + TaskProgress { + phase: Some(phase.to_string()), + percent, + message: Some(message), + preview_slots: Vec::new(), + } +} + +fn build_image_task_error( + code: &str, + message: impl Into, + retryable: bool, + stage: &str, +) -> TaskErrorRecord { + TaskErrorRecord { + code: code.to_string(), + message: message.into(), + retryable, + stage: Some(stage.to_string()), + provider_code: None, + occurred_at: Some(Utc::now().to_rfc3339()), + } +} + +fn load_current_image_task( + workspace_root: &Path, + task_id: &str, +) -> Result { + load_task_output(workspace_root, task_id, None) +} + +fn patch_image_task( + workspace_root: &Path, + task_id: &str, + patch: TaskArtifactPatch, +) -> Result { + patch_task_artifact(workspace_root, task_id, None, patch) +} + +fn mark_image_task_failed( + workspace_root: &Path, + task_id: &str, + error: TaskErrorRecord, + on_update: &mut F, +) -> Result +where + F: FnMut(&MediaTaskOutput), +{ + let current = load_current_image_task(workspace_root, task_id)?; + if current.normalized_status == "cancelled" { + return Ok(current); + } + + let output = patch_image_task( + workspace_root, + task_id, + TaskArtifactPatch { + status: Some("failed".to_string()), + last_error: Some(Some(error.clone())), + progress: Some(build_image_task_progress( + "failed", + error.message.clone(), + None, + )), + current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), + ..TaskArtifactPatch::default() + }, + )?; + on_update(&output); + Ok(output) +} + +pub async fn execute_image_generation_task( + workspace_root: &Path, + task_id: &str, + runner_config: &ImageGenerationRunnerConfig, +) -> Result { + execute_image_generation_task_with_hook(workspace_root, task_id, runner_config, |_| {}).await +} + +pub async fn execute_image_generation_task_with_hook( + workspace_root: &Path, + task_id: &str, + runner_config: &ImageGenerationRunnerConfig, + mut on_update: F, +) -> Result +where + F: FnMut(&MediaTaskOutput) + Send, +{ + let current = load_current_image_task(workspace_root, task_id)?; + if matches!( + current.normalized_status.as_str(), + "cancelled" | "failed" | "succeeded" | "partial" + ) { + return Ok(current); + } + + let queued_output = if current.normalized_status == "pending" { + let output = patch_image_task( + workspace_root, + task_id, + TaskArtifactPatch { + status: Some("queued".to_string()), + progress: Some(build_image_task_progress( + "queued", + "图片任务已进入队列,等待图片服务响应。".to_string(), + Some(0), + )), + current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), + ..TaskArtifactPatch::default() + }, + )?; + on_update(&output); + output + } else { + current + }; + + if queued_output.normalized_status == "cancelled" { + return Ok(queued_output); + } + + let prepared_input = match prepare_image_task_input(&queued_output) { + Ok(prepared_input) => prepared_input, + Err(message) => { + let task_error = + build_image_task_error("invalid_image_task_payload", message, false, "payload"); + return mark_image_task_failed(workspace_root, task_id, task_error, &mut on_update); + } + }; + + let running_output = patch_image_task( + workspace_root, + task_id, + TaskArtifactPatch { + status: Some("running".to_string()), + progress: Some(build_image_task_progress( + "running", + "图片生成中,结果会自动回填到对话与画布。".to_string(), + None, + )), + current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), + ..TaskArtifactPatch::default() + }, + )?; + on_update(&running_output); + + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(IMAGE_TASK_RUNNER_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + let request_body = json!({ + "prompt": prepared_input.prompt.clone(), + "model": prepared_input.model.clone(), + "n": prepared_input.count.max(1), + "size": prepared_input.size.clone(), + "response_format": "b64_json", + "quality": Value::Null, + "style": prepared_input.style.clone(), + "user": task_id, + }); + + let mut request_builder = client + .post(&runner_config.endpoint) + .header("Authorization", format!("Bearer {}", runner_config.api_key)); + if let Some(provider_id) = prepared_input + .provider_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + request_builder = request_builder.header("X-Provider-Id", provider_id); + } + + let response = match request_builder.json(&request_body).send().await { + Ok(response) => response, + Err(error) => { + let task_error = build_image_task_error( + "image_request_failed", + format!("调用图片服务失败: {error}"), + true, + "request", + ); + return mark_image_task_failed(workspace_root, task_id, task_error, &mut on_update); + } + }; + + let status = response.status(); + let response_body_raw = match response.text().await { + Ok(body) => body, + Err(error) => { + let task_error = build_image_task_error( + "image_response_read_failed", + format!("读取图片服务响应失败: {error}"), + false, + "response", + ); + return mark_image_task_failed(workspace_root, task_id, task_error, &mut on_update); + } + }; + let response_body: Value = match serde_json::from_str(&response_body_raw) { + Ok(body) => body, + Err(error) => { + let detail = summarize_response_body(&response_body_raw); + let task_error = build_image_task_error( + "image_response_parse_failed", + format!("解析图片服务响应失败: {error};{detail}"), + false, + "response", + ); + return mark_image_task_failed(workspace_root, task_id, task_error, &mut on_update); + } + }; + + if !status.is_success() { + let error_code = response_body + .get("error") + .and_then(|value| value.get("code")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("image_generation_failed"); + let error_message = response_body + .get("error") + .and_then(|value| value.get("message")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("图片服务未返回可用结果"); + let task_error = build_image_task_error( + error_code, + error_message, + status.is_server_error() || status.as_u16() == 429, + "request", + ); + return mark_image_task_failed(workspace_root, task_id, task_error, &mut on_update); + } + + let images = collect_generated_images(&response_body); + if images.is_empty() { + let task_error = build_image_task_error( + "image_result_empty", + "图片服务已返回成功,但没有可用的图片地址", + false, + "result", + ); + return mark_image_task_failed(workspace_root, task_id, task_error, &mut on_update); + } + + let latest = load_current_image_task(workspace_root, task_id)?; + if latest.normalized_status == "cancelled" { + return Ok(latest); + } + + let final_status = if images.len() < prepared_input.count as usize { + "partial" + } else { + "succeeded" + }; + let result_value = json!({ + "provider_id": prepared_input.provider_id, + "model": if prepared_input.model.trim().is_empty() { + None:: + } else { + Some(prepared_input.model) + }, + "size": prepared_input.size, + "requested_count": prepared_input.count, + "received_count": images.len(), + "images": images, + "response": response_body, + }); + let success_message = if final_status == "partial" { + format!("图片任务已返回部分结果,共生成 {} 张。", images.len()) + } else { + format!("图片任务已完成,共生成 {} 张。", images.len()) + }; + let completed = patch_image_task( + workspace_root, + task_id, + TaskArtifactPatch { + status: Some(final_status.to_string()), + result: Some(Some(result_value)), + last_error: Some(None), + progress: Some(build_image_task_progress( + final_status, + success_message, + Some(100), + )), + current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), + ..TaskArtifactPatch::default() + }, + )?; + on_update(&completed); + Ok(completed) +} + fn supports_idempotent_reuse(normalized_status: &str) -> bool { matches!(normalized_status, "pending" | "queued" | "running") } @@ -1730,6 +2158,15 @@ pub fn parse_media_task_output(raw: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use std::sync::{Arc, Mutex}; + + use axum::{ + extract::Json, + http::{HeaderMap, StatusCode}, + routing::post, + Router, + }; + use tokio::net::TcpListener; #[test] fn write_media_task_artifact_uses_default_task_root() { @@ -2068,4 +2505,192 @@ mod tests { vec![".lime/tasks/image_generate/demo.json".to_string()] ); } + + #[tokio::test] + async fn execute_image_generation_task_should_advance_task_file_to_succeeded() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let captured_provider_id = Arc::new(Mutex::new(None::)); + let captured_response_format = Arc::new(Mutex::new(None::)); + let created = write_task_artifact( + temp_dir.path(), + TaskType::ImageGenerate, + Some("青柠主视觉".to_string()), + json!({ + "prompt": "未来感青柠实验室", + "size": "1024x1024", + "count": 1, + "style": "cinematic", + "provider_id": "fal", + "model": "fal-ai/nano-banana-pro", + }), + TaskWriteOptions::default(), + ) + .expect("create task"); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind image api"); + let address = listener.local_addr().expect("resolve address"); + let captured_provider_id_for_server = Arc::clone(&captured_provider_id); + let captured_response_format_for_server = Arc::clone(&captured_response_format); + let server = tokio::spawn(async move { + let app = Router::new().route( + "/v1/images/generations", + post(move |headers: HeaderMap, Json(body): Json| { + let captured_provider_id = Arc::clone(&captured_provider_id_for_server); + let captured_response_format = Arc::clone(&captured_response_format_for_server); + async move { + let provider_id = headers + .get("x-provider-id") + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + *captured_provider_id.lock().expect("lock provider id") = provider_id; + let response_format = body + .get("response_format") + .and_then(Value::as_str) + .map(|value| value.to_string()); + *captured_response_format + .lock() + .expect("lock response format") = response_format; + ( + StatusCode::OK, + Json(json!({ + "created": 1_717_200_000i64, + "data": [ + { + "b64_json": "ZmFrZS1saW1lLWltYWdl", + "revised_prompt": "未来感青柠实验室主视觉" + } + ] + })), + ) + } + }), + ); + axum::serve(listener, app).await.expect("serve image api"); + }); + + let result = execute_image_generation_task( + temp_dir.path(), + &created.task_id, + &ImageGenerationRunnerConfig { + endpoint: format!("http://{address}/v1/images/generations"), + api_key: "test-key".to_string(), + }, + ) + .await + .expect("execute image task"); + + assert_eq!(result.normalized_status, "succeeded"); + assert_eq!( + result + .record + .result + .as_ref() + .and_then(|value| value.get("images")) + .and_then(Value::as_array) + .map(Vec::len), + Some(1) + ); + assert_eq!( + result + .record + .result + .as_ref() + .and_then(|value| value.get("images")) + .and_then(Value::as_array) + .and_then(|images| images.first()) + .and_then(|value| value.get("url")) + .and_then(Value::as_str), + Some("data:image/png;base64,ZmFrZS1saW1lLWltYWdl") + ); + assert_eq!( + result + .record + .attempts + .last() + .and_then(|attempt| attempt.worker_id.as_deref()), + Some(IMAGE_TASK_RUNNER_WORKER_ID) + ); + assert_eq!( + captured_provider_id + .lock() + .expect("lock provider id") + .clone(), + Some("fal".to_string()) + ); + assert_eq!( + captured_response_format + .lock() + .expect("lock response format") + .clone(), + Some("b64_json".to_string()) + ); + + server.abort(); + } + + #[tokio::test] + async fn execute_image_generation_task_should_mark_task_failed_when_service_rejects() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let created = write_task_artifact( + temp_dir.path(), + TaskType::ImageGenerate, + Some("青柠主视觉".to_string()), + json!({ + "prompt": "未来感青柠实验室", + "size": "1024x1024", + "count": 1, + "provider_id": "fal", + "model": "fal-ai/nano-banana-pro", + }), + TaskWriteOptions::default(), + ) + .expect("create task"); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind image api"); + let address = listener.local_addr().expect("resolve address"); + let server = tokio::spawn(async move { + let app = Router::new().route( + "/v1/images/generations", + post(|| async move { + ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ + "error": { + "code": "rate_limited", + "message": "图片服务限流,请稍后重试" + } + })), + ) + }), + ); + axum::serve(listener, app).await.expect("serve image api"); + }); + + let result = execute_image_generation_task( + temp_dir.path(), + &created.task_id, + &ImageGenerationRunnerConfig { + endpoint: format!("http://{address}/v1/images/generations"), + api_key: "test-key".to_string(), + }, + ) + .await + .expect("image task should settle to failed output"); + + assert_eq!(result.normalized_status, "failed"); + assert_eq!( + result.last_error.as_ref().map(|value| value.code.as_str()), + Some("rate_limited") + ); + assert_eq!( + result.last_error.as_ref().map(|value| value.retryable), + Some(true) + ); + + server.abort(); + } } diff --git a/src-tauri/crates/services/src/session_context_service.rs b/src-tauri/crates/services/src/session_context_service.rs index e3cadc45f..4e5fb43c4 100644 --- a/src-tauri/crates/services/src/session_context_service.rs +++ b/src-tauri/crates/services/src/session_context_service.rs @@ -600,6 +600,8 @@ mod tests { tool_calls_json TEXT, tool_call_id TEXT, reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER, FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE )", [], diff --git a/src-tauri/resources/default-skills/form_generate/SKILL.md b/src-tauri/resources/default-skills/form_generate/SKILL.md new file mode 100644 index 000000000..2a8d70014 --- /dev/null +++ b/src-tauri/resources/default-skills/form_generate/SKILL.md @@ -0,0 +1,72 @@ +--- +name: form_generate +description: 根据目标说明生成一份可直接在聊天区渲染的 A2UI 表单,复用 Lime 现有表单协议。 +metadata: + lime_argument_hint: 输入表单目标、表单类型、受众、风格与字段数。 + lime_when_to_use: 用户需要快速产出问卷、报名表、反馈表、申请表或线索收集表时使用。 + lime_version: 1.0.0 + lime_execution_mode: prompt + lime_surface: workbench + lime_category: creation +--- + +你是 Lime 的表单生成助手。 + +## 工作目标 + +根据用户输入生成一份可直接在聊天区渲染的 A2UI 表单,不要发明新的表单协议。 + +## 执行规则 + +- 优先使用 `form_request.prompt`、`form_request.content` 与当前对话里最近的相关上下文。 +- 最终必须输出且只输出一个 ` ```a2ui ` 代码块;代码块内放可被 Lime 现有 parser 识别的 JSON。 +- 优先使用简化表单格式: + `{"type":"form","title":"...","description":"...","fields":[...],"submitLabel":"提交"}` +- 字段类型只允许使用 `choice`、`text`、`slider`、`checkbox`。 +- `choice` 字段必须补 `options`,每个选项至少包含 `value` 与 `label`。 +- 若用户指定了 `form_type`、`style`、`audience`、`field_count`,必须显式遵循;未指定时默认按高完成度通用表单执行。 +- 如果是报名/申请/线索表单,默认包含姓名、联系方式、角色/需求、隐私同意等字段。 +- 如果是问卷/反馈表单,优先包含目标背景、满意度/评分、选择项、开放意见等字段。 +- 字段文案必须贴合用户主题,不要输出 lorem ipsum、字段一/字段二 这类空占位。 +- 字段数尽量贴近用户要求;未指定时默认控制在 5-8 个字段。 +- 如信息不足,最多追问 1 个关键问题;除非真的缺失目标,否则不要停在追问。 +- 不要输出 ``、HTML、Markdown 表格或新的自定义 DSL。 +- 若需要补充说明,把说明放在 ` ```a2ui ` 代码块之后,控制在 2-4 行。 + +## 简化表单格式要求 + +- `title`:表单标题,必须具体。 +- `description`:一句说明表单用途和填写预期。 +- `fields`:数组,字段顺序需符合填写流。 +- 每个字段必须包含: + - `id` + - `type` + - `label` +- 选填字段按需添加: + - `description` + - `placeholder` + - `default` + - `options` + - `min` + - `max` + - `variant` +- `submitLabel` 默认可用“提交”“发送报名”“提交反馈”等贴合语义的文案。 + +## 输出格式(固定) + +```a2ui +{ + "type": "form", + "title": "AI Workshop 报名表", + "description": "收集活动报名信息与参与偏好。", + "fields": [ + { + "id": "name", + "type": "text", + "label": "姓名", + "placeholder": "请输入姓名" + } + ], + "submitLabel": "提交报名" +} +``` diff --git a/src-tauri/resources/default-skills/image_generate/SKILL.md b/src-tauri/resources/default-skills/image_generate/SKILL.md index f6b30712f..596f05fa3 100644 --- a/src-tauri/resources/default-skills/image_generate/SKILL.md +++ b/src-tauri/resources/default-skills/image_generate/SKILL.md @@ -5,7 +5,7 @@ allowed-tools: Bash, lime_create_image_generation_task metadata: lime_argument_hint: 输入主题、画面主体、风格、构图、数量、尺寸。 lime_when_to_use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。 - lime_version: 1.3.0 + lime_version: 1.3.1 lime_execution_mode: prompt lime_surface: workbench lime_category: media @@ -20,13 +20,18 @@ metadata: ## 执行规则 - 先判断是否属于封面需求;封面需求请转 `cover_generate`。 +- 当前已经进入 `@配图/@修图/@重绘 -> image_skill_launch -> Skill(image_generate)` 主链,不要先调用 `ToolSearch`、`WebSearch`、`Read`、`Glob`、`Grep` 去“找技能”或“确认工具”。 +- 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前上下文已经明确要求执行图片任务。 - 提示词必须包含主体、场景、风格,不要空泛。 - 若调用方在结构化上下文里提供了 `image_task`,必须优先复用其中的 `mode`、`reference_images`、`target_output_*`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target` 等字段,不要擅自丢失。 +- 若上下文已提供 `provider_id` 或 `model`,提交任务时也要原样透传,不要降级成匿名默认值。 - 若用户给了参考素材,需体现在参数中;若 `reference_images` 已经是文件路径、URL 或输入图片物化路径,直接原样透传。 -- 优先调用 `Bash` 执行 `lime media image generate --json` 创建任务;如当前环境只提供 `task create` 入口,也可使用 `lime task create image --json`。 +- 优先调用 `Bash` 执行 `lime media image generate --json` 提交任务。 +- 如果当前环境实际走到 `lime task create image --json` 兼容入口,也必须确认它复用同一条图片执行链,而不是只写一个 `pending_submit` 任务文件后停止。 - 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_image_generation_task`。 -- 任务结果必须兼容 `lime task create image --json` 的任务文件契约。 -- `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`;如有上下文,还应携带 `mode`、`reference_images`、`target_output_id`、`target_output_ref_id`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`。 +- 回退 `lime_create_image_generation_task` 时,仍必须复用同一条图片任务执行链;不要传 `outputPath`,不要把任务写成 markdown 文稿。 +- `lime media image generate --json` 与 `lime task create image --json` 的任务结果都必须兼容同一份图片任务文件契约。 +- `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`;如有上下文,还应携带 `mode`、`provider_id`、`model`、`reference_images`、`target_output_id`、`target_output_ref_id`、`session_id`、`project_id`、`content_id`、`entry_source`、`requested_target`。 ## 输出格式(固定) @@ -35,4 +40,4 @@ metadata: - 任务类型:image_generate - 任务 ID:{task_id} - 任务文件:{path} -- 状态:pending_submit +- 状态:{status} diff --git a/src-tauri/resources/default-skills/presentation_generate/SKILL.md b/src-tauri/resources/default-skills/presentation_generate/SKILL.md new file mode 100644 index 000000000..da998823f --- /dev/null +++ b/src-tauri/resources/default-skills/presentation_generate/SKILL.md @@ -0,0 +1,68 @@ +--- +name: presentation_generate +description: 根据目标说明生成一份可直接讲述、继续导出的单文件演示稿 Markdown,并落到工作区供右侧 viewer 预览。 +metadata: + lime_argument_hint: 输入演示目标、演示类型、受众、风格、页数与核心论点。 + lime_when_to_use: 用户需要快速产出路演稿、汇报稿、培训稿或方案演示时使用。 + lime_version: 1.0.0 + lime_execution_mode: prompt + lime_surface: workbench + lime_category: creation +--- + +你是 Lime 的演示稿生成助手。 + +## 工作目标 + +根据用户输入生成一个可直接讲述和继续导出的单文件 Markdown 演示稿,并通过 `` 落到工作区。 + +## 执行规则 + +- 优先使用 `presentation_request.prompt`、`presentation_request.content` 与当前对话里最近的相关上下文。 +- 默认产出一个自包含的 Markdown 演示稿,不依赖额外构建工具才能查看。 +- 如果用户提供了 `deck_type`、`style`、`audience`、`slide_count`,必须显式遵循;未指定时默认按高完成度通用演示稿执行。 +- 演示稿必须可讲述,每一页都要给出明确标题、核心要点与讲述重点,不要只写空泛标题。 +- 页面结构至少包含:封面页、目录页、问题/背景页、方案/核心论点页、案例/证明页、结论/行动页。 +- 如果目标页数已给出,尽量贴近;未给出时默认控制在 8-12 页。 +- 页面文案必须贴合用户主题,不要输出 lorem ipsum、占位 bullet 或泛泛模板词。 +- 如信息不足,最多追问 1 个关键问题;除非真的缺失目标,否则不要停在追问。 +- 最终必须输出且只输出一个 ``,文件扩展名必须是 `.md`。 +- `` 内只能放最终 Markdown,不要再包 Markdown 代码块。 +- 如果需要补充说明,把说明放在 `` 之后,且不要重复整份文稿。 + +## Markdown 结构要求 + +- 使用 Markdown 标题和分隔线组织页面。 +- 文件开头先给出演示名称、演示类型、受众与建议讲述时长。 +- 每一页统一使用以下结构: + `## 01 封面` + `- 页面目标:...` + `- 标题:...` + `- 要点:...` + `- 讲述备注:...` +- 页面之间使用 `---` 分隔,方便后续导出或转换。 +- 如果适合加入数据、案例或图示建议,可以在对应页添加 `- 可视化建议:...`。 + +## 输出格式(固定) + + +# 演示稿标题 + +- 演示类型:... +- 目标受众:... +- 建议讲述时长:... + +--- + +## 01 封面 +- 页面目标:... +- 标题:... +- 要点:... +- 讲述备注:... + + +## 收尾要求 + +- 如果用户明确要求“融资路演”“销售方案”“培训课件”“周报汇报”等类型,页面结构必须体现对应逻辑。 +- 如果用户没有指定文件名,使用与主题相关的 slug。 +- 若用户还需要一句摘要,可在文件后补 2-4 行简短总结:适用场景、页数结构、建议下一步。 diff --git a/src-tauri/resources/default-skills/site_search/SKILL.md b/src-tauri/resources/default-skills/site_search/SKILL.md index d9cdfe92a..0c08d5407 100644 --- a/src-tauri/resources/default-skills/site_search/SKILL.md +++ b/src-tauri/resources/default-skills/site_search/SKILL.md @@ -1,6 +1,6 @@ --- name: site_search -description: 通过站点适配器检索指定站点内容(GitHub、知乎、B站、36Kr、linux.do、什么值得买、Yahoo Finance)。 +description: 通过站点适配器检索指定站点内容(GitHub、知乎、B站、36Kr、linux.do、什么值得买、Yahoo Finance、X 长文)。 allowed-tools: lime_site_run, lime_site_list, lime_site_search, lime_site_info metadata: lime_argument_hint: 输入目标站点、查询关键词或标的、返回数量,以及是否要保存到当前内容/项目。 @@ -44,6 +44,7 @@ metadata: - 36Kr:`36kr/newsflash` - linux.do:`linux-do/categories`、`linux-do/hot` - 什么值得买:`smzdm/search` +- X:`x/article-export` - Yahoo Finance:`yahoo-finance/quote` 全量参数、登录提示和域名清单见 `references/adapter-catalog.md`。 @@ -58,6 +59,7 @@ metadata: - 仓库搜索优先 `github/search` - 仓库 issue 列表优先 `github/issues` - 热榜/热门优先 `zhihu/hot`、`linux-do/hot`、`36kr/newsflash` + - X 长文导出优先 `x/article-export` - 股票报价优先 `yahoo-finance/quote` 3. 用 `lime_site_info` 校验参数 4. 用 `lime_site_run` 执行 diff --git a/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md b/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md index 0fddb25a1..40dbf143b 100644 --- a/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md +++ b/src-tauri/resources/default-skills/site_search/references/adapter-catalog.md @@ -19,6 +19,7 @@ | `linux-do/categories` | linux.do | 读取 linux.do 分类列表。 | 无 | limit | 请先在浏览器中登录 linux.do,再重试该命令。 | | `linux-do/hot` | linux.do | 读取 linux.do 热门话题。 | 无 | limit, period | 请先在浏览器中登录 linux.do,再重试该命令。 | | `smzdm/search` | search.smzdm.com | 按关键词采集什么值得买搜索结果。 | query | limit | 无 | +| `x/article-export` | x.com | 把 X 长文导出为 Markdown 正文和图片资源包。 | url | 无 | 如果页面内容未完整加载,请先在浏览器中登录 X 并打开目标文章页面。 | | `yahoo-finance/quote` | finance.yahoo.com | 读取 Yahoo Finance 股票行情摘要。 | symbol | 无 | 无 | | `zhihu/hot` | www.zhihu.com | 采集知乎热榜问题列表。 | 无 | limit | 请先在浏览器中登录知乎,再重试该命令。 | | `zhihu/search` | www.zhihu.com | 按关键词采集知乎搜索结果。 | query | limit | 请先在浏览器中登录知乎,再重试该命令。 | @@ -33,6 +34,7 @@ - 社区目录:`linux-do/categories` - 快讯流:`36kr/newsflash` - 商品搜索:`smzdm/search` +- X 长文导出:`x/article-export` - 行情摘要:`yahoo-finance/quote` ## 使用提醒 diff --git a/src-tauri/resources/default-skills/webpage_generate/SKILL.md b/src-tauri/resources/default-skills/webpage_generate/SKILL.md new file mode 100644 index 000000000..d36c9ce89 --- /dev/null +++ b/src-tauri/resources/default-skills/webpage_generate/SKILL.md @@ -0,0 +1,54 @@ +--- +name: webpage_generate +description: 根据目标说明生成可直接预览的单文件 HTML 网页,并落到工作区供右侧 viewer 预览。 +metadata: + lime_argument_hint: 输入网页目标、页面类型、风格、技术偏好、核心卖点与 CTA。 + lime_when_to_use: 用户需要快速产出落地页、官网页、活动页或产品展示页原型时使用。 + lime_version: 1.0.0 + lime_execution_mode: prompt + lime_surface: workbench + lime_category: creation +--- + +你是 Lime 的网页生成助手。 + +## 工作目标 + +根据用户输入生成一个可直接预览的单文件 HTML 页面,并通过 `` 落到工作区。 + +## 执行规则 + +- 优先使用 `webpage_request.prompt`、`webpage_request.content` 与当前对话里最近的相关上下文。 +- 默认产出一个自包含 HTML 文件,不依赖 npm、构建工具、React/Vite 或外部资源才能预览。 +- 页面必须同时兼顾桌面与移动端,包含清晰的信息层级、主 CTA、核心卖点区块与结尾收口。 +- 如果用户提供了 `page_type`、`style`、`tech_stack`,必须显式遵循;未指定时默认按高完成度落地页执行。 +- 视觉方向要明确,不要默认紫色玻璃态模板感;优先使用稳定的排版、间距、层次和少量必要动效。 +- 页面文案必须贴合用户主题,不要输出 lorem ipsum 或空泛占位文案。 +- 如信息不足,最多追问 1 个关键问题;除非真的缺失目标,否则不要停在追问。 +- 最终必须输出且只输出一个 ``,文件扩展名必须是 `.html`。 +- `` 内只能放最终 HTML,不要再包 Markdown 代码块。 +- 如果需要补充说明,把说明放在 `` 之后,且不要重复整页源码。 + +## HTML 要求 + +- 使用语义化 HTML 结构。 +- 包含 ``、``、``、`` 与 `viewport`。 +- 使用 CSS variables 管理颜色、间距与阴影。 +- 默认使用内联 CSS,必要时可加入少量原生 JS 交互。 +- 页面至少包含:首屏、卖点/能力区、证明/案例区、CTA 区、页脚。 +- CTA 按钮、卡片、间距与背景层必须可读,不要出现贴边、溢出或移动端布局断裂。 + +## 输出格式(固定) + + + + +... + + + +## 收尾要求 + +- 如果用户明确要求“官网”“活动页”“产品页”等页面类型,文件内容必须体现对应结构。 +- 如果用户没有指定文件名,使用与主题相关的 slug。 +- 若用户还需要页面说明,可在文件后补 2-4 行简短总结:页面定位、结构亮点、建议下一步。 diff --git a/src-tauri/resources/site-adapters/bundled/index.json b/src-tauri/resources/site-adapters/bundled/index.json index b09d42039..4adc9a5a1 100644 --- a/src-tauri/resources/site-adapters/bundled/index.json +++ b/src-tauri/resources/site-adapters/bundled/index.json @@ -238,6 +238,30 @@ "script_file": "scripts/yahoo-finance-quote.js", "source_version": "2026-03-28" }, + { + "name": "x/article-export", + "domain": "x.com", + "description": "把 X 长文导出为 Markdown 正文和图片资源包。", + "read_only": true, + "capabilities": ["article_export", "markdown_bundle", "research"], + "args": [ + { + "name": "url", + "description": "X 长文链接,格式 https://x.com/<账号>/article/<文章ID>", + "required": true, + "arg_type": "string", + "example": "https://x.com/GoogleCloudTech/article/2033953579824758855" + } + ], + "example": "x/article-export {\"url\":\"https://x.com/GoogleCloudTech/article/2033953579824758855\"}", + "auth_hint": "如果页面内容未完整加载,请先在浏览器中登录 X 并打开目标文章页面。", + "entry": { + "kind": "builder", + "id": "x_article_export" + }, + "script_file": "scripts/x-article-export.js", + "source_version": "2026-04-07" + }, { "name": "zhihu/hot", "domain": "www.zhihu.com", diff --git a/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js b/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js new file mode 100644 index 000000000..4bcc0b91d --- /dev/null +++ b/src-tauri/resources/site-adapters/bundled/scripts/x-article-export.js @@ -0,0 +1,398 @@ +async (args, helpers) => { + const ARTICLE_ROOT_SELECTOR = '[data-testid="twitterArticleReadView"]'; + const ARTICLE_TITLE_SELECTOR = '[data-testid="twitter-article-title"]'; + const ARTICLE_CONTENT_SELECTOR = + '[data-testid="longformRichTextComponent"] [data-contents="true"], [data-testid="longformRichTextComponent"]'; + const IMAGE_SELECTOR = '[data-testid="tweetPhoto"] img'; + const CODE_BLOCK_SELECTOR = '[data-testid="markdown-code-block"]'; + + function normalizeText(value) { + return String(value || "") + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + function normalizeMultilineText(value) { + return String(value || "") + .replace(/\r\n/g, "\n") + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + + function escapeMarkdownText(value) { + return String(value || "").replace(/([\\`*_{}\[\]()#+\-.!|>])/g, "\\$1"); + } + + function normalizeArticleUrl(rawUrl) { + try { + const url = new URL(rawUrl, location.href); + const hostname = url.hostname.replace(/^www\./, "").toLowerCase(); + if (hostname !== "x.com" && hostname !== "twitter.com") { + return null; + } + if (!/\/article\//.test(url.pathname)) { + return null; + } + return url.toString(); + } catch { + return null; + } + } + + function resolveAuthor() { + const titleMatch = document.title.match(/^(.+?) on X/i); + if (titleMatch?.[1]) { + return normalizeText(titleMatch[1]); + } + + const pathMatch = location.pathname.match(/^\/([^/]+)/); + if (pathMatch?.[1]) { + return `@${pathMatch[1]}`; + } + + return null; + } + + function normalizeImageUrl(rawUrl) { + try { + const url = new URL(rawUrl, location.href); + if (url.hostname.includes("pbs.twimg.com")) { + if (url.searchParams.has("format")) { + url.searchParams.set("name", "orig"); + } + } + return url.toString(); + } catch { + return rawUrl || ""; + } + } + + function resolveSuggestedImageName(rawUrl, fallbackIndex) { + try { + const url = new URL(rawUrl, location.href); + const format = normalizeText(url.searchParams.get("format")); + const ext = format || normalizeText(url.pathname.split(".").pop()) || "jpg"; + return `image-${fallbackIndex + 1}.${ext}`; + } catch { + return `image-${fallbackIndex + 1}.jpg`; + } + } + + function resolveCodeLanguage(element) { + const candidates = [ + element.getAttribute("data-language"), + element.querySelector("[data-language]")?.getAttribute("data-language"), + element.querySelector("code")?.getAttribute("data-language"), + element.querySelector("pre")?.getAttribute("data-language"), + element.querySelector("code")?.className, + ]; + + for (const candidate of candidates) { + const normalized = normalizeText(candidate); + if (!normalized) { + continue; + } + const match = normalized.match(/language-([a-z0-9_+-]+)/i); + if (match?.[1]) { + return match[1].toLowerCase(); + } + if (/^[a-z0-9_+-]+$/i.test(normalized)) { + return normalized.toLowerCase(); + } + } + + return ""; + } + + function createState() { + return { + imageUrls: new Set(), + images: [], + }; + } + + function registerImage(state, imageElement) { + const rawUrl = + imageElement.getAttribute("src") || + imageElement.getAttribute("data-src") || + ""; + const normalizedUrl = normalizeImageUrl(rawUrl); + if (!normalizedUrl) { + return ""; + } + + if (!state.imageUrls.has(normalizedUrl)) { + state.imageUrls.add(normalizedUrl); + state.images.push({ + url: normalizedUrl, + alt: normalizeText(imageElement.getAttribute("alt")) || undefined, + suggested_file_name: resolveSuggestedImageName( + normalizedUrl, + state.images.length, + ), + }); + } + + const altText = normalizeText(imageElement.getAttribute("alt")) || "插图"; + return `![${escapeMarkdownText(altText)}](${normalizedUrl})`; + } + + function collectInlineMarkdown(node, state) { + if (!node) { + return ""; + } + + if (node.nodeType === Node.TEXT_NODE) { + return escapeMarkdownText(node.textContent || ""); + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node; + if (element.matches?.(IMAGE_SELECTOR) || element.tagName === "IMG") { + return registerImage(state, element); + } + + if (element.matches?.(CODE_BLOCK_SELECTOR)) { + return ""; + } + + if (element.tagName === "BR") { + return "\n"; + } + + const childMarkdown = Array.from(element.childNodes) + .map((child) => collectInlineMarkdown(child, state)) + .join(""); + const normalized = childMarkdown.replace(/[ \t]+\n/g, "\n"); + + if (element.tagName === "A") { + const href = helpers.absoluteUrl(element.getAttribute("href") || ""); + const label = normalizeMultilineText(normalized); + if (!href) { + return label; + } + return label ? `[${label}](${href})` : href; + } + + if (element.tagName === "CODE") { + const codeText = normalizeMultilineText(element.textContent || ""); + return codeText ? `\`${codeText}\`` : ""; + } + + if (element.tagName === "STRONG" || element.tagName === "B") { + const text = normalized.trim(); + return text ? `**${text}**` : ""; + } + + if (element.tagName === "EM" || element.tagName === "I") { + const text = normalized.trim(); + return text ? `*${text}*` : ""; + } + + return normalized; + } + + function hasMeaningfulText(element) { + return normalizeText(element.textContent || "").length > 0; + } + + function serializeList(element, state, depth = 0) { + const items = Array.from(element.children) + .filter((child) => child.tagName === "LI") + .map((item, index) => { + const marker = element.tagName === "OL" ? `${index + 1}.` : "-"; + const nestedLists = Array.from(item.children).filter((child) => + ["UL", "OL"].includes(child.tagName), + ); + const clonedItem = item.cloneNode(true); + nestedLists.forEach((list) => { + const nestedClone = clonedItem.querySelector(list.tagName.toLowerCase()); + nestedClone?.remove(); + }); + + const text = normalizeMultilineText( + collectInlineMarkdown(clonedItem, state), + ); + const lines = []; + if (text) { + lines.push(`${" ".repeat(depth)}${marker} ${text}`); + } + nestedLists.forEach((list) => { + const nestedMarkdown = serializeList(list, state, depth + 1); + if (nestedMarkdown) { + lines.push(nestedMarkdown); + } + }); + return lines.join("\n"); + }) + .filter(Boolean); + + return items.join("\n"); + } + + function serializeBlock(element, state) { + if (!(element instanceof Element)) { + return []; + } + + if (element.matches(ARTICLE_TITLE_SELECTOR)) { + return []; + } + + if (element.matches(CODE_BLOCK_SELECTOR)) { + const codeText = normalizeMultilineText(element.innerText || element.textContent || ""); + if (!codeText) { + return []; + } + const language = resolveCodeLanguage(element); + return [`\`\`\`${language}\n${codeText}\n\`\`\``]; + } + + if (element.matches(IMAGE_SELECTOR) || element.matches('[data-testid="tweetPhoto"], figure')) { + const images = element.matches(IMAGE_SELECTOR) + ? [element] + : Array.from(element.querySelectorAll("img")); + return images + .map((image) => registerImage(state, image)) + .filter(Boolean); + } + + if (element.matches("ul,ol")) { + const listMarkdown = serializeList(element, state); + return listMarkdown ? [listMarkdown] : []; + } + + if (element.matches("blockquote")) { + const text = normalizeMultilineText(collectInlineMarkdown(element, state)); + if (!text) { + return []; + } + return [text.split("\n").map((line) => `> ${line}`).join("\n")]; + } + + if (element.matches("h1,h2,h3,h4,h5,h6")) { + const level = Number(element.tagName.slice(1)) || 2; + const text = normalizeMultilineText(collectInlineMarkdown(element, state)); + return text ? [`${"#".repeat(Math.min(level, 6))} ${text}`] : []; + } + + if (element.matches("hr")) { + return ["---"]; + } + + const directBlockChildren = Array.from(element.children).filter((child) => { + if (child.matches(CODE_BLOCK_SELECTOR)) { + return true; + } + if (child.matches(IMAGE_SELECTOR) || child.matches('[data-testid="tweetPhoto"], figure')) { + return true; + } + if (child.matches("ul,ol,blockquote,h1,h2,h3,h4,h5,h6,hr")) { + return true; + } + return child.children.length > 0 && !["A", "SPAN", "EM", "STRONG", "I", "B", "CODE"].includes(child.tagName); + }); + + if (directBlockChildren.length > 0 && !element.matches("p,li")) { + return directBlockChildren.flatMap((child) => serializeBlock(child, state)); + } + + if (!hasMeaningfulText(element) && !element.querySelector("img")) { + return []; + } + + const paragraph = normalizeMultilineText(collectInlineMarkdown(element, state)); + return paragraph ? [paragraph] : []; + } + + function serializeArticle(root, state) { + const blocks = Array.from(root.children) + .flatMap((child) => serializeBlock(child, state)) + .map((block) => normalizeMultilineText(block)) + .filter(Boolean); + + if (blocks.length > 0) { + return blocks.join("\n\n"); + } + + const fallback = normalizeMultilineText(root.innerText || root.textContent || ""); + return fallback; + } + + const requestedUrl = String(args.url || "").trim(); + if (requestedUrl && !normalizeArticleUrl(requestedUrl)) { + return { + ok: false, + error_code: "invalid_args", + error_message: "url 必须是 x.com 或 twitter.com 的 article 链接。", + }; + } + + const articleRoot = await helpers.waitFor( + () => document.querySelector(ARTICLE_ROOT_SELECTOR), + 15000, + 250, + ); + if (!articleRoot) { + if (helpers.looksLikeLoginWall()) { + return { + ok: false, + error_code: "auth_required", + error_message: "未能读取 X 长文内容,可能需要先登录 X 后再访问。", + }; + } + return { + ok: false, + error_code: "adapter_runtime_error", + error_message: "未找到 X 长文阅读视图。", + }; + } + + const title = + normalizeText(articleRoot.querySelector(ARTICLE_TITLE_SELECTOR)?.textContent) || + normalizeText(document.title.replace(/\s+on X.*$/i, "")); + const contentRoot = articleRoot.querySelector(ARTICLE_CONTENT_SELECTOR); + if (!contentRoot) { + return { + ok: false, + error_code: helpers.looksLikeLoginWall() ? "auth_required" : "adapter_runtime_error", + error_message: "未找到 X 长文正文区域。", + }; + } + + const state = createState(); + const markdown = serializeArticle(contentRoot, state); + if (!markdown) { + return { + ok: false, + error_code: helpers.looksLikeLoginWall() ? "auth_required" : "adapter_runtime_error", + error_message: "正文为空,无法导出 Markdown。", + }; + } + + const publishedAt = + articleRoot.querySelector("time[datetime]")?.getAttribute("datetime") || + document.querySelector("time[datetime]")?.getAttribute("datetime") || + undefined; + const sourceUrl = normalizeArticleUrl(location.href) || location.href; + + return { + ok: true, + source_url: sourceUrl, + data: { + export_kind: "markdown_bundle", + title: title || undefined, + source_url: sourceUrl, + author: resolveAuthor() || undefined, + published_at: publishedAt || undefined, + markdown, + images: state.images, + }, + }; +}; diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 551368a14..5d4106561 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -980,9 +980,8 @@ 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 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(), 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 index 035a80359..b750a12d2 100644 --- a/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/analysis_skill_launch.rs @@ -1,6 +1,13 @@ use super::*; const ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const ANALYSIS_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +97,74 @@ pub(crate) fn merge_system_prompt_with_analysis_skill_launch( } } +pub(crate) fn should_lock_analysis_skill_launch_to_analysis( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["analysis_skill_launch", "analysisSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "analysis_request".to_string()) + == "analysis_request" +} + +pub(crate) fn append_analysis_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_analysis_skill_launch_to_analysis(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前分析技能启动回合生效".to_string()), + }] + }; + + for pattern in ANALYSIS_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1227, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "分析技能启动回合已锁定为 Skill(analysis) 主链,禁止先走工具目录/联网检索偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_analysis_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_analysis_skill_launch_to_analysis(request_metadata) { + return; + } + + for tool_name in ANALYSIS_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_analysis_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -145,8 +220,17 @@ fn build_analysis_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走分析技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(analysis) 真正执行前,不要先走 ToolSearch / WebSearch / Grep 等工具目录发现、联网检索或内容检索偏航。".to_string(), + "- 不要先搜索 “analysis”、“read_file” 或 “list_directory” 的目录信息;当前 analysis_request 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(analysis)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天分析。".to_string(), "- 若用户明确给了正文、文件路径或范围,优先分析这些材料;若未明确给材料,则分析当前对话中与请求最相关的内容。".to_string(), + "- 如需处理本地路径或目录,只允许在 Skill(analysis) 内最小化使用 Read / Glob 确认必要内容;不要在进入 Skill 前先探测大量文件。".to_string(), "- 分析结果必须区分原文事实、你的判断与待确认项,不要把推断写成已确认事实。".to_string(), format!("- 当前分析请求上下文(JSON):{request_json}"), format!("- 当前入口来源:{entry_source}。"), 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 index eefaffdb0..9c8f0f601 100644 --- a/src-tauri/src/commands/aster_agent_cmd/broadcast_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/broadcast_skill_launch.rs @@ -1,6 +1,17 @@ use super::*; const BROADCAST_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const BROADCAST_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +101,74 @@ pub(crate) fn merge_system_prompt_with_broadcast_skill_launch( } } +pub(crate) fn should_lock_broadcast_skill_launch_to_broadcast_generation( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["broadcast_skill_launch", "broadcastSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "broadcast_task".to_string()) + == "broadcast_task" +} + +pub(crate) fn append_broadcast_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_broadcast_skill_launch_to_broadcast_generation(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前播报技能启动回合生效".to_string()), + }] + }; + + for pattern in BROADCAST_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1236, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "播报技能启动回合已锁定为 Skill(broadcast_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_broadcast_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_broadcast_skill_launch_to_broadcast_generation(request_metadata) { + return; + } + + for tool_name in BROADCAST_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_broadcast_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -152,6 +231,14 @@ fn build_broadcast_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走播报技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(broadcast_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “broadcast_generate”、“lime task create broadcast --json” 或 “lime_create_broadcast_generation_task” 之类目录信息;当前 broadcast_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(broadcast_generate)。".to_string(), "- Skill 执行后,优先沿 broadcast_generate skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_broadcast_generation_task。".to_string(), "- 不要伪造“播报已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前播报任务上下文(JSON):{task_json}"), diff --git a/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs index 29ae2953d..29fdef503 100644 --- a/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/cover_skill_launch.rs @@ -1,6 +1,17 @@ use super::*; const COVER_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const COVER_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -46,6 +57,74 @@ pub(crate) fn merge_system_prompt_with_cover_skill_launch( } } +pub(crate) fn should_lock_cover_skill_launch_to_cover_generation( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["cover_skill_launch", "coverSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "cover_task".to_string()) + == "cover_task" +} + +pub(crate) fn append_cover_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_cover_skill_launch_to_cover_generation(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前封面技能启动回合生效".to_string()), + }] + }; + + for pattern in COVER_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1238, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "封面技能启动回合已锁定为 Skill(cover_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_cover_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_cover_skill_launch_to_cover_generation(request_metadata) { + return; + } + + for tool_name in COVER_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_cover_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -98,6 +177,14 @@ fn build_cover_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走封面技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(cover_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “cover_generate”、“social_generate_cover_image” 或 “lime task create cover --json” 之类目录信息;当前 cover_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(cover_generate)。".to_string(), "- Skill 执行后,优先沿 cover_generate skill 的 social_generate_cover_image + Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_cover_generation_task。".to_string(), "- 不要把封面任务退化成普通配图,也不要伪造“封面已生成完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前封面任务上下文(JSON):{cover_task_json}"), 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 index 89855bc6c..04a23ced7 100644 --- 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 @@ -1,6 +1,15 @@ use super::*; const DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const DEEP_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +99,74 @@ pub(crate) fn merge_system_prompt_with_deep_search_skill_launch( } } +pub(crate) fn should_lock_deep_search_skill_launch_to_prompt_search( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["deep_search_skill_launch", "deepSearchSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "deep_search_request".to_string()) + == "deep_search_request" +} + +pub(crate) fn append_deep_search_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_deep_search_skill_launch_to_prompt_search(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前深搜技能启动回合生效".to_string()), + }] + }; + + for pattern in DEEP_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1233, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "深搜技能启动回合已锁定为 Skill(research) 主链,禁止先走工具目录/本地文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_deep_search_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_deep_search_skill_launch_to_prompt_search(request_metadata) { + return; + } + + for tool_name in DEEP_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_deep_search_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -151,6 +228,14 @@ fn build_deep_search_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走深搜技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(research) 真正执行前,不要先走 ToolSearch / Read / Glob / Grep 等工具目录发现或本地文件链路。".to_string(), + "- 不要先搜索 “research”、“search_query” 或 “WebSearch” 的目录信息;当前 deep_search_request 已经提供了足够上下文。".to_string(), + "- 如果某个工具目录/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(research)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通聊天、普通 @搜索 或一次浅搜。".to_string(), "- research skill 内部必须真正执行联网检索,不要只凭已有记忆直接回答。".to_string(), "- 深搜至少执行 2 轮以上扩搜,主动使用不同关键词组合、来源或时间切片;不能只搜一次就直接收尾。".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/form_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/form_skill_launch.rs new file mode 100644 index 000000000..c5a4616a3 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/form_skill_launch.rs @@ -0,0 +1,274 @@ +use super::*; + +const FORM_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const FORM_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; + +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 extract_object_u64( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(|value| { + value + .as_u64() + .or_else(|| value.as_i64().map(|raw| raw.max(0) as u64)) + }) + .filter(|value| *value > 0) +} + +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_form_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let mut metadata = request_metadata.cloned()?; + ensure_harness_workbench_chat_mode(&mut metadata, &["form_skill_launch", "formSkillLaunch"]); + + Some(metadata) +} + +pub(crate) fn merge_system_prompt_with_form_skill_launch( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(launch_prompt) = build_form_skill_launch_system_prompt(request_metadata) else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(FORM_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), + } +} + +pub(crate) fn should_lock_form_skill_launch_to_form_generate( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = + extract_harness_nested_object(request_metadata, &["form_skill_launch", "formSkillLaunch"]) + else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "form_request".to_string()) + == "form_request" +} + +pub(crate) fn append_form_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_form_skill_launch_to_form_generate(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前表单生成技能启动回合生效".to_string()), + }] + }; + + for pattern in FORM_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1225, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "表单生成技能启动回合已锁定为 Skill(form_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_form_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_form_skill_launch_to_form_generate(request_metadata) { + return; + } + + for tool_name in FORM_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + +fn build_form_skill_launch_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let launch = + extract_harness_nested_object(request_metadata, &["form_skill_launch", "formSkillLaunch"])?; + let kind = + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "form_request".to_string()); + if kind != "form_request" { + return None; + } + + let skill_name = extract_object_string(launch, &["skill_name", "skillName"]) + .unwrap_or_else(|| "form_generate".to_string()); + let form_request = launch + .get("form_request") + .and_then(serde_json::Value::as_object)?; + let raw_text = extract_object_string(form_request, &["raw_text", "rawText"]); + let prompt = extract_object_string(form_request, &["prompt"]) + .unwrap_or_else(|| "请生成一个可直接使用的 A2UI 表单".to_string()); + let content = extract_object_string(form_request, &["content"]); + let form_type = extract_object_string(form_request, &["form_type", "formType"]); + let style = extract_object_string(form_request, &["style"]); + let audience = extract_object_string(form_request, &["audience"]); + let field_count = extract_object_u64(form_request, &["field_count", "fieldCount"]); + let project_id = extract_object_string(form_request, &["project_id", "projectId"]); + let content_id = extract_object_string(form_request, &["content_id", "contentId"]); + let entry_source = extract_object_string(form_request, &["entry_source", "entrySource"]) + .unwrap_or_else(|| "at_form_command".to_string()); + let args_payload = serde_json::json!({ + "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), + "form_request": serde_json::Value::Object(form_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(form_request).unwrap_or_else(|_| "{}".to_string()), + 4_000, + ); + + let mut lines = vec![ + FORM_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!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走表单生成技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(form_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 目标是复用 Lime 现有 A2UI 协议输出一份真实可渲染的表单,不要退回纯文本问题列表,也不要输出另一套自定义表单 DSL。".to_string(), + "- 最终结果必须输出一个 ```a2ui 代码块,并在代码块内放一份可被现有 A2UI parser 识别的 JSON。".to_string(), + "- 优先输出简化表单格式:{ \"type\": \"form\", \"title\": \"...\", \"description\": \"...\", \"fields\": [...], \"submitLabel\": \"提交\" }。".to_string(), + "- 字段类型只允许使用 simple form 已支持的 choice / text / slider / checkbox;不要发明 DatePicker、FileUpload 等当前 simple form 还未支持的字段。".to_string(), + "- 若用户要求报名/线索表单,应至少包含联系人字段与隐私同意 checkbox;若是问卷/反馈表单,应优先使用 choice、slider 和 text 组合。".to_string(), + "- 如信息不足,最多追问 1 个关键问题;除非真的缺失目标,否则不要停在追问。".to_string(), + format!("- 当前表单请求上下文(JSON):{request_json}"), + format!("- 当前入口来源:{entry_source}。"), + ]; + + lines.push(format!("- 当前表单目标:{prompt}")); + if let Some(value) = content.as_deref() { + lines.push(format!( + "- 当前原始命令摘要:{}", + truncate_prompt_text(value.to_string(), 400) + )); + } + if let Some(value) = form_type.as_deref() { + lines.push(format!("- 当前表单类型:{value}。")); + } + if let Some(value) = style.as_deref() { + lines.push(format!("- 当前风格要求:{value}。")); + } + if let Some(value) = audience.as_deref() { + lines.push(format!("- 当前目标受众:{value}。")); + } + if let Some(value) = field_count { + lines.push(format!("- 目标字段数:{value}。")); + } + if let Some(value) = project_id.as_deref() { + lines.push(format!("- 当前项目 ID:{value}。")); + } + if let Some(value) = content_id.as_deref() { + lines.push(format!("- 当前内容 ID:{value}。")); + } + lines.push("- 不要输出 ``、HTML、Markdown 表格或伪代码来替代 A2UI;这次需要直接复用现有 A2UI 渲染链。".to_string()); + + Some(lines.join("\n")) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs index d513ba308..6d6f4a7dc 100644 --- a/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/image_skill_launch.rs @@ -4,6 +4,18 @@ use std::fs; use std::path::{Path, PathBuf}; const IMAGE_SKILL_INPUT_REF_PREFIX: &str = "skill-input-image://"; +pub(super) const IMAGE_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const IMAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -254,6 +266,74 @@ pub(crate) fn merge_system_prompt_with_image_skill_launch( } } +pub(crate) fn should_lock_image_skill_launch_to_image_generation( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["image_skill_launch", "imageSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "image_task".to_string()) + == "image_task" +} + +pub(crate) fn append_image_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_image_skill_launch_to_image_generation(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前图片技能启动回合生效".to_string()), + }] + }; + + for pattern in IMAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1240, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "图片技能启动回合已锁定为 Skill(image_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_image_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_image_skill_launch_to_image_generation(request_metadata) { + return; + } + + for tool_name in IMAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_image_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -309,7 +389,16 @@ fn build_image_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走图片技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(image_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前 image_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(image_generate)。".to_string(), "- Skill 执行后,优先沿 image_generate skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_image_generation_task。".to_string(), + "- 如果回退到 lime_create_image_generation_task,也必须只提交标准 image task 参数;不要传 outputPath,不要把任务写成 markdown 文稿。".to_string(), "- 不要伪造“图片已生成完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前图片任务上下文(JSON):{image_task_json}"), format!("- 当前模式:{mode}。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index e35c7f5b9..544f7b416 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -149,7 +149,6 @@ const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024"; const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url"; const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】"; const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】"; -const IMAGE_SKILL_LAUNCH_PROMPT_MARKER: &str = "【图片技能启动】"; const SERVICE_SKILL_LAUNCH_PROMPT_MARKER: &str = "【站点技能启动】"; const SERVICE_SKILL_LAUNCH_PRELOAD_PROMPT_MARKER: &str = "【站点技能预执行结果】"; const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】"; @@ -265,9 +264,11 @@ pub(crate) mod command_api; mod cover_skill_launch; mod deep_search_skill_launch; mod dto; +mod form_skill_launch; mod image_skill_launch; mod mcp_bridge; mod pdf_read_skill_launch; +mod presentation_skill_launch; mod prompt_context; mod reply_runtime; mod report_skill_launch; @@ -287,6 +288,7 @@ mod translation_skill_launch; mod typesetting_skill_launch; mod url_parse_skill_launch; mod video_skill_launch; +mod webpage_skill_launch; #[cfg(test)] use self::subagent_runtime::{ build_subagent_customization_state, build_subagent_customization_system_prompt, @@ -306,11 +308,15 @@ pub(crate) use action_runtime::{ validate_elicitation_submission, }; pub(crate) use analysis_skill_launch::{ + append_analysis_skill_launch_session_permissions, merge_system_prompt_with_analysis_skill_launch, prepare_analysis_skill_launch_request_metadata, + prune_analysis_skill_launch_detour_tools_from_registry, }; pub(crate) use broadcast_skill_launch::{ + append_broadcast_skill_launch_session_permissions, merge_system_prompt_with_broadcast_skill_launch, prepare_broadcast_skill_launch_request_metadata, + prune_broadcast_skill_launch_detour_tools_from_registry, }; pub(crate) use browser_assist::{ append_browser_assist_session_permissions, apply_browser_requirement_to_request_tool_policy, @@ -337,10 +343,15 @@ pub(crate) use command_api::{ agent_runtime_update_session, agent_runtime_wait_subagents, aster_agent_configure_from_pool, 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 cover_skill_launch::{ + append_cover_skill_launch_session_permissions, merge_system_prompt_with_cover_skill_launch, + prune_cover_skill_launch_detour_tools_from_registry, +}; pub(crate) use deep_search_skill_launch::{ + append_deep_search_skill_launch_session_permissions, merge_system_prompt_with_deep_search_skill_launch, prepare_deep_search_skill_launch_request_metadata, + prune_deep_search_skill_launch_detour_tools_from_registry, }; #[allow(unused_imports)] pub(crate) use dto::{ @@ -361,12 +372,26 @@ pub(crate) use dto::{ AgentRuntimeWaitSubagentsRequest, AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload, ConfigureFromPoolRequest, ConfigureProviderRequest, }; +pub(crate) use form_skill_launch::{ + append_form_skill_launch_session_permissions, merge_system_prompt_with_form_skill_launch, + prepare_form_skill_launch_request_metadata, prune_form_skill_launch_detour_tools_from_registry, +}; pub(crate) use image_skill_launch::{ - merge_system_prompt_with_image_skill_launch, prepare_image_skill_launch_request_metadata, + append_image_skill_launch_session_permissions, merge_system_prompt_with_image_skill_launch, + prepare_image_skill_launch_request_metadata, + prune_image_skill_launch_detour_tools_from_registry, }; pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions}; pub(crate) use pdf_read_skill_launch::{ + append_pdf_read_skill_launch_session_permissions, merge_system_prompt_with_pdf_read_skill_launch, prepare_pdf_read_skill_launch_request_metadata, + prune_pdf_read_skill_launch_detour_tools_from_registry, +}; +pub(crate) use presentation_skill_launch::{ + append_presentation_skill_launch_session_permissions, + merge_system_prompt_with_presentation_skill_launch, + prepare_presentation_skill_launch_request_metadata, + prune_presentation_skill_launch_detour_tools_from_registry, }; #[cfg(test)] pub(crate) use prompt_context::build_team_preference_system_prompt; @@ -384,15 +409,21 @@ use reply_runtime::{ 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, + append_report_skill_launch_session_permissions, merge_system_prompt_with_report_skill_launch, + prepare_report_skill_launch_request_metadata, + prune_report_skill_launch_detour_tools_from_registry, }; use request_model_resolution::resolve_runtime_request_provider_config; pub(crate) use research_skill_launch::{ + append_research_skill_launch_session_permissions, merge_system_prompt_with_research_skill_launch, prepare_research_skill_launch_request_metadata, + prune_research_skill_launch_detour_tools_from_registry, }; pub(crate) use resource_search_skill_launch::{ + append_resource_search_skill_launch_session_permissions, merge_system_prompt_with_resource_search_skill_launch, prepare_resource_search_skill_launch_request_metadata, + prune_resource_search_skill_launch_detour_tools_from_registry, }; use run_metadata::{ build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array, @@ -424,8 +455,10 @@ pub(crate) use session_runtime::{ SessionRecentRuntimeContext, }; pub(crate) use site_search_skill_launch::{ + append_site_search_skill_launch_session_permissions, merge_system_prompt_with_site_search_skill_launch, prepare_site_search_skill_launch_request_metadata, + prune_site_search_skill_launch_detour_tools_from_registry, }; #[allow(unused_imports)] pub(crate) use subagent_runtime::{ @@ -435,7 +468,9 @@ pub(crate) use subagent_runtime::{ 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, + append_summary_skill_launch_session_permissions, merge_system_prompt_with_summary_skill_launch, + prepare_summary_skill_launch_request_metadata, + prune_summary_skill_launch_detour_tools_from_registry, }; #[allow(unused_imports)] pub(crate) use tool_runtime::social_generate_cover_image_cmd; @@ -452,17 +487,38 @@ pub(crate) use tool_runtime::{ ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, 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 transcription_skill_launch::{ + append_transcription_skill_launch_session_permissions, + merge_system_prompt_with_transcription_skill_launch, + prune_transcription_skill_launch_detour_tools_from_registry, +}; pub(crate) use translation_skill_launch::{ + append_translation_skill_launch_session_permissions, merge_system_prompt_with_translation_skill_launch, prepare_translation_skill_launch_request_metadata, + prune_translation_skill_launch_detour_tools_from_registry, }; pub(crate) use typesetting_skill_launch::{ + append_typesetting_skill_launch_session_permissions, merge_system_prompt_with_typesetting_skill_launch, prepare_typesetting_skill_launch_request_metadata, + prune_typesetting_skill_launch_detour_tools_from_registry, +}; +pub(crate) use url_parse_skill_launch::{ + append_url_parse_skill_launch_session_permissions, + merge_system_prompt_with_url_parse_skill_launch, + prune_url_parse_skill_launch_detour_tools_from_registry, }; -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; +pub(crate) use video_skill_launch::{ + append_video_skill_launch_session_permissions, + prune_video_skill_launch_detour_tools_from_registry, +}; +pub(crate) use webpage_skill_launch::{ + append_webpage_skill_launch_session_permissions, merge_system_prompt_with_webpage_skill_launch, + prepare_webpage_skill_launch_request_metadata, + prune_webpage_skill_launch_detour_tools_from_registry, +}; pub async fn resume_persisted_runtime_queues_on_startup( app: AppHandle, 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 index d972bd1e6..b84ad6cd4 100644 --- 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 @@ -1,6 +1,13 @@ use super::*; const PDF_READ_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const PDF_READ_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +97,74 @@ pub(crate) fn merge_system_prompt_with_pdf_read_skill_launch( } } +pub(crate) fn should_lock_pdf_read_skill_launch_to_pdf_read( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["pdf_read_skill_launch", "pdfReadSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "pdf_read_request".to_string()) + == "pdf_read_request" +} + +pub(crate) fn append_pdf_read_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_pdf_read_skill_launch_to_pdf_read(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前读PDF技能启动回合生效".to_string()), + }] + }; + + for pattern in PDF_READ_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1230, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "读PDF技能启动回合已锁定为 Skill(pdf_read) 主链,禁止先走工具目录/联网搜索偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_pdf_read_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_pdf_read_skill_launch_to_pdf_read(request_metadata) { + return; + } + + for tool_name in PDF_READ_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_pdf_read_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -150,6 +225,14 @@ fn build_pdf_read_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走读PDF技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(pdf_read) 真正执行前,不要先走 ToolSearch / WebSearch / Grep 等工具目录发现、联网搜索或内容检索偏航。".to_string(), + "- 不要先搜索 “pdf_read”、“read_file” 或 “list_directory” 的目录信息;当前 pdf_read_request 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(pdf_read)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建 task file,也不要退回普通聊天凭空回答。".to_string(), "- 若拿到本地或工作区 PDF 路径,优先最小化使用 `list_directory / read_file` 读取目标 PDF,并保留真实 tool timeline。".to_string(), "- 若路径是相对路径,可先用 `list_directory` 确认位置,再调用 `read_file`;不要假装文件已经读取成功。".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/presentation_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/presentation_skill_launch.rs new file mode 100644 index 000000000..1bb9b6985 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/presentation_skill_launch.rs @@ -0,0 +1,280 @@ +use super::*; + +const PRESENTATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const PRESENTATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; + +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 extract_object_u64( + object: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(|value| { + value + .as_u64() + .or_else(|| value.as_i64().map(|raw| raw.max(0) as u64)) + }) + .filter(|value| *value > 0) +} + +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_presentation_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let mut metadata = request_metadata.cloned()?; + ensure_harness_workbench_chat_mode( + &mut metadata, + &["presentation_skill_launch", "presentationSkillLaunch"], + ); + + Some(metadata) +} + +pub(crate) fn merge_system_prompt_with_presentation_skill_launch( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(launch_prompt) = build_presentation_skill_launch_system_prompt(request_metadata) + else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(PRESENTATION_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), + } +} + +pub(crate) fn should_lock_presentation_skill_launch_to_presentation_generate( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["presentation_skill_launch", "presentationSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "presentation_request".to_string()) + == "presentation_request" +} + +pub(crate) fn append_presentation_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_presentation_skill_launch_to_presentation_generate(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前演示稿生成技能启动回合生效".to_string()), + }] + }; + + for pattern in PRESENTATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1224, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "演示稿生成技能启动回合已锁定为 Skill(presentation_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_presentation_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_presentation_skill_launch_to_presentation_generate(request_metadata) { + return; + } + + for tool_name in PRESENTATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + +fn build_presentation_skill_launch_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let launch = extract_harness_nested_object( + request_metadata, + &["presentation_skill_launch", "presentationSkillLaunch"], + )?; + let kind = extract_object_string(launch, &["kind"]) + .unwrap_or_else(|| "presentation_request".to_string()); + if kind != "presentation_request" { + return None; + } + + let skill_name = extract_object_string(launch, &["skill_name", "skillName"]) + .unwrap_or_else(|| "presentation_generate".to_string()); + let presentation_request = launch + .get("presentation_request") + .and_then(serde_json::Value::as_object)?; + let raw_text = extract_object_string(presentation_request, &["raw_text", "rawText"]); + let prompt = extract_object_string(presentation_request, &["prompt"]) + .unwrap_or_else(|| "请生成一份可直接讲述的演示文稿草稿".to_string()); + let content = extract_object_string(presentation_request, &["content"]); + let deck_type = extract_object_string(presentation_request, &["deck_type", "deckType"]); + let style = extract_object_string(presentation_request, &["style"]); + let audience = extract_object_string(presentation_request, &["audience"]); + let slide_count = extract_object_u64(presentation_request, &["slide_count", "slideCount"]); + let project_id = extract_object_string(presentation_request, &["project_id", "projectId"]); + let content_id = extract_object_string(presentation_request, &["content_id", "contentId"]); + let entry_source = + extract_object_string(presentation_request, &["entry_source", "entrySource"]) + .unwrap_or_else(|| "at_presentation_command".to_string()); + let args_payload = serde_json::json!({ + "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), + "presentation_request": serde_json::Value::Object(presentation_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(presentation_request).unwrap_or_else(|_| "{}".to_string()), + 4_000, + ); + + let mut lines = vec![ + PRESENTATION_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!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走演示稿生成技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(presentation_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 当前命令的目标是产出真实演示稿 artifact,不要先退回空泛大纲,也不要只给口头建议。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(presentation_generate)。".to_string(), + "- Skill 执行时必须产出一个可预览、可继续导出的单文件演示稿,并通过 落到工作区。".to_string(), + "- 不要伪造“演示稿已生成”但没有实际文件;如果信息不足,最多追问 1 个关键问题。".to_string(), + format!("- 当前演示请求上下文(JSON):{request_json}"), + format!("- 当前入口来源:{entry_source}。"), + ]; + + lines.push(format!("- 当前演示目标:{prompt}")); + if let Some(value) = content.as_deref() { + lines.push(format!( + "- 当前原始命令摘要:{}", + truncate_prompt_text(value.to_string(), 400) + )); + } + if let Some(value) = deck_type.as_deref() { + lines.push(format!("- 当前演示类型:{value}。")); + } + if let Some(value) = style.as_deref() { + lines.push(format!("- 当前风格要求:{value}。")); + } + if let Some(value) = audience.as_deref() { + lines.push(format!("- 当前受众:{value}。")); + } + if let Some(value) = slide_count { + 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}。")); + } + lines.push("- 当前任务已经显式进入演示稿生成技能主链。".to_string()); + + Some(lines.join("\n")) +} 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 index d114d680b..d677e6e81 100644 --- a/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/report_skill_launch.rs @@ -1,6 +1,15 @@ use super::*; const REPORT_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const REPORT_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +99,74 @@ pub(crate) fn merge_system_prompt_with_report_skill_launch( } } +pub(crate) fn should_lock_report_skill_launch_to_report_generate( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["report_skill_launch", "reportSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "report_request".to_string()) + == "report_request" +} + +pub(crate) fn append_report_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_report_skill_launch_to_report_generate(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前研报技能启动回合生效".to_string()), + }] + }; + + for pattern in REPORT_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1232, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "研报技能启动回合已锁定为 Skill(report_generate) 主链,禁止先走工具目录/本地文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_report_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_report_skill_launch_to_report_generate(request_metadata) { + return; + } + + for tool_name in REPORT_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_report_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -150,6 +227,14 @@ fn build_report_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走研报技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(report_generate) 真正执行前,不要先走 ToolSearch / Read / Glob / Grep 等工具目录发现或本地文件链路。".to_string(), + "- 不要先搜索 “report_generate”、“search_query” 或 “WebSearch” 的目录信息;当前 report_request 已经提供了足够上下文。".to_string(), + "- 如果某个工具目录/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(report_generate)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要退回普通聊天写长文。".to_string(), "- report_generate skill 内部必须先执行真实联网检索,再产出研究报告。".to_string(), "- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在结果中标注时间口径。".to_string(), 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 index c4e725fd1..98b4c808e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/research_skill_launch.rs @@ -1,6 +1,15 @@ use super::*; const RESEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const RESEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +99,74 @@ pub(crate) fn merge_system_prompt_with_research_skill_launch( } } +pub(crate) fn should_lock_research_skill_launch_to_prompt_search( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["research_skill_launch", "researchSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "research_request".to_string()) + == "research_request" +} + +pub(crate) fn append_research_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_research_skill_launch_to_prompt_search(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前搜索技能启动回合生效".to_string()), + }] + }; + + for pattern in RESEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1234, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "搜索技能启动回合已锁定为 Skill(research) 主链,禁止先走工具目录/本地文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_research_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_research_skill_launch_to_prompt_search(request_metadata) { + return; + } + + for tool_name in RESEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_research_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -150,6 +227,14 @@ fn build_research_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走搜索技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(research) 真正执行前,不要先走 ToolSearch / Read / Glob / Grep 等工具目录发现或本地文件链路。".to_string(), + "- 不要先搜索 “research”、“search_query” 或 “WebSearch” 的目录信息;当前 research_request 已经提供了足够上下文。".to_string(), + "- 如果某个工具目录/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(research)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建媒体 task file,也不要回退成普通聊天搜索。".to_string(), "- research skill 内部必须真正执行联网检索,不要只凭已有记忆直接回答。".to_string(), "- 如果用户要求最新、近期、今天或时间敏感信息,检索词里必须补年份或时间范围,并在最终回答中标注时间口径。".to_string(), 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 index c86486bd4..170399adf 100644 --- 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 @@ -2,6 +2,17 @@ use super::*; const RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const RESOURCE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -92,6 +103,74 @@ pub(crate) fn merge_system_prompt_with_resource_search_skill_launch( } } +pub(crate) fn should_lock_resource_search_skill_launch_to_modal_resource_search( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["resource_search_skill_launch", "resourceSearchSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "resource_search_task".to_string()) + == "resource_search_task" +} + +pub(crate) fn append_resource_search_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_resource_search_skill_launch_to_modal_resource_search(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前素材技能启动回合生效".to_string()), + }] + }; + + for pattern in RESOURCE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1235, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "素材技能启动回合已锁定为 Skill(modal_resource_search) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_resource_search_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_resource_search_skill_launch_to_modal_resource_search(request_metadata) { + return; + } + + for tool_name in RESOURCE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_resource_search_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -164,6 +243,14 @@ fn build_resource_search_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走素材技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(modal_resource_search) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “modal_resource_search”、“lime_search_web_images” 或 “lime task create resource-search --json” 之类目录信息;当前 resource_search_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(modal_resource_search)。".to_string(), format!("- 当前素材检索任务上下文(JSON):{task_json}"), format!("- 当前入口来源:{entry_source}。"), ]; 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 27d515bb6..f7e40254f 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -471,10 +471,14 @@ async fn execute_aster_chat_request( 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_presentation_skill_launch_request_metadata(request.metadata.as_ref()); + request.metadata = prepare_form_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_webpage_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); @@ -803,10 +807,28 @@ async fn execute_aster_chat_request( prompt_with_pdf_read_skill_launch.clone(), ); - let prompt_with_summary_skill_launch = merge_system_prompt_with_summary_skill_launch( + let prompt_with_presentation_skill_launch = merge_system_prompt_with_presentation_skill_launch( prompt_with_pdf_read_skill_launch, request.metadata.as_ref(), ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::PresentationSkillLaunch, + prompt_with_presentation_skill_launch.clone(), + ); + + let prompt_with_form_skill_launch = merge_system_prompt_with_form_skill_launch( + prompt_with_presentation_skill_launch, + request.metadata.as_ref(), + ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::FormSkillLaunch, + prompt_with_form_skill_launch.clone(), + ); + + let prompt_with_summary_skill_launch = merge_system_prompt_with_summary_skill_launch( + prompt_with_form_skill_launch, + request.metadata.as_ref(), + ); turn_input_builder.apply_prompt_stage( TurnPromptAugmentationStageKind::SummarySkillLaunch, prompt_with_summary_skill_launch.clone(), @@ -858,10 +880,19 @@ async fn execute_aster_chat_request( prompt_with_typesetting_skill_launch.clone(), ); - let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch( + let prompt_with_webpage_skill_launch = merge_system_prompt_with_webpage_skill_launch( prompt_with_typesetting_skill_launch, request.metadata.as_ref(), ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::WebpageSkillLaunch, + prompt_with_webpage_skill_launch.clone(), + ); + + let prompt_with_service_skill_launch = merge_system_prompt_with_service_skill_launch( + prompt_with_webpage_skill_launch, + request.metadata.as_ref(), + ); turn_input_builder.apply_prompt_stage( TurnPromptAugmentationStageKind::ServiceSkillLaunch, prompt_with_service_skill_launch.clone(), @@ -1541,7 +1572,16 @@ async fn execute_aster_chat_request( tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error); } } - let done_event = RuntimeAgentEvent::FinalDone { usage: None }; + let usage = resolve_runtime_message_usage(session_id).await; + if let Some(ref usage) = usage { + if let Err(error) = persist_latest_assistant_message_usage(db, session_id, usage) { + tracing::warn!( + "[AsterAgent] 持久化消息 usage 失败(已降级继续): {}", + error + ); + } + } + let done_event = RuntimeAgentEvent::FinalDone { usage }; if let Err(e) = app.emit(&request.event_name, &done_event) { tracing::error!("[AsterAgent] 发送完成事件失败: {}", e); } @@ -1609,6 +1649,44 @@ async fn update_compaction_session_metrics( persist_compaction_session_metrics_update(&session_config.id, &update).await } +fn resolve_runtime_message_usage_from_session( + session: &aster::session::Session, +) -> Option { + match (session.input_tokens, session.output_tokens) { + (Some(input_tokens), Some(output_tokens)) if input_tokens >= 0 && output_tokens >= 0 => { + Some(lime_agent::AgentTokenUsage { + input_tokens: input_tokens as u32, + output_tokens: output_tokens as u32, + }) + } + _ => None, + } +} + +async fn resolve_runtime_message_usage(session_id: &str) -> Option { + let session = read_session(session_id, false, "读取会话 token 统计失败") + .await + .ok()?; + resolve_runtime_message_usage_from_session(&session) +} + +fn persist_latest_assistant_message_usage( + db: &DbConnection, + session_id: &str, + usage: &lime_agent::AgentTokenUsage, +) -> Result<(), String> { + let conn = db + .lock() + .map_err(|error| format!("更新消息 usage 时数据库锁定失败: {error}"))?; + lime_core::database::agent_session_repository::update_latest_assistant_message_usage( + &conn, + session_id, + usage.input_tokens, + usage.output_tokens, + )?; + Ok(()) +} + fn build_compaction_session_metrics_update( session: &aster::session::Session, session_config: &aster::agents::SessionConfig, 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 index 29023ddf9..c4f19def1 100644 --- 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 @@ -1,6 +1,17 @@ use super::*; const SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const SITE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +101,91 @@ pub(crate) fn merge_system_prompt_with_site_search_skill_launch( } } +pub(crate) fn should_lock_site_search_skill_launch_to_site_tools( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["site_search_skill_launch", "siteSearchSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "site_search_request".to_string()) + == "site_search_request" +} + +pub(crate) fn append_site_search_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_site_search_skill_launch_to_site_tools(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前站点搜索技能启动回合生效".to_string()), + }] + }; + + for pattern in SITE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1231, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "站点搜索技能启动回合已锁定为 Skill(site_search) 主链,禁止先走通用搜索/本地文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } + + for pattern in super::service_skill_launch::service_skill_launch_browser_deny_patterns() { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1231, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "站点搜索技能启动回合应优先沿 lime_site_* 主链执行,禁止直接回退到底层浏览器兼容工具" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_site_search_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_site_search_skill_launch_to_site_tools(request_metadata) { + return; + } + + for tool_name in SITE_SEARCH_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_site_search_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -153,6 +249,14 @@ fn build_site_search_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走站点搜索技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(site_search) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用搜索、工具目录发现或本地文件链路。".to_string(), + "- 不要先搜索 “site_search”、“lime_site_run” 或 “lime_site_search” 的目录信息;当前 site_search_request 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(site_search)。".to_string(), "- 这条命令属于 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(), 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 index 6641a9e5d..1af843240 100644 --- a/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/summary_skill_launch.rs @@ -1,6 +1,13 @@ use super::*; const SUMMARY_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const SUMMARY_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +97,74 @@ pub(crate) fn merge_system_prompt_with_summary_skill_launch( } } +pub(crate) fn should_lock_summary_skill_launch_to_summary( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["summary_skill_launch", "summarySkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "summary_request".to_string()) + == "summary_request" +} + +pub(crate) fn append_summary_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_summary_skill_launch_to_summary(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前总结技能启动回合生效".to_string()), + }] + }; + + for pattern in SUMMARY_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1229, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "总结技能启动回合已锁定为 Skill(summary) 主链,禁止先走工具目录/联网检索偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_summary_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_summary_skill_launch_to_summary(request_metadata) { + return; + } + + for tool_name in SUMMARY_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_summary_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -151,8 +226,17 @@ fn build_summary_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走总结技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(summary) 真正执行前,不要先走 ToolSearch / WebSearch / Grep 等工具目录发现、联网检索或内容检索偏航。".to_string(), + "- 不要先搜索 “summary”、“read_file” 或 “list_directory” 的目录信息;当前 summary_request 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(summary)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天总结。".to_string(), "- 若用户明确给了正文、文件路径或范围,优先总结这些材料;若未明确给材料,则总结当前对话中与请求最相关的内容。".to_string(), + "- 如需处理本地路径或目录,只允许在 Skill(summary) 内最小化使用 Read / Glob 确认必要内容;不要在进入 Skill 前先探测大量文件。".to_string(), "- 结果必须忠于原文,不要补写原文没有的新事实;遇到信息缺失或歧义时,要单独标注待确认项。".to_string(), format!("- 当前总结请求上下文(JSON):{request_json}"), format!("- 当前入口来源:{entry_source}。"), diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 8a37abc87..759ed4307 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -761,6 +761,1891 @@ mod tests { ); } + #[test] + fn test_append_image_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "image_skill_launch": { + "kind": "image_task", + "skill_name": "image_generate", + "image_task": { + "prompt": "生成一张夏日青柠汽水插图" + } + } + } + }); + let mut permissions = Vec::new(); + + append_image_skill_launch_session_permissions( + &mut permissions, + "session-image-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1240); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-image-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_image_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() { + let metadata = serde_json::json!({ + "harness": { + "image_skill_launch": { + "kind": "image_task", + "skill_name": "image_generate", + "image_task": { + "prompt": "生成一张夏日青柠汽水插图" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_image_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_cover_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "cover_skill_launch": { + "kind": "cover_task", + "skill_name": "cover_generate", + "cover_task": { + "prompt": "生成一张夏日青柠新品封面" + } + } + } + }); + let mut permissions = Vec::new(); + + append_cover_skill_launch_session_permissions( + &mut permissions, + "session-cover-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1238); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-cover-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_cover_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() { + let metadata = serde_json::json!({ + "harness": { + "cover_skill_launch": { + "kind": "cover_task", + "skill_name": "cover_generate", + "cover_task": { + "prompt": "生成一张夏日青柠新品封面" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_cover_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_video_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "video_skill_launch": { + "kind": "video_task", + "skill_name": "video_generate", + "video_task": { + "prompt": "生成一条夏日青柠新品短视频" + } + } + } + }); + let mut permissions = Vec::new(); + + append_video_skill_launch_session_permissions( + &mut permissions, + "session-video-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1237); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-video-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_video_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() { + let metadata = serde_json::json!({ + "harness": { + "video_skill_launch": { + "kind": "video_task", + "skill_name": "video_generate", + "video_task": { + "prompt": "生成一条夏日青柠新品短视频" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_video_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_broadcast_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "broadcast_skill_launch": { + "kind": "broadcast_task", + "skill_name": "broadcast_generate", + "broadcast_task": { + "prompt": "把新品发布稿整理成适合口播的文案" + } + } + } + }); + let mut permissions = Vec::new(); + + append_broadcast_skill_launch_session_permissions( + &mut permissions, + "session-broadcast-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1236); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-broadcast-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_broadcast_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() + { + let metadata = serde_json::json!({ + "harness": { + "broadcast_skill_launch": { + "kind": "broadcast_task", + "skill_name": "broadcast_generate", + "broadcast_task": { + "prompt": "把新品发布稿整理成适合口播的文案" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_broadcast_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_resource_search_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "resource_search_skill_launch": { + "kind": "resource_search_task", + "skill_name": "modal_resource_search", + "resource_search_task": { + "resource_type": "image", + "query": "咖啡馆木桌背景" + } + } + } + }); + let mut permissions = Vec::new(); + + append_resource_search_skill_launch_session_permissions( + &mut permissions, + "session-resource-search-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1235); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-resource-search-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_resource_search_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "resource_search_skill_launch": { + "kind": "resource_search_task", + "skill_name": "modal_resource_search", + "resource_search_task": { + "resource_type": "image", + "query": "咖啡馆木桌背景" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_resource_search_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_research_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "research_skill_launch": { + "kind": "research_request", + "skill_name": "research", + "research_request": { + "query": "AI Agent 融资" + } + } + } + }); + let mut permissions = Vec::new(); + + append_research_skill_launch_session_permissions( + &mut permissions, + "session-research-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1234); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-research-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_research_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() + { + let metadata = serde_json::json!({ + "harness": { + "research_skill_launch": { + "kind": "research_request", + "skill_name": "research", + "research_request": { + "query": "AI Agent 融资" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_research_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("WebSearch")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_deep_search_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "deep_search_skill_launch": { + "kind": "deep_search_request", + "skill_name": "research", + "deep_search_request": { + "query": "AI Agent 融资" + } + } + } + }); + let mut permissions = Vec::new(); + + append_deep_search_skill_launch_session_permissions( + &mut permissions, + "session-deep-search-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1233); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-deep-search-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_deep_search_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "deep_search_skill_launch": { + "kind": "deep_search_request", + "skill_name": "research", + "deep_search_request": { + "query": "AI Agent 融资" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_deep_search_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("WebSearch")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_report_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "report_skill_launch": { + "kind": "report_request", + "skill_name": "report_generate", + "report_request": { + "query": "AI Agent 融资" + } + } + } + }); + let mut permissions = Vec::new(); + + append_report_skill_launch_session_permissions( + &mut permissions, + "session-report-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1232); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-report-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_report_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() { + let metadata = serde_json::json!({ + "harness": { + "report_skill_launch": { + "kind": "report_request", + "skill_name": "report_generate", + "report_request": { + "query": "AI Agent 融资" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_report_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("WebSearch")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_site_search_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "site_search_skill_launch": { + "kind": "site_search_request", + "skill_name": "site_search", + "site_search_request": { + "site": "GitHub", + "query": "openai agents sdk issue" + } + } + } + }); + let mut permissions = Vec::new(); + + append_site_search_skill_launch_session_permissions( + &mut permissions, + "session-site-search-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1231); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-site-search-skill-1") + ); + + let browser_deny_rule = permissions + .iter() + .find(|permission| permission.tool == "mcp__lime-browser__*") + .expect("should add browser compat deny rule"); + assert!(!browser_deny_rule.allowed); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_site_search_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "site_search_skill_launch": { + "kind": "site_search_request", + "skill_name": "site_search", + "site_search_request": { + "site": "GitHub", + "query": "openai agents sdk issue" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "lime_site_run", + "Run site adapter", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_site_search_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(registry.contains("lime_site_run")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_pdf_read_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "pdf_read_skill_launch": { + "kind": "pdf_read_request", + "skill_name": "pdf_read", + "pdf_read_request": { + "source_path": "/tmp/agent-report.pdf" + } + } + } + }); + let mut permissions = Vec::new(); + + append_pdf_read_skill_launch_session_permissions( + &mut permissions, + "session-pdf-read-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1230); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-pdf-read-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + } + + #[test] + fn test_prune_pdf_read_skill_launch_detour_tools_from_registry_keeps_read_and_glob_tools() { + let metadata = serde_json::json!({ + "harness": { + "pdf_read_skill_launch": { + "kind": "pdf_read_request", + "skill_name": "pdf_read", + "pdf_read_request": { + "source_path": "/tmp/agent-report.pdf" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_pdf_read_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Read")); + assert!(registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_summary_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "summary_skill_launch": { + "kind": "summary_request", + "skill_name": "summary", + "summary_request": { + "prompt": "请总结这篇长文的三点要点", + "content": "这是一篇关于 AI Agent 融资的长文" + } + } + } + }); + let mut permissions = Vec::new(); + + append_summary_skill_launch_session_permissions( + &mut permissions, + "session-summary-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1229); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-summary-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + } + + #[test] + fn test_prune_summary_skill_launch_detour_tools_from_registry_keeps_read_and_glob_tools() { + let metadata = serde_json::json!({ + "harness": { + "summary_skill_launch": { + "kind": "summary_request", + "skill_name": "summary", + "summary_request": { + "prompt": "请总结这篇长文的三点要点", + "content": "这是一篇关于 AI Agent 融资的长文" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_summary_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Read")); + assert!(registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_translation_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "translation_skill_launch": { + "kind": "translation_request", + "skill_name": "translation", + "translation_request": { + "prompt": "将 hello world 翻译成中文", + "content": "hello world" + } + } + } + }); + let mut permissions = Vec::new(); + + append_translation_skill_launch_session_permissions( + &mut permissions, + "session-translation-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1228); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-translation-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + } + + #[test] + fn test_prune_translation_skill_launch_detour_tools_from_registry_keeps_read_and_glob_tools() { + let metadata = serde_json::json!({ + "harness": { + "translation_skill_launch": { + "kind": "translation_request", + "skill_name": "translation", + "translation_request": { + "prompt": "将 hello world 翻译成中文", + "content": "hello world" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_translation_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Read")); + assert!(registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_analysis_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "analysis_skill_launch": { + "kind": "analysis_request", + "skill_name": "analysis", + "analysis_request": { + "prompt": "判断 OpenAI 新模型发布的商业影响", + "content": "OpenAI 发布新模型" + } + } + } + }); + let mut permissions = Vec::new(); + + append_analysis_skill_launch_session_permissions( + &mut permissions, + "session-analysis-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1227); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-analysis-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + } + + #[test] + fn test_prune_analysis_skill_launch_detour_tools_from_registry_keeps_read_and_glob_tools() { + let metadata = serde_json::json!({ + "harness": { + "analysis_skill_launch": { + "kind": "analysis_request", + "skill_name": "analysis", + "analysis_request": { + "prompt": "判断 OpenAI 新模型发布的商业影响", + "content": "OpenAI 发布新模型" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_analysis_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Read")); + assert!(registry.contains("Glob")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_transcription_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "transcription_skill_launch": { + "kind": "transcription_task", + "skill_name": "transcription_generate", + "transcription_task": { + "prompt": "生成逐字稿", + "source_url": "https://example.com/interview.mp4" + } + } + } + }); + let mut permissions = Vec::new(); + + append_transcription_skill_launch_session_permissions( + &mut permissions, + "session-transcription-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1226); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-transcription-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_transcription_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "transcription_skill_launch": { + "kind": "transcription_task", + "skill_name": "transcription_generate", + "transcription_task": { + "prompt": "生成逐字稿", + "source_url": "https://example.com/interview.mp4" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_transcription_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_url_parse_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "url_parse_skill_launch": { + "kind": "url_parse_task", + "skill_name": "url_parse", + "url_parse_task": { + "prompt": "提取要点并整理成投资人可读摘要", + "url": "https://example.com/agent" + } + } + } + }); + let mut permissions = Vec::new(); + + append_url_parse_skill_launch_session_permissions( + &mut permissions, + "session-url-parse-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1225); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-url-parse-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_url_parse_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "url_parse_skill_launch": { + "kind": "url_parse_task", + "skill_name": "url_parse", + "url_parse_task": { + "prompt": "提取要点并整理成投资人可读摘要", + "url": "https://example.com/agent" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_url_parse_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_typesetting_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "typesetting_skill_launch": { + "kind": "typesetting_task", + "skill_name": "typesetting", + "typesetting_task": { + "prompt": "整理成更适合小红书阅读的短句节奏", + "content": "这是一段待排版正文", + "target_platform": "小红书" + } + } + } + }); + let mut permissions = Vec::new(); + + append_typesetting_skill_launch_session_permissions( + &mut permissions, + "session-typesetting-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1224); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-typesetting-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_typesetting_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "typesetting_skill_launch": { + "kind": "typesetting_task", + "skill_name": "typesetting", + "typesetting_task": { + "prompt": "整理成更适合小红书阅读的短句节奏", + "content": "这是一段待排版正文", + "target_platform": "小红书" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_typesetting_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_presentation_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "presentation_skill_launch": { + "kind": "presentation_request", + "skill_name": "presentation_generate", + "presentation_request": { + "prompt": "帮我做一个 AI 助手创业项目融资演示稿", + "content": "类型:路演PPT 风格:极简科技 受众:投资人 页数:10", + "deck_type": "pitch_deck", + "style": "极简科技", + "audience": "投资人", + "slide_count": 10 + } + } + } + }); + let mut permissions = Vec::new(); + + append_presentation_skill_launch_session_permissions( + &mut permissions, + "session-presentation-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1224); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-presentation-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_presentation_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools( + ) { + let metadata = serde_json::json!({ + "harness": { + "presentation_skill_launch": { + "kind": "presentation_request", + "skill_name": "presentation_generate", + "presentation_request": { + "prompt": "帮我做一个 AI 助手创业项目融资演示稿", + "deck_type": "pitch_deck" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_presentation_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_form_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "form_skill_launch": { + "kind": "form_request", + "skill_name": "form_generate", + "form_request": { + "prompt": "帮我做一个 AI Workshop 报名表", + "content": "类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8", + "form_type": "registration_form", + "style": "简洁专业", + "audience": "活动嘉宾", + "field_count": 8 + } + } + } + }); + let mut permissions = Vec::new(); + + append_form_skill_launch_session_permissions( + &mut permissions, + "session-form-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1225); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-form-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_form_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() { + let metadata = serde_json::json!({ + "harness": { + "form_skill_launch": { + "kind": "form_request", + "skill_name": "form_generate", + "form_request": { + "prompt": "帮我做一个用户调研问卷" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_form_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Skill")); + } + + #[test] + fn test_append_webpage_skill_launch_session_permissions_blocks_detour_tools() { + let metadata = serde_json::json!({ + "harness": { + "webpage_skill_launch": { + "kind": "webpage_request", + "skill_name": "webpage_generate", + "webpage_request": { + "prompt": "帮我做一个 AI 代码助手官网", + "content": "类型:落地页 风格:未来感", + "page_type": "landing_page", + "style": "未来感", + "tech_stack": "原生 HTML" + } + } + } + }); + let mut permissions = Vec::new(); + + append_webpage_skill_launch_session_permissions( + &mut permissions, + "session-webpage-skill-1", + Some(&metadata), + ); + + let tool_search_rule = permissions + .iter() + .find(|permission| permission.tool == TOOL_SEARCH_TOOL_NAME) + .expect("should add ToolSearch deny rule"); + assert!(!tool_search_rule.allowed); + assert_eq!(tool_search_rule.priority, 1223); + assert_eq!(tool_search_rule.conditions.len(), 1); + assert_eq!( + tool_search_rule.conditions[0].value, + serde_json::json!("session-webpage-skill-1") + ); + + assert!(permissions + .iter() + .any(|permission| permission.tool == "WebSearch" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Read" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Glob" && !permission.allowed)); + assert!(permissions + .iter() + .any(|permission| permission.tool == "Grep" && !permission.allowed)); + } + + #[test] + fn test_prune_webpage_skill_launch_detour_tools_from_registry_hides_tool_search_and_fs_tools() { + let metadata = serde_json::json!({ + "harness": { + "webpage_skill_launch": { + "kind": "webpage_request", + "skill_name": "webpage_generate", + "webpage_request": { + "prompt": "帮我做一个 AI 代码助手官网", + "page_type": "landing_page" + } + } + } + }); + let mut registry = aster::tools::ToolRegistry::new(); + registry.register(Box::new(DummyTool::new( + TOOL_SEARCH_TOOL_NAME, + "Search tools", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "WebSearch", + "Web search", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Read", + "Read file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Glob", + "Glob file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Grep", + "Grep file", + serde_json::json!({"type": "object"}), + ))); + registry.register(Box::new(DummyTool::new( + "Skill", + "Run skill", + serde_json::json!({"type": "object"}), + ))); + + prune_webpage_skill_launch_detour_tools_from_registry(&mut registry, Some(&metadata)); + + assert!(!registry.contains(TOOL_SEARCH_TOOL_NAME)); + assert!(!registry.contains("WebSearch")); + assert!(!registry.contains("Read")); + assert!(!registry.contains("Glob")); + assert!(!registry.contains("Grep")); + assert!(registry.contains("Skill")); + } + #[test] fn test_agent_runtime_submit_turn_request_maps_to_aster_chat_request() { let json = r#"{ @@ -1745,6 +3630,7 @@ mod tests { text: "hello".to_string(), }], timestamp: 0, + usage: None, }, }, "/tmp/workspace", @@ -1770,6 +3656,7 @@ mod tests { text: "hello".to_string(), }], timestamp: 0, + usage: None, }, }, "/tmp/workspace", @@ -2138,11 +4025,18 @@ mod tests { ) .expect("should contain merged prompt"); - assert!(merged.contains(IMAGE_SKILL_LAUNCH_PROMPT_MARKER)); + assert!(merged.contains( + super::image_skill_launch::IMAGE_SKILL_LAUNCH_PROMPT_MARKER + )); assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"image_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"image_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(image_generate)")); + assert!(merged.contains("lime media image generate --json")); assert!(merged.contains("不要伪造“图片已生成完成”")); assert!(merged.contains("当前任务已经显式进入图片技能主链")); } @@ -2179,7 +4073,12 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"cover_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"cover_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(cover_generate)")); + assert!(merged.contains("lime task create cover --json")); assert!(merged.contains("不要把封面任务退化成普通配图")); assert!(merged.contains("当前任务已经显式进入封面技能主链")); } @@ -2213,7 +4112,12 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"video_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"video_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(video_generate)")); + assert!(merged.contains("lime media video generate --json")); assert!(merged.contains("不要伪造“视频已生成完成”")); assert!(merged.contains("当前任务已经显式进入视频技能主链")); } @@ -2248,7 +4152,11 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"transcription_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"transcription_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(transcription_generate)")); assert!(merged.contains("不要伪造“转写已完成”")); assert!(merged.contains("当前任务已经显式进入转写技能主链")); } @@ -2284,7 +4192,12 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"broadcast_generate\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"broadcast_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(broadcast_generate)")); + assert!(merged.contains("lime task create broadcast --json")); assert!(merged.contains("不要伪造“播报已完成”")); assert!(merged.contains("当前任务已经显式进入播报技能主链")); } @@ -2319,9 +4232,12 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"modal_resource_search\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"resource_search_task\":")); assert!(merged.contains("lime_search_web_images")); - assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(modal_resource_search)")); assert!(merged.contains("当前任务已经显式进入素材检索技能主链")); } @@ -2357,7 +4273,11 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"research\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"research_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(research)")); assert!(merged.contains("research skill 内部必须真正执行联网检索")); assert!(merged.contains("当前任务已经显式进入搜索技能主链")); } @@ -2391,8 +4311,14 @@ mod tests { .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("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"deep_search_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(research)")); assert!(merged.contains("深搜至少执行 2 轮以上扩搜")); assert!(merged.contains("已确认事实")); assert!(merged.contains("当前任务已经显式进入深搜技能主链")); @@ -2427,8 +4353,14 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"report_generate\"")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"report_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(report_generate)")); assert!(merged.contains("report_generate skill 内部必须先执行真实联网检索")); assert!(merged.contains("核心结论、关键证据、风险/待确认项与建议动作")); assert!(merged.contains("当前任务已经显式进入研报技能主链")); @@ -2463,7 +4395,11 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"site_search\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"site_search_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(site_search)")); assert!(merged.contains("不要先改用 WebSearch、research")); assert!(merged.contains("当前任务已经显式进入站点搜索技能主链")); } @@ -2494,8 +4430,14 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"pdf_read\"")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"pdf_read_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep")); + assert!(merged.contains("应立即改为直调 Skill(pdf_read)")); assert!(merged.contains("list_directory / read_file")); assert!(merged.contains("当前任务已经显式提供 PDF 路径")); } @@ -2528,8 +4470,15 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("skill=\"summary\"")); assert!(merged.contains("\"summary_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep")); + assert!(merged.contains("应立即改为直调 Skill(summary)")); + assert!(merged.contains("Read / Glob")); assert!(merged.contains("结果必须忠于原文")); assert!(merged.contains("当前任务已经显式进入总结技能主链")); } @@ -2562,8 +4511,15 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("skill=\"translation\"")); assert!(merged.contains("\"translation_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep")); + assert!(merged.contains("应立即改为直调 Skill(translation)")); + assert!(merged.contains("Read / Glob")); assert!(merged.contains("译文必须忠于原文")); assert!(merged.contains("当前任务已经显式进入翻译技能主链")); } @@ -2595,8 +4551,15 @@ mod tests { .expect("should contain merged prompt"); assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("skill=\"analysis\"")); assert!(merged.contains("\"analysis_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Grep")); + assert!(merged.contains("应立即改为直调 Skill(analysis)")); + assert!(merged.contains("Read / Glob")); assert!(merged.contains("分析结果必须区分原文事实、你的判断与待确认项")); assert!(merged.contains("当前任务已经显式进入分析技能主链")); } @@ -2901,7 +4864,11 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"url_parse\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"url_parse_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(url_parse)")); assert!(merged.contains("extractStatus 设为 pending_extract")); assert!(merged.contains("当前任务已经显式进入链接解析技能主链")); } @@ -2934,7 +4901,11 @@ mod tests { assert!(merged.contains("第一优先工具调用必须是 Skill")); assert!(merged.contains("skill=\"typesetting\"")); assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); assert!(merged.contains("\"typesetting_task\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(typesetting)")); assert!(merged.contains("不要伪造“排版已完成”")); assert!(merged.contains("当前任务已经显式进入排版技能主链")); } @@ -2966,6 +4937,206 @@ mod tests { ); } + #[test] + fn test_merge_system_prompt_with_presentation_skill_launch_appends_prompt() { + let metadata = serde_json::json!({ + "harness": { + "allow_model_skills": true, + "presentation_skill_launch": { + "skill_name": "presentation_generate", + "kind": "presentation_request", + "presentation_request": { + "prompt": "帮我做一个 AI 助手创业项目融资演示稿", + "raw_text": "@PPT 类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿", + "content": "类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿", + "deck_type": "pitch_deck", + "style": "极简科技", + "audience": "投资人", + "slide_count": 10 + } + } + } + }); + + let merged = merge_system_prompt_with_presentation_skill_launch( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); + assert!(merged.contains("skill=\"presentation_generate\"")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); + assert!(merged.contains("\"presentation_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(presentation_generate)")); + assert!(merged.contains("必须产出一个可预览、可继续导出的单文件演示稿")); + assert!(merged.contains("当前任务已经显式进入演示稿生成技能主链")); + } + + #[test] + fn test_prepare_presentation_skill_launch_request_metadata_sets_workbench_chat_mode() { + let metadata = serde_json::json!({ + "harness": { + "presentation_skill_launch": { + "skill_name": "presentation_generate", + "kind": "presentation_request", + "presentation_request": { + "prompt": "帮我做一版融资路演演示稿" + } + } + } + }); + + let prepared = prepare_presentation_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_webpage_skill_launch_appends_prompt() { + let metadata = serde_json::json!({ + "harness": { + "allow_model_skills": true, + "webpage_skill_launch": { + "skill_name": "webpage_generate", + "kind": "webpage_request", + "webpage_request": { + "prompt": "帮我做一个 AI 代码助手官网", + "raw_text": "@网页 类型:落地页 风格:未来感 帮我做一个 AI 代码助手官网", + "content": "类型:落地页 风格:未来感 帮我做一个 AI 代码助手官网", + "page_type": "landing_page", + "style": "未来感", + "tech_stack": "原生 HTML" + } + } + } + }); + + let merged = merge_system_prompt_with_webpage_skill_launch( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); + assert!(merged.contains("skill=\"webpage_generate\"")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); + assert!(merged.contains("\"webpage_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("应立即改为直调 Skill(webpage_generate)")); + assert!(merged.contains("必须产出一个可预览的单文件 HTML")); + assert!(merged.contains("当前任务已经显式进入网页生成技能主链")); + } + + #[test] + fn test_merge_system_prompt_with_form_skill_launch_appends_prompt() { + let metadata = serde_json::json!({ + "harness": { + "allow_model_skills": true, + "form_skill_launch": { + "skill_name": "form_generate", + "kind": "form_request", + "form_request": { + "prompt": "帮我做一个 AI Workshop 报名表", + "raw_text": "@表单 类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表", + "content": "类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表", + "form_type": "registration_form", + "style": "简洁专业", + "audience": "活动嘉宾", + "field_count": 8 + } + } + } + }); + + let merged = merge_system_prompt_with_form_skill_launch( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains("<>")); + assert!(merged.contains("第一优先工具调用必须是 Skill")); + assert!(merged.contains("skill=\"form_generate\"")); + assert!(merged.contains("Skill.args 的 JSON")); + assert!(merged.contains("第一工具调用示例(Skill 参数 JSON)")); + assert!(merged.contains("\"form_request\":")); + assert!(merged.contains("不要为了确认技能名、工具名或命令名再去调用 ToolSearch")); + assert!(merged.contains("不要先走 ToolSearch / WebSearch / Read / Glob / Grep")); + assert!(merged.contains("目标是复用 Lime 现有 A2UI 协议输出一份真实可渲染的表单")); + assert!(merged.contains("最终结果必须输出一个 ```a2ui 代码块")); + assert!(merged.contains("字段类型只允许使用 simple form 已支持的 choice / text / slider / checkbox")); + } + + #[test] + fn test_prepare_form_skill_launch_request_metadata_sets_workbench_chat_mode() { + let metadata = serde_json::json!({ + "harness": { + "form_skill_launch": { + "skill_name": "form_generate", + "kind": "form_request", + "form_request": { + "prompt": "帮我做一个活动报名表" + } + } + } + }); + + let prepared = prepare_form_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_webpage_skill_launch_request_metadata_sets_workbench_chat_mode() { + let metadata = serde_json::json!({ + "harness": { + "webpage_skill_launch": { + "skill_name": "webpage_generate", + "kind": "webpage_request", + "webpage_request": { + "prompt": "帮我做一个产品落地页" + } + } + } + }); + + let prepared = prepare_webpage_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"); @@ -4025,6 +6196,7 @@ mod tests { text: "子代理最终结论".to_string(), }], timestamp: 0, + usage: None, }], execution_strategy: None, execution_runtime: None, 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 a315abc44..426906c2e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -203,6 +203,73 @@ pub(crate) async fn apply_workspace_sandbox_permissions( } append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata); + append_image_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); + append_cover_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); + append_video_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); + append_broadcast_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_resource_search_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_research_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_deep_search_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_report_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); + append_site_search_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_pdf_read_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_presentation_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_form_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); + append_summary_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); + append_translation_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_analysis_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_transcription_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_url_parse_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_typesetting_skill_launch_session_permissions( + &mut permissions, + session_id, + request_metadata, + ); + append_webpage_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); append_service_skill_launch_session_permissions(&mut permissions, session_id, request_metadata); let (registry_arc, _) = resolve_agent_registry(state).await?; @@ -243,6 +310,25 @@ pub(crate) async fn apply_workspace_sandbox_permissions( config_manager.0.clone(), ); workspace_tools::wrap_registry_native_tools_for_workspace_runtime(&mut registry); + prune_image_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_cover_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_video_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_broadcast_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_resource_search_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_research_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_deep_search_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_report_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_site_search_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_pdf_read_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_presentation_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_form_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_summary_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_translation_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_analysis_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_transcription_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_url_parse_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_typesetting_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); + prune_webpage_skill_launch_detour_tools_from_registry(&mut registry, request_metadata); Ok(apply_outcome) } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index 68779aaf3..b66509865 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -1,8 +1,16 @@ use super::*; +use crate::commands::media_task_cmd::{ + create_image_generation_task_artifact_inner, finalize_image_generation_task_creation, + CreateImageGenerationTaskArtifactRequest, +}; use lime_media_runtime::{ write_task_artifact, MediaTaskType, TaskRelationships, TaskType, TaskWriteOptions, }; +const PROJECT_ID_ENV_KEYS: &[&str] = &["LIME_PROJECT_ID", "PROXYCAST_PROJECT_ID"]; +const CONTENT_ID_ENV_KEYS: &[&str] = &["LIME_CONTENT_ID", "PROXYCAST_CONTENT_ID"]; +const IMAGE_TASK_DEFAULT_ENTRY_SOURCE: &str = "at_image_command"; + fn submit_creation_task_record( app_handle: &AppHandle, context: &ToolContext, @@ -384,6 +392,8 @@ struct ImageTaskInput { #[serde(default)] provider_id: Option, #[serde(default)] + model: Option, + #[serde(default)] session_id: Option, #[serde(default)] project_id: Option, @@ -459,6 +469,159 @@ impl LimeCreateImageTaskTool { } } +fn image_task_input_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "图像提示词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "mode": { "type": "string", "description": "任务模式 generate/edit/variation(可选)。" }, + "rawText": { "type": "string", "description": "原始用户输入(可选)。" }, + "style": { "type": "string", "description": "风格(可选)。" }, + "size": { "type": "string", "description": "尺寸(可选)。" }, + "aspectRatio": { "type": "string", "description": "宽高比(可选)。" }, + "count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" }, + "usage": { "type": "string", "description": "用途(可选)。" }, + "providerId": { "type": "string", "description": "Provider 标识(可选)。" }, + "model": { "type": "string", "description": "首选模型(可选)。" }, + "sessionId": { "type": "string", "description": "会话 ID(可选)。" }, + "projectId": { "type": "string", "description": "项目 ID(可选)。" }, + "contentId": { "type": "string", "description": "内容 ID(可选)。" }, + "entrySource": { "type": "string", "description": "入口来源(可选)。" }, + "requestedTarget": { "type": "string", "description": "目标类型 generate/cover(可选)。" }, + "slotId": { "type": "string", "description": "正文插图 slot 绑定(可选)。" }, + "anchorHint": { "type": "string", "description": "正文插图锚点提示(可选)。" }, + "anchorSectionTitle": { "type": "string", "description": "正文插图小节标题(可选)。" }, + "anchorText": { "type": "string", "description": "正文插图锚点文本(可选)。" }, + "targetOutputId": { "type": "string", "description": "目标图片输出 ID(可选)。" }, + "targetOutputRefId": { "type": "string", "description": "目标图片引用 ID(可选)。" }, + "referenceImages": { + "type": "array", + "items": { "type": "string" }, + "description": "参考图 URL、文件路径或已物化的输入图片路径(可选)。" + } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["image", "task", "generation"], + "allowed_callers": ["assistant", "skill"] + } + }) +} + +fn build_image_generation_task_request( + context: &ToolContext, + input: ImageTaskInput, +) -> CreateImageGenerationTaskArtifactRequest { + let ImageTaskInput { + prompt, + title, + mode, + raw_text, + style, + size, + aspect_ratio, + count, + usage, + provider_id, + model, + session_id, + project_id, + content_id, + entry_source, + requested_target, + slot_id, + anchor_hint, + anchor_section_title, + anchor_text, + target_output_id, + target_output_ref_id, + reference_images, + output_path, + } = input; + let _compat_ignored_output_path = output_path; + let session_id = session_id.or_else(|| { + let value = context.session_id.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + }); + let project_id = project_id.or_else(|| { + PROJECT_ID_ENV_KEYS.iter().find_map(|key| { + context + .environment + .get(*key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + }); + let content_id = content_id.or_else(|| { + CONTENT_ID_ENV_KEYS.iter().find_map(|key| { + context + .environment + .get(*key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + }); + let entry_source = entry_source.or_else(|| Some(IMAGE_TASK_DEFAULT_ENTRY_SOURCE.to_string())); + + CreateImageGenerationTaskArtifactRequest { + project_root_path: context.working_directory.to_string_lossy().to_string(), + prompt, + title, + mode, + raw_text, + size, + aspect_ratio, + count, + usage, + style, + provider_id, + model, + session_id, + project_id, + content_id, + entry_source, + requested_target, + slot_id, + anchor_hint, + anchor_section_title, + anchor_text, + target_output_id, + target_output_ref_id, + reference_images, + } +} + +fn submit_image_generation_task_record( + app_handle: &AppHandle, + context: &ToolContext, + input: ImageTaskInput, +) -> Result { + let request = build_image_generation_task_request(context, input); + let project_root_path = request.project_root_path.trim().to_string(); + let output = create_image_generation_task_artifact_inner(request) + .map_err(|error| ToolError::execution_failed(format!("创建图片任务失败: {error}")))?; + + finalize_image_generation_task_creation(Some(app_handle), project_root_path.as_str(), &output); + + let serialized = serde_json::to_string_pretty(&output) + .unwrap_or_else(|_| serde_json::json!(&output).to_string()); + Ok(media_cli_bridge::attach_media_task_metadata( + ToolResult::success(serialized), + &output, + )) +} + #[async_trait] impl Tool for LimeCreateImageTaskTool { fn name(&self) -> &str { @@ -470,45 +633,7 @@ impl Tool for LimeCreateImageTaskTool { } fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "prompt": { "type": "string", "description": "图像提示词。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "mode": { "type": "string", "description": "任务模式 generate/edit/variation(可选)。" }, - "rawText": { "type": "string", "description": "原始用户输入(可选)。" }, - "style": { "type": "string", "description": "风格(可选)。" }, - "size": { "type": "string", "description": "尺寸(可选)。" }, - "aspectRatio": { "type": "string", "description": "宽高比(可选)。" }, - "count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" }, - "usage": { "type": "string", "description": "用途(可选)。" }, - "providerId": { "type": "string", "description": "Provider 标识(可选)。" }, - "sessionId": { "type": "string", "description": "会话 ID(可选)。" }, - "projectId": { "type": "string", "description": "项目 ID(可选)。" }, - "contentId": { "type": "string", "description": "内容 ID(可选)。" }, - "entrySource": { "type": "string", "description": "入口来源(可选)。" }, - "requestedTarget": { "type": "string", "description": "目标类型 generate/cover(可选)。" }, - "slotId": { "type": "string", "description": "正文插图 slot 绑定(可选)。" }, - "anchorHint": { "type": "string", "description": "正文插图锚点提示(可选)。" }, - "anchorSectionTitle": { "type": "string", "description": "正文插图小节标题(可选)。" }, - "anchorText": { "type": "string", "description": "正文插图锚点文本(可选)。" }, - "targetOutputId": { "type": "string", "description": "目标图片输出 ID(可选)。" }, - "targetOutputRefId": { "type": "string", "description": "目标图片引用 ID(可选)。" }, - "referenceImages": { - "type": "array", - "items": { "type": "string" }, - "description": "参考图 URL、文件路径或已物化的输入图片路径(可选)。" - }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["prompt"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["image", "task", "generation"], - "allowed_callers": ["assistant", "skill"] - } - }) + image_task_input_schema() } async fn execute( @@ -523,39 +648,7 @@ impl Tool for LimeCreateImageTaskTool { "prompt 不能为空字符串".to_string(), )); } - let payload = serde_json::json!({ - "prompt": input.prompt, - "mode": input.mode, - "raw_text": input.raw_text, - "model": "lime-image-cli", - "style": input.style, - "size": input.size, - "aspect_ratio": input.aspect_ratio, - "count": input.count, - "usage": input.usage, - "provider_id": input.provider_id, - "session_id": input.session_id, - "project_id": input.project_id, - "content_id": input.content_id, - "entry_source": input.entry_source, - "requested_target": input.requested_target, - "slot_id": input.slot_id, - "anchor_hint": input.anchor_hint, - "anchor_section_title": input.anchor_section_title, - "anchor_text": input.anchor_text, - "target_output_id": input.target_output_id, - "target_output_ref_id": input.target_output_ref_id, - "reference_images": input.reference_images - }); - submit_media_generation_task_record( - &self.app_handle, - context, - MediaTaskType::ImageGenerate, - input.title, - payload, - Some("pending_submit".to_string()), - input.output_path.as_deref(), - ) + submit_image_generation_task_record(&self.app_handle, context, input) } } @@ -1066,3 +1159,131 @@ pub(crate) async fn ensure_creation_task_tools_registered( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + use tempfile::tempdir; + + #[test] + fn image_task_input_schema_should_expose_model_and_hide_output_path() { + let schema = image_task_input_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("schema properties"); + + assert!(properties.contains_key("model")); + assert!(!properties.contains_key("outputPath")); + } + + #[test] + fn build_image_generation_task_request_should_ignore_output_path_and_keep_standard_artifact() { + let temp_dir = tempdir().expect("create temp dir"); + let context = ToolContext::new(temp_dir.path().to_path_buf()) + .with_session_id("session-image-compat-1") + .with_environment(std::collections::HashMap::from([ + ( + "LIME_PROJECT_ID".to_string(), + "project-image-compat-1".to_string(), + ), + ( + "LIME_CONTENT_ID".to_string(), + "content-image-compat-1".to_string(), + ), + ])); + let request = build_image_generation_task_request( + &context, + ImageTaskInput { + prompt: "未来感青柠实验室".to_string(), + title: Some("青柠主视觉".to_string()), + mode: Some("generate".to_string()), + raw_text: Some("@配图 未来感青柠实验室".to_string()), + style: Some("cinematic".to_string()), + size: Some("1024x1024".to_string()), + aspect_ratio: Some("1:1".to_string()), + count: Some(2), + usage: Some("document-inline".to_string()), + provider_id: Some("fal".to_string()), + model: Some("fal-ai/nano-banana-pro".to_string()), + session_id: None, + project_id: None, + content_id: None, + entry_source: None, + requested_target: Some("generate".to_string()), + slot_id: Some("slot-1".to_string()), + anchor_hint: Some("section_end".to_string()), + anchor_section_title: Some("技术亮点".to_string()), + anchor_text: Some("这里需要一张未来实验室配图".to_string()), + target_output_id: Some("task-a:output:1".to_string()), + target_output_ref_id: Some("img-1".to_string()), + reference_images: vec!["https://example.com/reference.png".to_string()], + output_path: Some("output/image_generation_task.md".to_string()), + }, + ); + + assert_eq!( + request.project_root_path, + temp_dir.path().to_string_lossy().to_string() + ); + assert_eq!(request.model.as_deref(), Some("fal-ai/nano-banana-pro")); + assert_eq!( + request.session_id.as_deref(), + Some("session-image-compat-1") + ); + assert_eq!( + request.project_id.as_deref(), + Some("project-image-compat-1") + ); + assert_eq!( + request.content_id.as_deref(), + Some("content-image-compat-1") + ); + assert_eq!(request.entry_source.as_deref(), Some("at_image_command")); + + let output = + create_image_generation_task_artifact_inner(request).expect("create image artifact"); + + assert!(output.path.contains("image_generate")); + assert!(output.path.ends_with(".json")); + assert!(!output.path.ends_with(".md")); + assert!(output.absolute_path.ends_with(".json")); + assert_eq!( + output + .record + .payload + .get("session_id") + .and_then(Value::as_str), + Some("session-image-compat-1") + ); + assert_eq!( + output + .record + .payload + .get("project_id") + .and_then(Value::as_str), + Some("project-image-compat-1") + ); + assert_eq!( + output + .record + .payload + .get("content_id") + .and_then(Value::as_str), + Some("content-image-compat-1") + ); + assert_eq!( + output + .record + .payload + .get("entry_source") + .and_then(Value::as_str), + Some("at_image_command") + ); + assert_eq!( + output.record.payload.get("model").and_then(Value::as_str), + Some("fal-ai/nano-banana-pro") + ); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs index f65c9f58c..ca25aee58 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs @@ -48,6 +48,16 @@ fn read_output_slot_id(output: &MediaTaskOutput) -> Option { .or_else(|| read_payload_string(&output.record.payload, &["slot_id", "slotId"])) } +fn read_output_result_string(output: &MediaTaskOutput, keys: &[&str]) -> Option { + let result = output.record.result.as_ref()?; + read_payload_string(result, keys) +} + +fn read_output_result_u64(output: &MediaTaskOutput, keys: &[&str]) -> Option { + let result = output.record.result.as_ref()?; + read_payload_u64(result, keys) +} + pub(crate) fn tool_error_from_media_runtime(error: MediaRuntimeError) -> ToolError { match error { MediaRuntimeError::InvalidParams(message) => ToolError::invalid_params(message), @@ -86,6 +96,8 @@ pub(crate) fn emit_media_creation_task_event(app_handle: &AppHandle, output: &Me "anchor_hint": read_payload_string(payload_record, &["anchor_hint", "anchorHint"]), "anchor_section_title": read_payload_string(payload_record, &["anchor_section_title", "anchorSectionTitle"]), "anchor_text": read_payload_string(payload_record, &["anchor_text", "anchorText"]), + "requested_count": read_output_result_u64(output, &["requested_count", "requestedCount"]), + "received_count": read_output_result_u64(output, &["received_count", "receivedCount"]), }); if let Err(error) = app_handle.emit(CREATION_TASK_EVENT_NAME, &payload) { @@ -115,6 +127,56 @@ pub(crate) fn attach_media_task_metadata( "current_attempt_id", serde_json::json!(output.current_attempt_id), ) + .with_metadata( + "prompt", + serde_json::json!(read_payload_string(&output.record.payload, &["prompt"])), + ) + .with_metadata( + "size", + serde_json::json!(read_payload_string( + &output.record.payload, + &["size", "resolution"] + )), + ) + .with_metadata( + "project_id", + serde_json::json!(read_payload_string( + &output.record.payload, + &["project_id", "projectId"] + )), + ) + .with_metadata( + "content_id", + serde_json::json!(read_payload_string( + &output.record.payload, + &["content_id", "contentId"] + )), + ) + .with_metadata( + "provider_id", + serde_json::json!(read_output_result_string( + output, + &["provider_id", "providerId"] + )), + ) + .with_metadata( + "model", + serde_json::json!(read_output_result_string(output, &["model"])), + ) + .with_metadata( + "requested_count", + serde_json::json!(read_output_result_u64( + output, + &["requested_count", "requestedCount"] + )), + ) + .with_metadata( + "received_count", + serde_json::json!(read_output_result_u64( + output, + &["received_count", "receivedCount"] + )), + ) .with_metadata("artifact_paths", serde_json::json!(output.artifact_paths())) } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs index 956e1f1ce..9c59dd2e7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/site_tools.rs @@ -1159,6 +1159,12 @@ mod tests { content_id: "content-1".to_string(), project_id: "project-context".to_string(), title: "GitHub MCP 搜索结果".to_string(), + project_root_path: None, + bundle_relative_dir: None, + markdown_relative_path: None, + images_relative_dir: None, + meta_relative_path: None, + image_count: None, }, ), saved_project_id: Some("project-context".to_string()), diff --git a/src-tauri/src/commands/aster_agent_cmd/transcription_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/transcription_skill_launch.rs index 26f83bc63..f5e9fb29c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/transcription_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/transcription_skill_launch.rs @@ -1,6 +1,17 @@ use super::*; const TRANSCRIPTION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const TRANSCRIPTION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -47,6 +58,74 @@ pub(crate) fn merge_system_prompt_with_transcription_skill_launch( } } +pub(crate) fn should_lock_transcription_skill_launch_to_transcription_generation( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["transcription_skill_launch", "transcriptionSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "transcription_task".to_string()) + == "transcription_task" +} + +pub(crate) fn append_transcription_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_transcription_skill_launch_to_transcription_generation(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前转写技能启动回合生效".to_string()), + }] + }; + + for pattern in TRANSCRIPTION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1226, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "转写技能启动回合已锁定为 Skill(transcription_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_transcription_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_transcription_skill_launch_to_transcription_generation(request_metadata) { + return; + } + + for tool_name in TRANSCRIPTION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_transcription_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -107,6 +186,14 @@ fn build_transcription_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走转写技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(transcription_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “transcription_generate”、“lime task create transcription --json” 或 “lime_create_transcription_task” 之类目录信息;当前 transcription_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(transcription_generate)。".to_string(), "- Skill 执行后,优先沿 transcription_generate skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_transcription_task。".to_string(), "- 不要伪造“转写已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前转写任务上下文(JSON):{transcription_task_json}"), 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 index 31ed45a60..51e649ff5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/translation_skill_launch.rs @@ -1,6 +1,13 @@ use super::*; const TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const TRANSLATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +97,74 @@ pub(crate) fn merge_system_prompt_with_translation_skill_launch( } } +pub(crate) fn should_lock_translation_skill_launch_to_translation( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["translation_skill_launch", "translationSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "translation_request".to_string()) + == "translation_request" +} + +pub(crate) fn append_translation_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_translation_skill_launch_to_translation(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前翻译技能启动回合生效".to_string()), + }] + }; + + for pattern in TRANSLATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1228, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "翻译技能启动回合已锁定为 Skill(translation) 主链,禁止先走工具目录/联网检索偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_translation_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_translation_skill_launch_to_translation(request_metadata) { + return; + } + + for tool_name in TRANSLATION_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_translation_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -149,8 +224,17 @@ fn build_translation_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走翻译技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(translation) 真正执行前,不要先走 ToolSearch / WebSearch / Grep 等工具目录发现、联网检索或内容检索偏航。".to_string(), + "- 不要先搜索 “translation”、“read_file” 或 “list_directory” 的目录信息;当前 translation_request 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(translation)。".to_string(), "- 这条命令属于 prompt skill 主链,不要创建 task file,也不要回退成普通聊天翻译。".to_string(), "- 若用户明确给了正文、文件路径或范围,优先翻译这些材料;若未明确给材料,则翻译当前对话中与请求最相关的内容。".to_string(), + "- 如需处理本地路径或目录,只允许在 Skill(translation) 内最小化使用 Read / Glob 确认必要内容;不要在进入 Skill 前先探测大量文件。".to_string(), "- 译文必须忠于原文,不要补写原文没有的新事实;遇到术语或语义歧义时,要单独标注待确认项。".to_string(), format!("- 当前翻译请求上下文(JSON):{request_json}"), format!("- 当前入口来源:{entry_source}。"), 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 index 4ce6eaf3e..ff2b453f9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/typesetting_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/typesetting_skill_launch.rs @@ -1,6 +1,17 @@ use super::*; const TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const TYPESETTING_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -90,6 +101,74 @@ pub(crate) fn merge_system_prompt_with_typesetting_skill_launch( } } +pub(crate) fn should_lock_typesetting_skill_launch_to_typesetting( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["typesetting_skill_launch", "typesettingSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "typesetting_task".to_string()) + == "typesetting_task" +} + +pub(crate) fn append_typesetting_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_typesetting_skill_launch_to_typesetting(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前排版技能启动回合生效".to_string()), + }] + }; + + for pattern in TYPESETTING_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1224, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "排版技能启动回合已锁定为 Skill(typesetting) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_typesetting_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_typesetting_skill_launch_to_typesetting(request_metadata) { + return; + } + + for tool_name in TYPESETTING_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_typesetting_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -151,6 +230,14 @@ fn build_typesetting_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走排版技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(typesetting) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “typesetting”、“lime task create typesetting --json” 或 “lime_create_typesetting_task” 之类目录信息;当前 typesetting_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(typesetting)。".to_string(), "- Skill 执行后,优先沿 typesetting skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_typesetting_task。".to_string(), "- 不要伪造“排版已完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前排版任务上下文(JSON):{task_json}"), diff --git a/src-tauri/src/commands/aster_agent_cmd/url_parse_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/url_parse_skill_launch.rs index bbb07aa08..747bf4909 100644 --- a/src-tauri/src/commands/aster_agent_cmd/url_parse_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/url_parse_skill_launch.rs @@ -1,6 +1,17 @@ use super::*; const URL_PARSE_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const URL_PARSE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -46,6 +57,74 @@ pub(crate) fn merge_system_prompt_with_url_parse_skill_launch( } } +pub(crate) fn should_lock_url_parse_skill_launch_to_url_parse( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["url_parse_skill_launch", "urlParseSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "url_parse_task".to_string()) + == "url_parse_task" +} + +pub(crate) fn append_url_parse_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_url_parse_skill_launch_to_url_parse(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前链接解析技能启动回合生效".to_string()), + }] + }; + + for pattern in URL_PARSE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1225, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "链接解析技能启动回合已锁定为 Skill(url_parse) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_url_parse_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_url_parse_skill_launch_to_url_parse(request_metadata) { + return; + } + + for tool_name in URL_PARSE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_url_parse_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -97,6 +176,14 @@ fn build_url_parse_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走链接解析技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(url_parse) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “url_parse”、“lime task create url-parse --json” 或 “lime_create_url_parse_task” 之类目录信息;当前 url_parse_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(url_parse)。".to_string(), "- Skill 执行后,优先沿 url_parse skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_url_parse_task。".to_string(), "- 如果当前回合无法在预算内抓取并整理网页正文,也必须创建真实 url_parse task,并把 extractStatus 设为 pending_extract,而不是停留在空泛解释。".to_string(), "- 不要伪造“链接已解析完成”;只有 task payload 已经写入 summary / keyPoints 且 extractStatus=ready 时,才能声称已得到解析结果。".to_string(), diff --git a/src-tauri/src/commands/aster_agent_cmd/video_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/video_skill_launch.rs index 1df490058..1bd51f52e 100644 --- a/src-tauri/src/commands/aster_agent_cmd/video_skill_launch.rs +++ b/src-tauri/src/commands/aster_agent_cmd/video_skill_launch.rs @@ -1,6 +1,17 @@ use super::*; const VIDEO_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const VIDEO_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; fn extract_object_string( object: &serde_json::Map, @@ -46,6 +57,74 @@ pub(crate) fn merge_system_prompt_with_video_skill_launch( } } +pub(crate) fn should_lock_video_skill_launch_to_video_generation( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["video_skill_launch", "videoSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "video_task".to_string()) + == "video_task" +} + +pub(crate) fn append_video_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_video_skill_launch_to_video_generation(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前视频技能启动回合生效".to_string()), + }] + }; + + for pattern in VIDEO_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1237, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "视频技能启动回合已锁定为 Skill(video_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_video_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_video_skill_launch_to_video_generation(request_metadata) { + return; + } + + for tool_name in VIDEO_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + fn build_video_skill_launch_system_prompt( request_metadata: Option<&serde_json::Value>, ) -> Option { @@ -97,6 +176,14 @@ fn build_video_skill_launch_system_prompt( format!("- 第一优先工具调用必须是 Skill,且 skill=\"{skill_name}\"。"), "- 调用 Skill 时,args 必须是一个严格 JSON 字符串,不要漏引号、不要写注释、不要只传半截字段。".to_string(), format!("- 推荐传给 Skill.args 的 JSON:{args_json}"), + format!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走视频技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(video_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 不要搜索 “video_generate”、“lime media video generate --json” 或 “lime_create_video_generation_task” 之类目录信息;当前 video_task 已经提供了足够上下文。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(video_generate)。".to_string(), "- Skill 执行后,优先沿 video_generate skill 的 Bash / task file 主链提交异步任务;只有 Skill 明确不可用时,才允许直接回退到 lime_create_video_generation_task / create_video_generation_task。".to_string(), "- 不要伪造“视频已生成完成”;在 task file 真正返回结果前,只能汇报任务已提交、排队或执行中。".to_string(), format!("- 当前视频任务上下文(JSON):{video_task_json}"), diff --git a/src-tauri/src/commands/aster_agent_cmd/webpage_skill_launch.rs b/src-tauri/src/commands/aster_agent_cmd/webpage_skill_launch.rs new file mode 100644 index 000000000..86dad87a2 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/webpage_skill_launch.rs @@ -0,0 +1,260 @@ +use super::*; + +const WEBPAGE_SKILL_LAUNCH_PROMPT_MARKER: &str = "<>"; +const WEBPAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[ + TOOL_SEARCH_TOOL_NAME, + "WebSearch", + "web_search", + "Read", + "read", + "Glob", + "glob", + "Grep", + "grep", +]; + +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_webpage_skill_launch_request_metadata( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let mut metadata = request_metadata.cloned()?; + ensure_harness_workbench_chat_mode( + &mut metadata, + &["webpage_skill_launch", "webpageSkillLaunch"], + ); + + Some(metadata) +} + +pub(crate) fn merge_system_prompt_with_webpage_skill_launch( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(launch_prompt) = build_webpage_skill_launch_system_prompt(request_metadata) else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(WEBPAGE_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), + } +} + +pub(crate) fn should_lock_webpage_skill_launch_to_webpage_generate( + request_metadata: Option<&serde_json::Value>, +) -> bool { + let Some(launch) = extract_harness_nested_object( + request_metadata, + &["webpage_skill_launch", "webpageSkillLaunch"], + ) else { + return false; + }; + + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "webpage_request".to_string()) + == "webpage_request" +} + +pub(crate) fn append_webpage_skill_launch_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_webpage_skill_launch_to_webpage_generate(request_metadata) { + return; + } + + let session_id = session_id.trim(); + let conditions = if session_id.is_empty() { + Vec::new() + } else { + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前网页生成技能启动回合生效".to_string()), + }] + }; + + for pattern in WEBPAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1223, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "网页生成技能启动回合已锁定为 Skill(webpage_generate) 主链,禁止先走通用工具搜索/读文件链路偏航" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) fn prune_webpage_skill_launch_detour_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, + request_metadata: Option<&serde_json::Value>, +) { + if !should_lock_webpage_skill_launch_to_webpage_generate(request_metadata) { + return; + } + + for tool_name in WEBPAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS { + registry.unregister(tool_name); + } +} + +fn build_webpage_skill_launch_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let launch = extract_harness_nested_object( + request_metadata, + &["webpage_skill_launch", "webpageSkillLaunch"], + )?; + let kind = + extract_object_string(launch, &["kind"]).unwrap_or_else(|| "webpage_request".to_string()); + if kind != "webpage_request" { + return None; + } + + let skill_name = extract_object_string(launch, &["skill_name", "skillName"]) + .unwrap_or_else(|| "webpage_generate".to_string()); + let webpage_request = launch + .get("webpage_request") + .and_then(serde_json::Value::as_object)?; + let raw_text = extract_object_string(webpage_request, &["raw_text", "rawText"]); + let prompt = extract_object_string(webpage_request, &["prompt"]) + .unwrap_or_else(|| "请生成一个可直接预览的网页".to_string()); + let content = extract_object_string(webpage_request, &["content"]); + let page_type = extract_object_string(webpage_request, &["page_type", "pageType"]); + let style = extract_object_string(webpage_request, &["style"]); + let tech_stack = extract_object_string(webpage_request, &["tech_stack", "techStack"]); + let project_id = extract_object_string(webpage_request, &["project_id", "projectId"]); + let content_id = extract_object_string(webpage_request, &["content_id", "contentId"]); + let entry_source = extract_object_string(webpage_request, &["entry_source", "entrySource"]) + .unwrap_or_else(|| "at_webpage_command".to_string()); + let args_payload = serde_json::json!({ + "user_input": raw_text.clone().unwrap_or_else(|| prompt.clone()), + "webpage_request": serde_json::Value::Object(webpage_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(webpage_request).unwrap_or_else(|_| "{}".to_string()), + 4_000, + ); + + let mut lines = vec![ + WEBPAGE_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!( + "- 第一工具调用示例(Skill 参数 JSON):{{\"skill\":\"{skill_name}\",\"args\":{}}}", + serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string()) + ), + "- 当前回合已经显式知道要走网页生成技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(), + "- 在 Skill(webpage_generate) 真正执行前,不要先走 ToolSearch / WebSearch / Read / Glob / Grep 等通用工具发现、检索或读文件链路。".to_string(), + "- 当前网页命令的目标是产出真实 HTML artifact,不要先退回空泛文案,也不要只给伪代码或结构说明。".to_string(), + "- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(webpage_generate)。".to_string(), + "- Skill 执行时必须产出一个可预览的单文件 HTML,并通过 落到工作区。".to_string(), + "- 不要伪造“网页已生成”但没有实际文件;如果信息不足,最多追问 1 个关键问题。".to_string(), + format!("- 当前网页请求上下文(JSON):{request_json}"), + format!("- 当前入口来源:{entry_source}。"), + ]; + + lines.push(format!("- 当前网页目标:{prompt}")); + if let Some(value) = content.as_deref() { + lines.push(format!( + "- 当前原始命令摘要:{}", + truncate_prompt_text(value.to_string(), 400) + )); + } + if let Some(value) = page_type.as_deref() { + lines.push(format!("- 当前页面类型:{value}。")); + } + if let Some(value) = style.as_deref() { + lines.push(format!("- 当前风格要求:{value}。")); + } + if let Some(value) = tech_stack.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}。")); + } + lines.push("- 当前任务已经显式进入网页生成技能主链。".to_string()); + + Some(lines.join("\n")) +} diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index 9d814513b..b8cc98698 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -9,19 +9,17 @@ use lime_media_runtime::{ }; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::json; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use std::time::Duration; use tauri::{AppHandle, Manager}; use crate::commands::aster_agent_cmd::tool_runtime::media_cli_bridge; use crate::config::GlobalConfigManagerState; const IMAGE_TASK_RUNNER_WORKER_ID: &str = "lime-image-api-worker"; -const IMAGE_TASK_RUNNER_TIMEOUT_SECS: u64 = 300; static ACTIVE_IMAGE_TASK_EXECUTIONS: Lazy>> = Lazy::new(|| Mutex::new(HashSet::new())); @@ -215,16 +213,6 @@ struct ImageGenerationRunnerConfig { api_key: String, } -#[derive(Debug, Clone)] -struct PreparedImageTaskInput { - prompt: String, - model: String, - size: Option, - count: u32, - style: Option, - provider_id: Option, -} - fn normalize_server_host(host: &str) -> String { let trimmed = host.trim(); if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" { @@ -302,96 +290,6 @@ fn build_task_error( } } -fn read_payload_string(payload: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - payload - .get(*key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) -} - -fn read_payload_positive_u32(payload: &Value, keys: &[&str]) -> Option { - keys.iter().find_map(|key| { - let value = payload.get(*key)?; - if let Some(number) = value.as_u64() { - return u32::try_from(number).ok().filter(|item| *item > 0); - } - value - .as_str() - .and_then(|item| item.trim().parse::().ok().filter(|parsed| *parsed > 0)) - }) -} - -fn prepare_image_task_input(task: &MediaTaskOutput) -> Result { - let payload = &task.record.payload; - let prompt = read_payload_string(payload, &["prompt"]) - .ok_or_else(|| "图片任务缺少 prompt,无法继续执行".to_string())?; - let count = read_payload_positive_u32(payload, &["count", "image_count"]).unwrap_or(1); - - Ok(PreparedImageTaskInput { - prompt, - model: read_payload_string(payload, &["model"]).unwrap_or_default(), - size: read_payload_string(payload, &["size"]), - count, - style: read_payload_string(payload, &["style"]), - provider_id: read_payload_string(payload, &["provider_id", "providerId"]), - }) -} - -fn collect_generated_images(response_body: &Value) -> Vec { - response_body - .get("data") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(|item| { - let record = item.as_object()?; - let url = record - .get("url") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .or_else(|| { - record - .get("b64_json") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!("data:image/png;base64,{value}")) - })?; - Some(json!({ - "url": url, - "revised_prompt": record - .get("revised_prompt") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()), - })) - }) - .collect() - }) - .unwrap_or_default() -} - -fn summarize_response_body(body: &str) -> String { - let trimmed = body.trim(); - if trimmed.is_empty() { - return "响应体为空".to_string(); - } - - let preview: String = trimmed.chars().take(240).collect(); - if trimmed.chars().count() > preview.chars().count() { - format!("{preview}...") - } else { - preview - } -} - fn load_current_image_task( workspace_root: &Path, task_id: &str, @@ -447,207 +345,18 @@ async fn execute_image_generation_task( task_id: String, runner_config: ImageGenerationRunnerConfig, ) -> Result { - let current = load_current_image_task(&workspace_root, &task_id)?; - if matches!( - current.normalized_status.as_str(), - "cancelled" | "failed" | "succeeded" | "partial" - ) { - return Ok(current); - } - - let queued_output = if current.normalized_status == "pending" { - let output = patch_image_task( - &workspace_root, - &task_id, - TaskArtifactPatch { - status: Some("queued".to_string()), - progress: Some(build_task_progress( - "queued", - "图片任务已进入队列,等待图片服务响应。".to_string(), - Some(0), - )), - current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), - ..TaskArtifactPatch::default() - }, - )?; - emit_image_task_event(app.as_ref(), &output); - output - } else { - current - }; - - if queued_output.normalized_status == "cancelled" { - return Ok(queued_output); - } - - let prepared_input = prepare_image_task_input(&queued_output).map_err(|message| { - let task_error = build_task_error("invalid_image_task_payload", message, false, "payload"); - let _ = mark_image_task_failed(app.as_ref(), &workspace_root, &task_id, task_error); - "图片任务 payload 无法执行".to_string() - })?; - - let running_output = patch_image_task( + let emit_app = app.clone(); + lime_media_runtime::execute_image_generation_task_with_hook( &workspace_root, &task_id, - TaskArtifactPatch { - status: Some("running".to_string()), - progress: Some(build_task_progress( - "running", - "图片生成中,结果会自动回填到对话与画布。".to_string(), - None, - )), - current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), - ..TaskArtifactPatch::default() + &lime_media_runtime::ImageGenerationRunnerConfig { + endpoint: runner_config.endpoint, + api_key: runner_config.api_key, }, - )?; - emit_image_task_event(app.as_ref(), &running_output); - - let client = reqwest::Client::builder() - .no_proxy() - .timeout(Duration::from_secs(IMAGE_TASK_RUNNER_TIMEOUT_SECS)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - - let request_body = lime_core::models::openai::ImageGenerationRequest { - prompt: prepared_input.prompt.clone(), - model: prepared_input.model.clone(), - n: prepared_input.count.max(1), - size: prepared_input.size.clone(), - response_format: "b64_json".to_string(), - quality: None, - style: prepared_input.style.clone(), - user: Some(task_id.clone()), - }; - - let mut request_builder = client - .post(&runner_config.endpoint) - .header("Authorization", format!("Bearer {}", runner_config.api_key)); - if let Some(provider_id) = prepared_input - .provider_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - request_builder = request_builder.header("X-Provider-Id", provider_id); - } - - let response = request_builder - .json(&request_body) - .send() - .await - .map_err(|error| { - let task_error = build_task_error( - "image_request_failed", - format!("调用图片服务失败: {error}"), - true, - "request", - ); - let _ = mark_image_task_failed(app.as_ref(), &workspace_root, &task_id, task_error); - format!("调用图片服务失败: {error}") - })?; - - let status = response.status(); - let response_body_raw = response.text().await.map_err(|error| { - let task_error = build_task_error( - "image_response_read_failed", - format!("读取图片服务响应失败: {error}"), - false, - "response", - ); - let _ = mark_image_task_failed(app.as_ref(), &workspace_root, &task_id, task_error); - format!("读取图片服务响应失败: {error}") - })?; - let response_body: Value = serde_json::from_str(&response_body_raw).map_err(|error| { - let detail = summarize_response_body(&response_body_raw); - let task_error = build_task_error( - "image_response_parse_failed", - format!("解析图片服务响应失败: {error};{detail}"), - false, - "response", - ); - let _ = mark_image_task_failed(app.as_ref(), &workspace_root, &task_id, task_error); - format!("解析图片服务响应失败: {error};{detail}") - })?; - - if !status.is_success() { - let error_code = response_body - .get("error") - .and_then(|value| value.get("code")) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("image_generation_failed"); - let error_message = response_body - .get("error") - .and_then(|value| value.get("message")) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("图片服务未返回可用结果"); - let task_error = build_task_error( - error_code, - error_message, - status.is_server_error() || status.as_u16() == 429, - "request", - ); - return mark_image_task_failed(app.as_ref(), &workspace_root, &task_id, task_error); - } - - let images = collect_generated_images(&response_body); - if images.is_empty() { - let task_error = build_task_error( - "image_result_empty", - "图片服务已返回成功,但没有可用的图片地址", - false, - "result", - ); - return mark_image_task_failed(app.as_ref(), &workspace_root, &task_id, task_error); - } - - let latest = load_current_image_task(&workspace_root, &task_id)?; - if latest.normalized_status == "cancelled" { - return Ok(latest); - } - - let final_status = if images.len() < prepared_input.count as usize { - "partial" - } else { - "succeeded" - }; - let result_value = json!({ - "provider_id": prepared_input.provider_id, - "model": if prepared_input.model.trim().is_empty() { - None:: - } else { - Some(prepared_input.model.clone()) - }, - "size": prepared_input.size, - "requested_count": prepared_input.count, - "received_count": images.len(), - "images": images, - "response": response_body, - }); - let success_message = if final_status == "partial" { - format!("图片任务已返回部分结果,共生成 {} 张。", images.len()) - } else { - format!("图片任务已完成,共生成 {} 张。", images.len()) - }; - let completed = patch_image_task( - &workspace_root, - &task_id, - TaskArtifactPatch { - status: Some(final_status.to_string()), - result: Some(Some(result_value)), - last_error: Some(None), - progress: Some(build_task_progress( - final_status, - success_message, - Some(100), - )), - current_attempt_worker_id: Some(Some(IMAGE_TASK_RUNNER_WORKER_ID.to_string())), - ..TaskArtifactPatch::default() - }, - )?; - emit_image_task_event(app.as_ref(), &completed); - Ok(completed) + move |output| emit_image_task_event(emit_app.as_ref(), output), + ) + .await + .map_err(|error| format!("执行图片任务失败: {error}")) } pub(crate) fn start_image_generation_task_worker_if_needed( @@ -708,6 +417,17 @@ fn emit_creation_task_event_if_needed(app: Option<&AppHandle>, output: &MediaTas } } +pub(crate) fn finalize_image_generation_task_creation( + app: Option<&AppHandle>, + workspace_root: &str, + output: &MediaTaskOutput, +) { + emit_creation_task_event_if_needed(app, output); + if let Some(app_handle) = app { + start_image_generation_task_worker_if_needed(app_handle, workspace_root, output); + } +} + pub(crate) fn create_image_generation_task_artifact_inner( request: CreateImageGenerationTaskArtifactRequest, ) -> Result { @@ -870,8 +590,7 @@ pub fn create_image_generation_task_artifact( ) -> Result { let project_root_path = request.project_root_path.trim().to_string(); let output = create_image_generation_task_artifact_inner(request)?; - emit_creation_task_event_if_needed(Some(&app), &output); - start_image_generation_task_worker_if_needed(&app, &project_root_path, &output); + finalize_image_generation_task_creation(Some(&app), &project_root_path, &output); Ok(output) } @@ -905,6 +624,7 @@ mod tests { routing::post, Json, Router, }; + use serde_json::Value; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; diff --git a/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs b/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs index 18904261c..533fae070 100644 --- a/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs +++ b/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs @@ -2,8 +2,8 @@ use super::{args_or_default, parse_nested_arg}; use crate::commands::aster_agent_cmd::tool_runtime::media_cli_bridge; use crate::commands::media_task_cmd::{ cancel_media_task_artifact_inner, create_image_generation_task_artifact_inner, - get_media_task_artifact_inner, list_media_task_artifacts_inner, - start_image_generation_task_worker_if_needed, CreateImageGenerationTaskArtifactRequest, + finalize_image_generation_task_creation, get_media_task_artifact_inner, + list_media_task_artifacts_inner, CreateImageGenerationTaskArtifactRequest, ListMediaTaskArtifactsRequest, MediaTaskLookupRequest, }; use crate::dev_bridge::DevBridgeState; @@ -29,14 +29,11 @@ pub(super) async fn try_handle( let project_root_path = request.project_root_path.trim().to_string(); let output = create_image_generation_task_artifact_inner(request).map_err(to_dyn_error)?; - if let Some(app_handle) = state.app_handle.as_ref() { - media_cli_bridge::emit_media_creation_task_event(app_handle, &output); - start_image_generation_task_worker_if_needed( - app_handle, - project_root_path.as_str(), - &output, - ); - } + finalize_image_generation_task_creation( + state.app_handle.as_ref(), + project_root_path.as_str(), + &output, + ); serde_json::to_value(output)? } "get_media_task_artifact" => { diff --git a/src-tauri/src/services/browser_connector_service.rs b/src-tauri/src/services/browser_connector_service.rs index d8e21c399..eec263595 100644 --- a/src-tauri/src/services/browser_connector_service.rs +++ b/src-tauri/src/services/browser_connector_service.rs @@ -15,6 +15,12 @@ use tauri::{AppHandle, Manager}; const SETTINGS_SUBDIR: &str = "connectors"; const SETTINGS_FILE_NAME: &str = "browser-connector-settings.json"; const EXTENSION_INSTALL_DIR_NAME: &str = "Lime Browser Connector"; +const EXTENSION_SYNC_REQUIRED_FILES: [&str; 4] = [ + "manifest.json", + "background.js", + "content_script.js", + "site_adapter_runners.generated.js", +]; const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [ ( @@ -551,6 +557,34 @@ pub fn resolve_browser_connector_extension_source(app: &AppHandle) -> Result Result, String> { + for relative_path in EXTENSION_SYNC_REQUIRED_FILES { + let bundled_path = bundled_extension_dir.join(relative_path); + let bundled_content = fs::read(&bundled_path) + .map_err(|error| format!("读取内置连接器文件失败 {:?}: {error}", bundled_path))?; + + let installed_path = install_dir.join(relative_path); + let installed_content = match fs::read(&installed_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Some(format!("缺少关键文件 {relative_path}"))); + } + Err(error) => { + return Ok(Some(format!("关键文件 {relative_path} 无法读取: {error}"))); + } + }; + + if installed_content != bundled_content { + return Ok(Some(format!("关键文件 {relative_path} 已变化"))); + } + } + + Ok(None) +} + fn build_install_status_from_paths( bundled_extension_dir: &Path, install_root_dir: Option<&Path>, @@ -586,15 +620,22 @@ fn build_install_status_from_paths( match read_manifest_info(&install_dir) { Ok(installed_manifest) => { - let status = if installed_manifest.version == bundled_manifest.version { - "installed" + let (status, message) = if installed_manifest.version != bundled_manifest.version { + ( + "update_available", + Some("检测到用户目录中的连接器版本落后于当前应用".to_string()), + ) + } else if let Some(drift_reason) = + detect_extension_sync_drift(bundled_extension_dir, &install_dir)? + { + ( + "update_available", + Some(format!( + "检测到已安装连接器与当前应用内置扩展不一致({drift_reason}),请重新同步扩展" + )), + ) } else { - "update_available" - }; - let message = if status == "installed" { - Some("已安装最新版本浏览器连接器".to_string()) - } else { - Some("检测到用户目录中的连接器版本落后于当前应用".to_string()) + ("installed", Some("已安装最新版本浏览器连接器".to_string())) }; Ok(BrowserConnectorInstallStatus { @@ -838,19 +879,38 @@ pub fn update_browser_action_capability_enabled( mod tests { use super::*; - fn create_extension_dir(base_dir: &Path, version: &str) -> PathBuf { - let extension_dir = base_dir.join(format!("extension-{version}")); + fn write_extension_fixture(extension_dir: &Path, version: &str, tag: &str) { fs::create_dir_all(&extension_dir).expect("创建扩展目录失败"); fs::write( extension_dir.join("manifest.json"), format!( r#"{{ - "name": "Lime Browser Connector", + "name": "Lime Browser Bridge", "version": "{version}" }}"# ), ) .expect("写入 manifest 失败"); + fs::write( + extension_dir.join("background.js"), + format!("globalThis.__lime_background = \"{tag}\";\n"), + ) + .expect("写入 background.js 失败"); + fs::write( + extension_dir.join("content_script.js"), + format!("globalThis.__lime_content = \"{tag}\";\n"), + ) + .expect("写入 content_script.js 失败"); + fs::write( + extension_dir.join("site_adapter_runners.generated.js"), + format!("globalThis.__lime_generated = \"{tag}\";\n"), + ) + .expect("写入 site_adapter_runners.generated.js 失败"); + } + + fn create_extension_dir(base_dir: &Path, version: &str, tag: &str) -> PathBuf { + let extension_dir = base_dir.join(format!("extension-{version}-{tag}")); + write_extension_fixture(&extension_dir, version, tag); extension_dir } @@ -864,7 +924,7 @@ mod tests { #[test] fn status_should_report_not_installed_without_root_dir() { let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); - let bundled_dir = create_extension_dir(temp_dir.path(), "1.0.0"); + let bundled_dir = create_extension_dir(temp_dir.path(), "1.0.0", "bundled"); let status = build_install_status_from_paths(&bundled_dir, None).expect("读取状态失败"); assert_eq!(status.status, "not_installed"); @@ -875,18 +935,10 @@ mod tests { #[test] fn status_should_report_update_available_when_versions_differ() { let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); - let bundled_dir = create_extension_dir(temp_dir.path(), "1.1.0"); + let bundled_dir = create_extension_dir(temp_dir.path(), "1.1.0", "bundled"); let install_root = temp_dir.path().join("user-selected"); let installed_dir = resolve_browser_connector_install_dir(&install_root); - fs::create_dir_all(&installed_dir).expect("创建安装目录失败"); - fs::write( - installed_dir.join("manifest.json"), - r#"{ - "name": "Lime Browser Connector", - "version": "1.0.0" -}"#, - ) - .expect("写入已安装 manifest 失败"); + write_extension_fixture(&installed_dir, "1.0.0", "installed"); let status = build_install_status_from_paths(&bundled_dir, Some(&install_root)) .expect("读取状态失败"); @@ -895,6 +947,44 @@ mod tests { assert_eq!(status.bundled_version, "1.1.0"); } + #[test] + fn status_should_report_installed_when_versions_and_files_match() { + let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); + let bundled_dir = create_extension_dir(temp_dir.path(), "1.1.0", "bundled"); + let install_root = temp_dir.path().join("user-selected"); + let installed_dir = resolve_browser_connector_install_dir(&install_root); + copy_dir_recursive(&bundled_dir, &installed_dir).expect("复制安装目录失败"); + + let status = build_install_status_from_paths(&bundled_dir, Some(&install_root)) + .expect("读取状态失败"); + + assert_eq!(status.status, "installed"); + assert_eq!( + status.message.as_deref(), + Some("已安装最新版本浏览器连接器") + ); + } + + #[test] + fn status_should_report_update_available_when_required_file_missing() { + let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); + let bundled_dir = create_extension_dir(temp_dir.path(), "1.1.0", "bundled"); + let install_root = temp_dir.path().join("user-selected"); + let installed_dir = resolve_browser_connector_install_dir(&install_root); + copy_dir_recursive(&bundled_dir, &installed_dir).expect("复制安装目录失败"); + fs::remove_file(installed_dir.join("site_adapter_runners.generated.js")) + .expect("删除 generated runner 失败"); + + let status = build_install_status_from_paths(&bundled_dir, Some(&install_root)) + .expect("读取状态失败"); + + assert_eq!(status.status, "update_available"); + assert!(status + .message + .as_deref() + .is_some_and(|message| message.contains("site_adapter_runners.generated.js"))); + } + #[cfg(target_os = "macos")] #[test] fn settings_snapshot_should_expose_visible_system_connectors_on_macos() { diff --git a/src-tauri/src/services/chat_history_service.rs b/src-tauri/src/services/chat_history_service.rs index f9068858b..429e4482e 100644 --- a/src-tauri/src/services/chat_history_service.rs +++ b/src-tauri/src/services/chat_history_service.rs @@ -213,7 +213,9 @@ mod tests { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, - reasoning_content TEXT + reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER ); CREATE TABLE general_chat_sessions ( id TEXT PRIMARY KEY, diff --git a/src-tauri/src/services/conversation_statistics_service.rs b/src-tauri/src/services/conversation_statistics_service.rs index 82df9fa78..676d4eea6 100644 --- a/src-tauri/src/services/conversation_statistics_service.rs +++ b/src-tauri/src/services/conversation_statistics_service.rs @@ -557,7 +557,9 @@ mod tests { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, - reasoning_content TEXT + reasoning_content TEXT, + input_tokens INTEGER, + output_tokens INTEGER ); CREATE TABLE general_chat_sessions ( id TEXT PRIMARY KEY, diff --git a/src-tauri/src/services/site_adapter_registry.rs b/src-tauri/src/services/site_adapter_registry.rs index dea416b17..a3ae71f7d 100644 --- a/src-tauri/src/services/site_adapter_registry.rs +++ b/src-tauri/src/services/site_adapter_registry.rs @@ -5,6 +5,7 @@ use serde_json::{Map, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; +use url::Url; const BUNDLED_ADAPTER_RELATIVE_DIR: &str = "resources/site-adapters/bundled"; const IMPORTED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/imported"; @@ -742,6 +743,9 @@ fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, Strin "scripts/yahoo-finance-quote.js" => Ok(include_str!( "../../resources/site-adapters/bundled/scripts/yahoo-finance-quote.js" )), + "scripts/x-article-export.js" => Ok(include_str!( + "../../resources/site-adapters/bundled/scripts/x-article-export.js" + )), "scripts/zhihu-hot.js" => Ok(include_str!( "../../resources/site-adapters/bundled/scripts/zhihu-hot.js" )), @@ -885,6 +889,7 @@ fn render_entry_template(template: &str, args: &Map) -> Result) -> Result { match id { "github_issues" => build_github_issues_url(args), + "x_article_export" => build_x_article_export_url(args), _ => Err(format!("不支持的入口构造器: {id}")), } } @@ -916,6 +921,24 @@ fn build_github_issues_url(args: &Map) -> Result } } +fn build_x_article_export_url(args: &Map) -> Result { + let raw_url = get_required_string_arg(args, "url")?; + let parsed = + Url::parse(&raw_url).map_err(|error| format!("url 不是合法的站点链接: {error}"))?; + let hostname = parsed + .host_str() + .map(|value| value.trim().to_ascii_lowercase()) + .ok_or_else(|| "url 缺少域名".to_string())?; + if hostname != "x.com" && hostname != "www.x.com" && hostname != "twitter.com" { + return Err("url 必须指向 x.com 或 twitter.com".to_string()); + } + if !parsed.path().contains("/article/") { + return Err("url 必须是 X 长文 article 链接".to_string()); + } + + Ok(parsed.to_string()) +} + fn get_required_string_arg(args: &Map, key: &str) -> Result { get_optional_string_arg(args, key).ok_or_else(|| format!("参数 {key} 不能为空")) } @@ -1037,6 +1060,18 @@ mod tests { if template == "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b" )); assert!(smzdm.script.contains("li.feed-row-wide")); + + let x_article = adapters + .iter() + .find(|adapter| adapter.name == "x/article-export") + .expect("x/article-export should exist"); + assert_eq!(x_article.source_kind, SiteAdapterSourceKind::Bundled); + assert_eq!(x_article.source_version.as_deref(), Some("2026-04-07")); + assert!(matches!( + x_article.entry, + SiteAdapterEntrySpec::Builder { ref id } if id == "x_article_export" + )); + assert!(x_article.script.contains("markdown_bundle")); } #[test] @@ -1114,6 +1149,35 @@ mod tests { assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1")); } + #[test] + fn should_build_x_article_export_url_with_builder() { + let mut args = Map::new(); + args.insert( + "url".to_string(), + Value::String("https://x.com/GoogleCloudTech/article/2033953579824758855".to_string()), + ); + + let url = build_entry_url_with_builder("x_article_export", &args) + .expect("x article builder should accept article url"); + assert_eq!( + url, + "https://x.com/GoogleCloudTech/article/2033953579824758855" + ); + } + + #[test] + fn should_reject_non_article_url_for_x_article_export_builder() { + let mut args = Map::new(); + args.insert( + "url".to_string(), + Value::String("https://x.com/GoogleCloudTech/status/123".to_string()), + ); + + let error = build_entry_url_with_builder("x_article_export", &args) + .expect_err("non-article url should be rejected"); + assert!(error.contains("article")); + } + #[test] fn should_load_imported_adapters_from_imported_catalog() { let temp_dir = tempdir().expect("temp dir should exist"); diff --git a/src-tauri/src/services/site_capability_service.rs b/src-tauri/src/services/site_capability_service.rs index 186a70668..1e559060e 100644 --- a/src-tauri/src/services/site_capability_service.rs +++ b/src-tauri/src/services/site_capability_service.rs @@ -9,6 +9,8 @@ use crate::services::site_adapter_registry::{ build_entry_url, find_site_adapter_spec, load_site_adapter_specs, normalize_site_adapter_name, SiteAdapterArgType, SiteAdapterSpec, }; +use crate::workspace::WorkspaceManager; +use base64::Engine; use lime_browser_runtime::{CdpSessionState, CdpTargetInfo}; use lime_core::database::dao::browser_profile::{ BrowserProfileDao, BrowserProfileRecord, BrowserProfileTransportKind, @@ -16,9 +18,13 @@ use lime_core::database::dao::browser_profile::{ use lime_server::chrome_bridge::{ self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeObserverSnapshot, }; +use reqwest::header::CONTENT_TYPE; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::HashSet; +use std::fs; +use std::future::Future; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use url::Url; @@ -28,6 +34,9 @@ const MIN_ADAPTER_EVALUATE_TIMEOUT_MS: u64 = 30_000; const MAX_TIMEOUT_MS: u64 = 120_000; const EXPLICIT_PROJECT_SAVE_SOURCE: &str = "explicit_project"; const EXPLICIT_CONTENT_SAVE_SOURCE: &str = "explicit_content"; +const SITE_EXPORTS_ROOT_DIR: &str = "exports"; +const MARKDOWN_BUNDLE_EXPORT_KIND: &str = "markdown_bundle"; +const MARKDOWN_BUNDLE_IMAGE_DIR: &str = "images"; #[derive(Debug, Clone, Serialize)] pub struct SiteAdapterArgumentDefinition { @@ -176,6 +185,98 @@ pub struct SavedSiteAdapterContent { pub content_id: String, pub project_id: String, pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_root_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_relative_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub markdown_relative_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub images_relative_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta_relative_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MarkdownBundleImageExport { + url: String, + #[serde(default)] + alt: Option, + #[serde(default)] + suggested_file_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MarkdownBundleExport { + #[serde(rename = "export_kind")] + _export_kind: String, + #[serde(default)] + title: Option, + #[serde(default)] + source_url: Option, + #[serde(default)] + author: Option, + #[serde(default)] + published_at: Option, + markdown: String, + #[serde(default)] + images: Vec, +} + +#[derive(Debug, Clone)] +struct MaterializedMarkdownBundle { + title: String, + markdown: String, + bundle_relative_dir: String, + markdown_relative_path: String, + images_relative_dir: String, + meta_relative_path: String, + image_count: usize, + source_url: Option, + author: Option, + published_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SavedMarkdownBundleImageRecord { + original_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + alt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + suggested_file_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + markdown_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + project_relative_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + download_error: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SavedMarkdownBundleMeta { + export_kind: String, + adapter_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + skill_title: Option, + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + original_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + source_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + published_at: Option, + entry_url: String, + exported_at: String, + bundle_relative_dir: String, + markdown_relative_path: String, + images_relative_dir: String, + meta_relative_path: String, + image_count: usize, + images: Vec, } #[derive(Debug, Clone)] @@ -211,7 +312,7 @@ struct SiteAdapterRecommendationCandidate { #[derive(Debug, Clone)] struct SiteAdapterAttachedLaunchCandidate { profile_key: String, - target_id: String, + target_id: Option, current_url_matches: bool, saved_existing_session: bool, } @@ -431,6 +532,612 @@ pub fn build_site_result_document_body( lines.join("\n") } +fn block_on_site_capability_future(future: F) -> Result +where + F: Future>, +{ + if let Ok(handle) = tokio::runtime::Handle::try_current() { + return tokio::task::block_in_place(|| handle.block_on(future)); + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .map_err(|error| format!("创建站点保存 Runtime 失败: {error}"))?; + runtime.block_on(future) +} + +fn trim_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn parse_markdown_bundle_export( + result: &SiteAdapterRunResult, +) -> Result, String> { + let Some(data) = result.data.as_ref() else { + return Ok(None); + }; + let Some(export_kind) = data.get("export_kind").and_then(Value::as_str) else { + return Ok(None); + }; + if export_kind != MARKDOWN_BUNDLE_EXPORT_KIND { + return Ok(None); + } + + let export: MarkdownBundleExport = serde_json::from_value(data.clone()) + .map_err(|error| format!("解析 markdown bundle 结果失败: {error}"))?; + if export.markdown.trim().is_empty() { + return Err("markdown bundle 结果缺少正文 markdown".to_string()); + } + + Ok(Some(export)) +} + +fn sanitize_export_segment(value: &str) -> String { + let mut sanitized = String::with_capacity(value.len()); + let mut last_was_dash = false; + + for ch in value.chars() { + let normalized = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if matches!(ch, '/' | '\\' | '-' | '_' | ' ' | '.') { + Some('-') + } else { + None + }; + + let Some(next_char) = normalized else { + continue; + }; + if next_char == '-' { + if last_was_dash { + continue; + } + last_was_dash = true; + sanitized.push(next_char); + continue; + } + + last_was_dash = false; + sanitized.push(next_char); + } + + let trimmed = sanitized.trim_matches('-'); + if trimmed.is_empty() { + "export".to_string() + } else { + trimmed.to_string() + } +} + +fn extract_last_path_segment(raw_url: Option<&str>) -> Option { + let url = Url::parse(raw_url?).ok()?; + url.path_segments() + .and_then(|mut segments| segments.next_back().map(ToString::to_string)) + .and_then(|value| trim_optional_text(Some(value.as_str()))) +} + +fn resolve_markdown_bundle_title( + request: &RunSiteAdapterRequest, + adapter: &SiteAdapterDefinition, + export: &MarkdownBundleExport, +) -> String { + trim_optional_text(request.save_title.as_deref()) + .or_else(|| trim_optional_text(export.title.as_deref())) + .or_else(|| trim_optional_text(request.skill_title.as_deref())) + .unwrap_or_else(|| build_site_result_document_title(&adapter.name, None)) +} + +fn resolve_markdown_bundle_slug(title: &str, export: &MarkdownBundleExport) -> String { + let title_slug = sanitize_export_segment(title); + if title_slug != "export" { + return title_slug; + } + + extract_last_path_segment(export.source_url.as_deref()) + .map(|value| sanitize_export_segment(&value)) + .filter(|value| value != "export") + .unwrap_or_else(|| "article-export".to_string()) +} + +fn first_non_empty_markdown_line(value: &str) -> Option<&str> { + value.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn markdown_heading_matches_title(markdown: &str, title: &str) -> bool { + let normalized_title = title.trim(); + if normalized_title.is_empty() { + return false; + } + + let Some(first_line) = first_non_empty_markdown_line(markdown) else { + return false; + }; + if first_line == normalized_title { + return true; + } + + first_line + .strip_prefix('#') + .map(str::trim) + .is_some_and(|line| line == normalized_title) +} + +fn build_markdown_bundle_document_body( + title: &str, + export: &MarkdownBundleExport, + markdown_body: &str, +) -> String { + let mut lines = Vec::new(); + let normalized_body = markdown_body.trim(); + + if !markdown_heading_matches_title(normalized_body, title) { + lines.push(format!("# {title}")); + lines.push(String::new()); + } + + let mut metadata_lines = Vec::new(); + if let Some(author) = trim_optional_text(export.author.as_deref()) { + metadata_lines.push(format!("> 作者:{author}")); + } + if let Some(published_at) = trim_optional_text(export.published_at.as_deref()) { + metadata_lines.push(format!("> 发布时间:{published_at}")); + } + if let Some(source_url) = trim_optional_text(export.source_url.as_deref()) { + metadata_lines.push(format!("> 原文链接:{source_url}")); + } + if !metadata_lines.is_empty() { + lines.extend(metadata_lines); + lines.push(String::new()); + } + + lines.push(normalized_body.to_string()); + lines.join("\n") +} + +fn normalize_bundle_file_name_segment(value: &str) -> Option { + let mut sanitized = String::with_capacity(value.len()); + let mut last_was_dash = false; + + for ch in value.chars() { + let normalized = if ch.is_ascii_alphanumeric() { + Some(ch.to_ascii_lowercase()) + } else if matches!(ch, '-' | '_' | ' ') { + Some('-') + } else { + None + }; + + let Some(next_char) = normalized else { + continue; + }; + if next_char == '-' { + if last_was_dash { + continue; + } + last_was_dash = true; + sanitized.push(next_char); + continue; + } + last_was_dash = false; + sanitized.push(next_char); + } + + let trimmed = sanitized.trim_matches('-'); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn extract_extension_from_file_name(value: &str) -> Option { + Path::new(value) + .extension() + .and_then(|ext| ext.to_str()) + .map(str::trim) + .filter(|value| !value.is_empty() && value.len() <= 12) + .map(|value| value.to_ascii_lowercase()) +} + +fn extract_extension_from_url(raw_url: &str) -> Option { + let url = Url::parse(raw_url).ok()?; + if let Some(format) = url + .query_pairs() + .find_map(|(key, value)| (key == "format").then_some(value.into_owned())) + .and_then(|value| trim_optional_text(Some(value.as_str()))) + { + return Some(format.to_ascii_lowercase()); + } + + url.path_segments() + .and_then(|mut segments| segments.next_back().map(ToString::to_string)) + .and_then(|value| extract_extension_from_file_name(&value)) +} + +fn extension_from_content_type(content_type: Option<&str>) -> Option<&'static str> { + match content_type + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + { + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/gif" => Some("gif"), + "image/webp" => Some("webp"), + "image/svg+xml" => Some("svg"), + "image/bmp" => Some("bmp"), + _ => None, + } +} + +fn build_markdown_bundle_image_file_name( + index: usize, + image: &MarkdownBundleImageExport, + content_type: Option<&str>, + used_file_names: &mut HashSet, +) -> String { + let suggested = trim_optional_text(image.suggested_file_name.as_deref()); + let suggested_stem = suggested + .as_deref() + .and_then(|value| Path::new(value).file_stem().and_then(|stem| stem.to_str())) + .and_then(normalize_bundle_file_name_segment); + let fallback_stem = extract_last_path_segment(Some(image.url.as_str())) + .as_deref() + .and_then(|value| Path::new(value).file_stem().and_then(|stem| stem.to_str())) + .and_then(normalize_bundle_file_name_segment); + let stem = suggested_stem + .or(fallback_stem) + .unwrap_or_else(|| format!("image-{}", index + 1)); + let extension = suggested + .as_deref() + .and_then(extract_extension_from_file_name) + .or_else(|| extract_extension_from_url(&image.url)) + .or_else(|| extension_from_content_type(content_type).map(ToString::to_string)) + .unwrap_or_else(|| "jpg".to_string()); + + let mut file_name = format!("{stem}.{extension}"); + if used_file_names.insert(file_name.clone()) { + return file_name; + } + + let mut duplicate_index = 2usize; + loop { + file_name = format!("{stem}-{duplicate_index}.{extension}"); + if used_file_names.insert(file_name.clone()) { + return file_name; + } + duplicate_index += 1; + } +} + +fn path_to_forward_slashes(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn relative_path_from(base: &Path, target: &Path) -> Result { + let relative = target + .strip_prefix(base) + .map_err(|error| format!("无法构建相对路径: {error}"))?; + Ok(path_to_forward_slashes(relative)) +} + +fn allocate_markdown_bundle_export_dir(base_dir: &Path, slug: &str) -> Result { + fs::create_dir_all(base_dir) + .map_err(|error| format!("创建导出目录失败 {}: {error}", base_dir.display()))?; + + for index in 1usize.. { + let candidate = if index == 1 { + base_dir.join(slug) + } else { + base_dir.join(format!("{slug}-{index}")) + }; + if candidate.exists() { + continue; + } + fs::create_dir_all(&candidate) + .map_err(|error| format!("创建导出资源包失败 {}: {error}", candidate.display()))?; + return Ok(candidate); + } + + Err("无法创建唯一的导出目录".to_string()) +} + +async fn download_markdown_bundle_image( + client: &reqwest::Client, + image_url: &str, +) -> Result<(Vec, Option), String> { + if image_url.starts_with("data:") { + return decode_markdown_bundle_data_url(image_url); + } + + let response = client + .get(image_url) + .send() + .await + .map_err(|error| format!("下载图片失败: {error}"))?; + if !response.status().is_success() { + return Err(format!("下载图片失败: HTTP {}", response.status())); + } + + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let bytes = response + .bytes() + .await + .map_err(|error| format!("读取图片内容失败: {error}"))?; + + Ok((bytes.to_vec(), content_type)) +} + +fn decode_markdown_bundle_data_url(image_url: &str) -> Result<(Vec, Option), String> { + let (header, payload) = image_url + .split_once(',') + .ok_or_else(|| "data URL 格式不正确".to_string())?; + if !header.starts_with("data:") { + return Err("仅支持 http(s) 或 data URL".to_string()); + } + + let meta = &header[5..]; + let mut mime_type: Option = None; + let mut is_base64 = false; + if !meta.is_empty() { + let mut parts = meta.split(';'); + if let Some(first) = parts.next() { + mime_type = trim_optional_text(Some(first)); + } + is_base64 = parts.any(|part| part.eq_ignore_ascii_case("base64")); + } + if !is_base64 { + return Err("暂不支持非 base64 的 data URL".to_string()); + } + + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .map_err(|error| format!("解码 data URL 失败: {error}"))?; + Ok((decoded, mime_type)) +} + +fn build_markdown_bundle_metadata_map(bundle: &MaterializedMarkdownBundle) -> Map { + let mut metadata = Map::new(); + metadata.insert( + "siteAdapterExportKind".to_string(), + Value::String(MARKDOWN_BUNDLE_EXPORT_KIND.to_string()), + ); + metadata.insert( + "siteAdapterBundleRelativeDir".to_string(), + Value::String(bundle.bundle_relative_dir.clone()), + ); + metadata.insert( + "siteAdapterBundleMarkdownPath".to_string(), + Value::String(bundle.markdown_relative_path.clone()), + ); + metadata.insert( + "siteAdapterBundleImagesDir".to_string(), + Value::String(bundle.images_relative_dir.clone()), + ); + metadata.insert( + "siteAdapterBundleMetaPath".to_string(), + Value::String(bundle.meta_relative_path.clone()), + ); + metadata.insert( + "siteAdapterImageCount".to_string(), + Value::Number(serde_json::Number::from(bundle.image_count as u64)), + ); + metadata.insert( + "siteAdapterAuthor".to_string(), + bundle + .author + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata.insert( + "siteAdapterPublishedAt".to_string(), + bundle + .published_at + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata.insert( + "siteAdapterSourceUrl".to_string(), + bundle + .source_url + .as_ref() + .map(|value| Value::String(value.clone())) + .unwrap_or(Value::Null), + ); + metadata +} + +fn resolve_workspace_root_path(db: &DbConnection, project_id: &str) -> Option { + WorkspaceManager::new(db.clone()) + .get(&project_id.to_string()) + .ok() + .flatten() + .map(|workspace| workspace.root_path.to_string_lossy().to_string()) +} + +fn build_saved_site_adapter_content( + db: &DbConnection, + content_id: &str, + project_id: &str, + title: &str, + bundle: Option<&MaterializedMarkdownBundle>, +) -> SavedSiteAdapterContent { + SavedSiteAdapterContent { + content_id: content_id.to_string(), + project_id: project_id.to_string(), + title: title.to_string(), + project_root_path: resolve_workspace_root_path(db, project_id), + bundle_relative_dir: bundle.map(|value| value.bundle_relative_dir.clone()), + markdown_relative_path: bundle.map(|value| value.markdown_relative_path.clone()), + images_relative_dir: bundle.map(|value| value.images_relative_dir.clone()), + meta_relative_path: bundle.map(|value| value.meta_relative_path.clone()), + image_count: bundle.map(|value| value.image_count), + } +} + +fn materialize_markdown_bundle( + db: &DbConnection, + project_id: &str, + adapter: &SiteAdapterDefinition, + request: &RunSiteAdapterRequest, + result: &SiteAdapterRunResult, + export: &MarkdownBundleExport, +) -> Result { + let workspace = WorkspaceManager::new(db.clone()) + .get(&project_id.to_string())? + .ok_or_else(|| format!("未找到目标项目: {project_id}"))?; + let workspace_root = workspace.root_path; + fs::create_dir_all(&workspace_root) + .map_err(|error| format!("创建项目目录失败 {}: {error}", workspace_root.display()))?; + + let bundle_title = resolve_markdown_bundle_title(request, adapter, export); + let bundle_slug = resolve_markdown_bundle_slug(&bundle_title, export); + let adapter_dir = workspace_root + .join(SITE_EXPORTS_ROOT_DIR) + .join(sanitize_export_segment(&adapter.name)); + let bundle_dir = allocate_markdown_bundle_export_dir(&adapter_dir, &bundle_slug)?; + let images_dir = bundle_dir.join(MARKDOWN_BUNDLE_IMAGE_DIR); + fs::create_dir_all(&images_dir) + .map_err(|error| format!("创建图片目录失败 {}: {error}", images_dir.display()))?; + + let source_url = trim_optional_text(export.source_url.as_deref()) + .or_else(|| trim_optional_text(result.source_url.as_deref())); + let author = trim_optional_text(export.author.as_deref()); + let published_at = trim_optional_text(export.published_at.as_deref()); + + let adapter_name = adapter.name.clone(); + let skill_title = trim_optional_text(request.skill_title.as_deref()); + let original_title = trim_optional_text(export.title.as_deref()); + let entry_url = result.entry_url.clone(); + + let (rewritten_markdown, image_records) = block_on_site_capability_future(async { + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 LimeSiteAdapter/1.0") + .build() + .map_err(|error| format!("创建图片下载客户端失败: {error}"))?; + let mut markdown = export.markdown.trim().to_string(); + let mut records = Vec::new(); + let mut used_file_names = HashSet::new(); + + for (index, image) in export.images.iter().enumerate() { + let image_url = image.url.trim(); + if image_url.is_empty() { + continue; + } + + match download_markdown_bundle_image(&client, image_url).await { + Ok((bytes, content_type)) => { + let file_name = build_markdown_bundle_image_file_name( + index, + image, + content_type.as_deref(), + &mut used_file_names, + ); + let image_path = images_dir.join(&file_name); + fs::write(&image_path, bytes).map_err(|error| { + format!("写入图片失败 {}: {error}", image_path.display()) + })?; + let markdown_path = path_to_forward_slashes( + Path::new(MARKDOWN_BUNDLE_IMAGE_DIR) + .join(&file_name) + .as_path(), + ); + let project_relative_path = relative_path_from(&workspace_root, &image_path)?; + markdown = markdown.replace(image_url, &markdown_path); + records.push(SavedMarkdownBundleImageRecord { + original_url: image_url.to_string(), + alt: trim_optional_text(image.alt.as_deref()), + suggested_file_name: trim_optional_text( + image.suggested_file_name.as_deref(), + ), + markdown_path: Some(markdown_path), + project_relative_path: Some(project_relative_path), + download_error: None, + }); + } + Err(error) => { + records.push(SavedMarkdownBundleImageRecord { + original_url: image_url.to_string(), + alt: trim_optional_text(image.alt.as_deref()), + suggested_file_name: trim_optional_text( + image.suggested_file_name.as_deref(), + ), + markdown_path: None, + project_relative_path: None, + download_error: Some(error), + }); + } + } + } + + Ok((markdown, records)) + })?; + + let final_markdown = + build_markdown_bundle_document_body(&bundle_title, export, &rewritten_markdown); + let markdown_path = bundle_dir.join("index.md"); + fs::write(&markdown_path, &final_markdown) + .map_err(|error| format!("写入 Markdown 失败 {}: {error}", markdown_path.display()))?; + let markdown_relative_path = relative_path_from(&workspace_root, &markdown_path)?; + let bundle_relative_dir = relative_path_from(&workspace_root, &bundle_dir)?; + let images_relative_dir = relative_path_from(&workspace_root, &images_dir)?; + let meta_path = bundle_dir.join("meta.json"); + let meta_relative_path = relative_path_from(&workspace_root, &meta_path)?; + + let metadata = SavedMarkdownBundleMeta { + export_kind: MARKDOWN_BUNDLE_EXPORT_KIND.to_string(), + adapter_name, + skill_title, + title: bundle_title.clone(), + original_title, + source_url: source_url.clone(), + author: author.clone(), + published_at: published_at.clone(), + entry_url, + exported_at: chrono::Utc::now().to_rfc3339(), + bundle_relative_dir: bundle_relative_dir.clone(), + markdown_relative_path: markdown_relative_path.clone(), + images_relative_dir: images_relative_dir.clone(), + meta_relative_path: meta_relative_path.clone(), + image_count: image_records + .iter() + .filter(|record| record.project_relative_path.is_some()) + .count(), + images: image_records, + }; + let metadata_bytes = serde_json::to_vec_pretty(&metadata) + .map_err(|error| format!("序列化导出元数据失败: {error}"))?; + fs::write(&meta_path, metadata_bytes) + .map_err(|error| format!("写入导出元数据失败 {}: {error}", meta_path.display()))?; + + Ok(MaterializedMarkdownBundle { + title: bundle_title, + markdown: final_markdown, + bundle_relative_dir, + markdown_relative_path, + images_relative_dir, + meta_relative_path, + image_count: metadata.image_count, + source_url, + author, + published_at, + }) +} + pub fn save_site_result_to_project( db: &DbConnection, project_id: &str, @@ -444,6 +1151,42 @@ pub fn save_site_result_to_project( return Err("project_id 不能为空".to_string()); } + if let Some(export) = parse_markdown_bundle_export(result)? { + let mut request_with_save_title = request.clone(); + if let Some(title) = trim_optional_text(save_title) { + request_with_save_title.save_title = Some(title); + } + let bundle = materialize_markdown_bundle( + db, + normalized_project_id, + adapter, + &request_with_save_title, + result, + &export, + )?; + let mut metadata = build_site_result_metadata_map(adapter, result, true); + metadata.extend(build_markdown_bundle_metadata_map(&bundle)); + let manager = ContentManager::new(db.clone()); + let content = manager + .create(ContentCreateRequest { + project_id: normalized_project_id.to_string(), + title: bundle.title.clone(), + content_type: Some(ContentType::Document), + order: None, + body: Some(bundle.markdown.clone()), + metadata: Some(Value::Object(metadata)), + }) + .map_err(|error| format!("保存 Markdown 资源包到项目失败: {error}"))?; + + return Ok(build_saved_site_adapter_content( + db, + &content.id, + &content.project_id, + &content.title, + Some(&bundle), + )); + } + let manager = ContentManager::new(db.clone()); let title = build_site_result_document_title(&adapter.name, save_title); let body = build_site_result_document_body(adapter, request, result); @@ -459,11 +1202,13 @@ pub fn save_site_result_to_project( }) .map_err(|error| format!("保存站点结果到项目失败: {error}"))?; - Ok(SavedSiteAdapterContent { - content_id: content.id, - project_id: content.project_id, - title, - }) + Ok(build_saved_site_adapter_content( + db, + &content.id, + &content.project_id, + &title, + None, + )) } pub fn save_site_result_to_content( @@ -483,12 +1228,40 @@ pub fn save_site_result_to_content( return Err(format!("未找到要写回的内容: {normalized_content_id}")); }; - let body = build_site_result_document_body(adapter, request, result); let mut metadata = existing_content .metadata .and_then(|value| value.as_object().cloned()) .unwrap_or_default(); metadata.extend(build_site_result_metadata_map(adapter, result, false)); + if let Some(export) = parse_markdown_bundle_export(result)? { + let bundle = materialize_markdown_bundle( + db, + &existing_content.project_id, + adapter, + request, + result, + &export, + )?; + metadata.extend(build_markdown_bundle_metadata_map(&bundle)); + let updated = manager.update( + &normalized_content_id.to_string(), + ContentUpdateRequest { + body: Some(bundle.markdown.clone()), + metadata: Some(Value::Object(metadata)), + ..Default::default() + }, + )?; + + return Ok(build_saved_site_adapter_content( + db, + &updated.id, + &updated.project_id, + &updated.title, + Some(&bundle), + )); + } + + let body = build_site_result_document_body(adapter, request, result); let updated = manager.update( &normalized_content_id.to_string(), @@ -499,11 +1272,13 @@ pub fn save_site_result_to_content( }, )?; - Ok(SavedSiteAdapterContent { - content_id: updated.id, - project_id: updated.project_id, - title: updated.title, - }) + Ok(build_saved_site_adapter_content( + db, + &updated.id, + &updated.project_id, + &updated.title, + None, + )) } pub fn save_existing_site_result_to_project( @@ -1119,6 +1894,13 @@ fn build_site_adapter_attached_session_missing_target_message(spec: &SiteAdapter ) } +fn build_site_adapter_attached_session_auto_open_message(spec: &SiteAdapterSpec) -> String { + format!( + "已检测到真实浏览器会话,执行时会自动打开 {} 的目标页面并继续运行。", + spec.domain + ) +} + fn build_site_adapter_attached_session_ready_message(spec: &SiteAdapterSpec) -> String { format!( "已检测到 {} 的真实浏览器页面,Claw 可以直接复用当前会话执行。", @@ -1187,7 +1969,9 @@ async fn resolve_site_adapter_launch_readiness_for_spec( } }; let selected_target = select_existing_session_target(&tabs, &spec.domain).map(|tab| tab.id); - let is_ready = selected_target.is_some(); + let has_selected_target = selected_target.is_some(); + let can_auto_open_target = status_snapshot.control_count > 0; + let is_ready = has_selected_target || can_auto_open_target; return Ok(build_site_adapter_launch_readiness_result( if is_ready { @@ -1199,7 +1983,11 @@ async fn resolve_site_adapter_launch_readiness_for_spec( Some(requested_profile_key), selected_target, if is_ready { - build_site_adapter_attached_session_ready_message(spec) + if has_selected_target { + build_site_adapter_attached_session_ready_message(spec) + } else { + build_site_adapter_attached_session_auto_open_message(spec) + } } else { build_site_adapter_attached_session_missing_target_message(spec) }, @@ -1227,49 +2015,52 @@ async fn resolve_site_adapter_launch_readiness_for_spec( Vec::new() } }; - let Some(selected_target) = select_existing_session_target(&tabs, &spec.domain) else { - continue; - }; + let selected_target = select_existing_session_target(&tabs, &spec.domain).map(|tab| tab.id); attached_candidates.push(SiteAdapterAttachedLaunchCandidate { profile_key: observer.profile_key.clone(), - target_id: selected_target.id, + target_id: selected_target, current_url_matches: observer_matches_site_domain(observer, &spec.domain), saved_existing_session: transport == Some(BrowserProfileTransportKind::ExistingSession), }); } - if let Some(candidate) = attached_candidates - .into_iter() - .max_by_key(|item| (item.current_url_matches, item.saved_existing_session)) - { + if let Some(candidate) = attached_candidates.into_iter().max_by_key(|item| { + ( + item.target_id.is_some(), + item.current_url_matches, + item.saved_existing_session, + ) + }) { + let can_auto_open_target = status_snapshot.control_count > 0; + let is_ready = candidate.target_id.is_some() || can_auto_open_target; return Ok(build_site_adapter_launch_readiness_result( - SiteAdapterLaunchReadinessStatus::Ready, + if is_ready { + SiteAdapterLaunchReadinessStatus::Ready + } else { + SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime + }, spec, Some(candidate.profile_key), - Some(candidate.target_id), - build_site_adapter_attached_session_ready_message(spec), + candidate.target_id, + if is_ready { + if candidate.current_url_matches { + build_site_adapter_attached_session_ready_message(spec) + } else { + build_site_adapter_attached_session_auto_open_message(spec) + } + } else { + build_site_adapter_attached_session_missing_target_message(spec) + }, )); } - let has_attached_observer = status_snapshot.observers.iter().any(|observer| { - profiles - .iter() - .find(|profile| profile.profile_key == observer.profile_key) - .map(|profile| profile.transport_kind != BrowserProfileTransportKind::ManagedCdp) - .unwrap_or(true) - }); - Ok(build_site_adapter_launch_readiness_result( SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime, spec, None, None, - if has_attached_observer { - build_site_adapter_attached_session_missing_target_message(spec) - } else { - build_site_adapter_attached_session_required_message(spec) - }, + build_site_adapter_attached_session_required_message(spec), )) } @@ -1369,7 +2160,16 @@ fn select_existing_session_target( domain: &str, ) -> Option { tabs.iter() - .max_by_key(|tab| (tab_matches_domain(tab, domain), tab.active, -tab.index)) + .filter(|tab| tab_matches_domain(tab, domain)) + .max_by_key(|tab| (tab.active, -tab.index)) + .cloned() +} + +fn select_existing_session_navigation_seed( + tabs: &[ExistingSessionTabRecord], +) -> Option { + tabs.iter() + .max_by_key(|tab| (tab.active, -tab.index)) .cloned() } @@ -1695,12 +2495,12 @@ async fn run_existing_session_adapter( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) { - ExistingSessionTabRecord { + Some(ExistingSessionTabRecord { id: explicit_target_id, index: 0, url: None, active: true, - } + }) } else { let tabs = match load_existing_session_tabs(&profile_key).await { Ok(result) => result, @@ -1716,28 +2516,19 @@ async fn run_existing_session_adapter( ); } }; - let Some(selected_target) = select_existing_session_target(&tabs, &spec.domain) else { - return build_error_result( - spec, - profile_key, - None, - None, - entry_url, - "no_matching_context", - "当前 Chrome 没有匹配的目标站点标签页,请先打开目标站点页面,或手动传入 target_id。", - ); - }; - selected_target + select_existing_session_target(&tabs, &spec.domain) }; let should_skip_navigation = selected_target - .url - .as_deref() + .as_ref() + .and_then(|target| target.url.as_deref()) .map(|current_url| url_matches_expected_entry(current_url, &entry_url)) .unwrap_or(false); - let mut bridged_target_id = Some(selected_target.id.clone()); + let mut bridged_target_id = selected_target.as_ref().map(|target| target.id.clone()); let latest_source_url = if should_skip_navigation { - selected_target.url.clone() + selected_target + .as_ref() + .and_then(|target| target.url.clone()) } else { let navigation_result = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { profile_key: Some(profile_key.clone()), @@ -1771,11 +2562,25 @@ async fn run_existing_session_adapter( .and_then(|data| data.get("tab_id")) .and_then(value_to_string) .or(bridged_target_id.clone()); + if bridged_target_id.is_none() { + bridged_target_id = load_existing_session_tabs(&profile_key) + .await + .ok() + .and_then(|tabs| { + select_existing_session_target(&tabs, &spec.domain) + .or_else(|| select_existing_session_navigation_seed(&tabs)) + }) + .map(|tab| tab.id); + } navigation_result .page_info .as_ref() .and_then(|page| page.url.clone()) - .or(selected_target.url.clone()) + .or_else(|| { + selected_target + .as_ref() + .and_then(|target| target.url.clone()) + }) }; let adapter_output = match execute_bridge_adapter_command(ChromeBridgeCommandRequest { @@ -2647,10 +3452,12 @@ mod tests { use crate::database::schema::create_tables; use crate::workspace::{WorkspaceManager, WorkspaceType}; use lime_core::database::dao::browser_profile::UpsertBrowserProfileInput; + use lime_server::chrome_bridge::ObserverCommandResultPayload; use rusqlite::Connection; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use tempfile::tempdir; + use tokio::sync::mpsc; fn setup_test_db() -> DbConnection { let conn = Connection::open_in_memory().expect("创建内存数据库失败"); @@ -2928,6 +3735,108 @@ mod tests { assert_eq!(selected.id, "tab-2"); } + #[test] + fn should_ignore_non_matching_existing_session_tabs() { + let selected = select_existing_session_target( + &[ExistingSessionTabRecord { + id: "tab-1".to_string(), + index: 0, + url: Some("https://outlook.live.com/mail/".to_string()), + active: true, + }], + "x.com", + ); + + assert!(selected.is_none()); + } + + #[tokio::test] + async fn should_mark_attached_observer_as_ready_when_control_can_auto_open_target_page() { + let db = setup_test_db(); + let spec = find_site_adapter_spec("x/article-export") + .expect("registry should load") + .expect("x adapter should exist"); + let hub = chrome_bridge::chrome_bridge_hub(); + let observer_client_id = format!("observer-test-{}", uuid::Uuid::new_v4()); + let control_client_id = format!("control-test-{}", uuid::Uuid::new_v4()); + let (observer_tx, mut observer_rx) = mpsc::unbounded_channel::(); + let (control_tx, _control_rx) = mpsc::unbounded_channel::(); + + hub.register_observer( + observer_client_id.clone(), + Some("default".to_string()), + Some("Chrome".to_string()), + observer_tx, + ) + .await; + hub.register_control( + control_client_id.clone(), + Some("Chrome".to_string()), + control_tx, + ) + .await; + hub.handle_observer_page_info_update( + &observer_client_id, + "# Outlook\nURL: https://outlook.live.com/mail/".to_string(), + ) + .await; + + let hub_for_task = hub.clone(); + let observer_client_id_for_task = observer_client_id.clone(); + let responder = tokio::spawn(async move { + let message = observer_rx.recv().await.expect("应收到 list_tabs 命令"); + let payload: Value = serde_json::from_str(&message).expect("命令消息应是合法 JSON"); + let data = payload + .get("data") + .and_then(Value::as_object) + .expect("命令消息应包含 data"); + assert_eq!( + data.get("command").and_then(Value::as_str), + Some("list_tabs") + ); + let request_id = data + .get("requestId") + .and_then(Value::as_str) + .expect("应包含 requestId") + .to_string(); + hub_for_task + .handle_observer_command_result( + &observer_client_id_for_task, + ObserverCommandResultPayload { + request_id, + status: "success".to_string(), + message: Some("ok".to_string()), + error: None, + data: Some(json!({ + "tabs": [ + { + "id": "outlook-tab", + "index": 0, + "url": "https://outlook.live.com/mail/", + "active": true + } + ] + })), + }, + ) + .await; + }); + + let readiness = + resolve_site_adapter_launch_readiness_for_spec(&db, &spec, Some("default"), None) + .await + .expect("readiness should resolve"); + + responder.await.expect("bridge responder should finish"); + hub.unregister_observer(&observer_client_id).await; + hub.unregister_control(&control_client_id).await; + + assert_eq!(readiness.status, SiteAdapterLaunchReadinessStatus::Ready); + assert_eq!(readiness.profile_key.as_deref(), Some("default")); + assert_eq!(readiness.target_id, None); + assert!(readiness.message.contains("自动打开")); + } + #[test] fn should_rank_observer_context_before_scope_only_recommendations() { let github = find_site_adapter_spec("github/search") @@ -3295,6 +4204,173 @@ mod tests { assert_eq!(actual_source_kind, expected_source_kind); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_save_markdown_bundle_site_result_to_project_resources() { + let db = setup_test_db(); + let workspace_root = tempdir().expect("创建临时目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "X 长文项目".to_string(), + workspace_root.path().join("x-article-export-project"), + WorkspaceType::General, + ) + .expect("创建测试项目失败"); + let image_url = "data:image/png;base64,iVBORw0KGgo=".to_string(); + let adapter = get_site_adapter("x/article-export").expect("x/article-export should exist"); + let request = SaveSiteAdapterResultRequest { + project_id: Some(workspace.id.clone()), + content_id: None, + save_title: None, + run_request: RunSiteAdapterRequest { + adapter_name: "x/article-export".to_string(), + args: serde_json::json!({ + "url": "https://x.com/GoogleCloudTech/article/2033953579824758855" + }), + profile_key: Some("general_browser_assist".to_string()), + target_id: Some("target-1".to_string()), + timeout_ms: Some(20_000), + content_id: None, + project_id: None, + save_title: None, + require_attached_session: Some(true), + skill_title: Some("X 文章转存".to_string()), + }, + result: SiteAdapterRunResult { + ok: true, + adapter: "x/article-export".to_string(), + domain: "x.com".to_string(), + profile_key: "general_browser_assist".to_string(), + session_id: Some("session-1".to_string()), + target_id: Some("target-1".to_string()), + entry_url: "https://x.com/GoogleCloudTech/article/2033953579824758855".to_string(), + source_url: Some( + "https://x.com/GoogleCloudTech/article/2033953579824758855".to_string(), + ), + data: Some(serde_json::json!({ + "export_kind": "markdown_bundle", + "title": "Google Cloud Agent2Agent 协议简介", + "source_url": "https://x.com/GoogleCloudTech/article/2033953579824758855", + "author": "Google Cloud Tech", + "published_at": "2026-04-07T08:00:00.000Z", + "markdown": format!( + "这是一篇测试长文。\n\n![封面图]({image_url})\n\n结尾总结。" + ), + "images": [ + { + "url": image_url, + "alt": "封面图", + "suggested_file_name": "cover-image.png" + } + ] + })), + error_code: None, + error_message: None, + auth_hint: None, + report_hint: None, + saved_content: None, + saved_project_id: None, + saved_by: None, + save_skipped_project_id: None, + save_skipped_by: None, + save_error_message: None, + }, + }; + + let saved_content = + save_existing_site_result_to_project(&db, request).expect("应保存 Markdown 资源包"); + let manager = ContentManager::new(db.clone()); + let content = manager + .get(&saved_content.content_id) + .expect("读取内容失败") + .expect("内容应存在"); + + assert_eq!(saved_content.project_id, workspace.id); + assert_eq!(saved_content.title, "Google Cloud Agent2Agent 协议简介"); + assert_eq!( + saved_content.project_root_path.as_deref(), + Some(workspace.root_path.to_string_lossy().as_ref()) + ); + assert_eq!(content.title, "Google Cloud Agent2Agent 协议简介"); + assert!(content.body.contains("images/")); + assert!(!content.body.contains("data:image")); + + let metadata = content.metadata.as_ref().expect("应写入 metadata"); + assert_eq!( + metadata.get("siteAdapterExportKind"), + Some(&serde_json::json!("markdown_bundle")) + ); + assert_eq!( + metadata.get("siteAdapterImageCount"), + Some(&serde_json::json!(1)) + ); + + let bundle_dir = metadata + .get("siteAdapterBundleRelativeDir") + .and_then(Value::as_str) + .expect("应记录 bundle 相对目录"); + let markdown_path = metadata + .get("siteAdapterBundleMarkdownPath") + .and_then(Value::as_str) + .expect("应记录 markdown 相对路径"); + assert_eq!( + saved_content.markdown_relative_path.as_deref(), + Some(markdown_path) + ); + let meta_path = metadata + .get("siteAdapterBundleMetaPath") + .and_then(Value::as_str) + .expect("应记录 meta 相对路径"); + assert_eq!( + saved_content.meta_relative_path.as_deref(), + Some(meta_path) + ); + assert_eq!( + saved_content.bundle_relative_dir.as_deref(), + Some(bundle_dir) + ); + let images_relative_dir = metadata + .get("siteAdapterBundleImagesDir") + .and_then(Value::as_str) + .expect("应记录图片目录相对路径"); + assert_eq!( + saved_content.images_relative_dir.as_deref(), + Some(images_relative_dir) + ); + assert_eq!(saved_content.image_count, Some(1)); + + let exported_markdown = std::fs::read_to_string(workspace.root_path.join(markdown_path)) + .expect("导出的 Markdown 应存在"); + let exported_meta = std::fs::read_to_string(workspace.root_path.join(meta_path)) + .expect("导出的 meta.json 应存在"); + let image_dir = workspace.root_path.join(bundle_dir).join("images"); + let image_entries = std::fs::read_dir(&image_dir) + .expect("图片目录应存在") + .collect::, _>>() + .expect("读取图片目录失败"); + + assert!(workspace.root_path.join(bundle_dir).exists()); + assert_eq!(image_entries.len(), 1); + let exported_image_name = image_entries[0] + .path() + .file_name() + .and_then(|value| value.to_str()) + .expect("图片文件名应存在") + .to_string(); + assert!(exported_markdown.contains(&format!("images/{exported_image_name}"))); + assert!(!exported_markdown.contains("data:image")); + assert!(exported_meta.contains("\"export_kind\": \"markdown_bundle\"")); + assert!(exported_meta.contains(&exported_image_name)); + let expected_source_kind = adapter.source_kind.clone().map(Value::String); + assert_eq!( + metadata.get("siteAdapterName"), + Some(&serde_json::json!("x/article-export")) + ); + assert_eq!( + metadata.get("siteAdapterSourceKind"), + expected_source_kind.as_ref() + ); + } + #[test] fn should_reject_saving_failed_site_result() { let db = setup_test_db(); @@ -3410,6 +4486,10 @@ mod tests { Some(EXPLICIT_PROJECT_SAVE_SOURCE) ); assert_eq!(saved_result.save_error_message, None); + assert_eq!( + saved_content.project_root_path.as_deref(), + Some(workspace.root_path.to_string_lossy().as_ref()) + ); assert_eq!(content.project_id, workspace.id); assert_eq!(content.title, "自动保存的 GitHub MCP 搜索结果"); } diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 3e8d3569f..9701ef71a 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -9,11 +9,13 @@ use lime_core::models::parse_skill_manifest_from_content; use lime_core::models::{ 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, + FORM_GENERATE_SKILL_DIRECTORY, IMAGE_GENERATE_SKILL_DIRECTORY, LIBRARY_SKILL_DIRECTORY, + MODAL_RESOURCE_SEARCH_SKILL_DIRECTORY, PDF_READ_SKILL_DIRECTORY, + PRESENTATION_GENERATE_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, + WEBPAGE_GENERATE_SKILL_DIRECTORY, }; const VIDEO_GENERATE_SKILL_CONTENT: &str = @@ -51,6 +53,12 @@ const SITE_SEARCH_SKILL_CONTENT: &str = const PDF_READ_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/pdf_read/SKILL.md"); +const PRESENTATION_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/presentation_generate/SKILL.md"); + +const FORM_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/form_generate/SKILL.md"); + const SUMMARY_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/summary/SKILL.md"); const TRANSLATION_SKILL_CONTENT: &str = @@ -69,6 +77,9 @@ const BUNDLED_SITE_ADAPTER_INDEX_CONTENT: &str = const TYPESETTING_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/typesetting/SKILL.md"); +const WEBPAGE_GENERATE_SKILL_CONTENT: &str = + include_str!("../../resources/default-skills/webpage_generate/SKILL.md"); + const CONTENT_POST_WITH_COVER_SKILL_CONTENT: &str = include_str!("../../resources/default-skills/content_post_with_cover/SKILL.md"); @@ -98,7 +109,7 @@ const SITE_SEARCH_EXTRA_FILES: &[BundledSkillFile] = &[BundledSkillFile { content: SITE_SEARCH_ADAPTER_CATALOG_CONTENT, }]; -fn default_skills() -> [BundledSkillDefinition; 17] { +fn default_skills() -> [BundledSkillDefinition; 20] { [ BundledSkillDefinition { directory: VIDEO_GENERATE_SKILL_DIRECTORY, @@ -160,6 +171,16 @@ fn default_skills() -> [BundledSkillDefinition; 17] { skill_content: PDF_READ_SKILL_CONTENT, extra_files: &[], }, + BundledSkillDefinition { + directory: PRESENTATION_GENERATE_SKILL_DIRECTORY, + skill_content: PRESENTATION_GENERATE_SKILL_CONTENT, + extra_files: &[], + }, + BundledSkillDefinition { + directory: FORM_GENERATE_SKILL_DIRECTORY, + skill_content: FORM_GENERATE_SKILL_CONTENT, + extra_files: &[], + }, BundledSkillDefinition { directory: SUMMARY_SKILL_DIRECTORY, skill_content: SUMMARY_SKILL_CONTENT, @@ -180,6 +201,11 @@ fn default_skills() -> [BundledSkillDefinition; 17] { skill_content: TYPESETTING_SKILL_CONTENT, extra_files: &[], }, + BundledSkillDefinition { + directory: WEBPAGE_GENERATE_SKILL_DIRECTORY, + skill_content: WEBPAGE_GENERATE_SKILL_CONTENT, + extra_files: &[], + }, BundledSkillDefinition { directory: CONTENT_POST_WITH_COVER_SKILL_DIRECTORY, skill_content: CONTENT_POST_WITH_COVER_SKILL_CONTENT, @@ -378,8 +404,9 @@ mod tests { assert!(IMAGE_GENERATE_SKILL_CONTENT .contains("allowed-tools: Bash, lime_create_image_generation_task")); assert!(IMAGE_GENERATE_SKILL_CONTENT - .contains("优先调用 `Bash` 执行 `lime media image generate --json` 创建任务")); - assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("也可使用 `lime task create image --json`")); + .contains("优先调用 `Bash` 执行 `lime media image generate --json` 提交任务")); + assert!(IMAGE_GENERATE_SKILL_CONTENT + .contains("如果当前环境实际走到 `lime task create image --json` 兼容入口")); assert!(LIBRARY_SKILL_CONTENT.contains("name: library")); assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse")); assert!(RESEARCH_SKILL_CONTENT.contains("name: research")); @@ -388,11 +415,15 @@ mod tests { 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!(FORM_GENERATE_SKILL_CONTENT.contains("name: form_generate")); + assert!(FORM_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench")); + assert!(FORM_GENERATE_SKILL_CONTENT.contains("```a2ui")); 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")); + assert!(WEBPAGE_GENERATE_SKILL_CONTENT.contains("name: webpage_generate")); assert!(VIDEO_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench")); assert!(TRANSCRIPTION_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench")); assert!(BROADCAST_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench")); @@ -400,6 +431,7 @@ mod tests { assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("lime_surface: workbench")); assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench")); assert!(TYPESETTING_SKILL_CONTENT.contains("lime_surface: workbench")); + assert!(WEBPAGE_GENERATE_SKILL_CONTENT.contains("lime_surface: workbench")); 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")); diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index dd772dd35..fcb67af75 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.4.0", + "version": "1.5.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 14cf01e0b..287ca3165 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.4.0", + "version": "1.5.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 8219e31ae..66cdb081b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,6 +43,7 @@ import { ComponentDebugOverlay } from "./components/dev"; import { buildClawAgentParams } from "./lib/workspace/navigation"; import { toast } from "sonner"; import { SettingsTabs } from "./types/settings"; +import { hasTauriInvokeCapability } from "./lib/tauri-runtime"; const AppContainer = styled.div` display: flex; @@ -98,6 +99,7 @@ const pageLoadingFallback = ( ); function AppContent() { + const hasTauriDesktopRuntime = hasTauriInvokeCapability(); const [showSplash, setShowSplash] = useState(true); const { currentPage, pageParams, handleNavigate } = useAppNavigation(); const [agentHasMessages, setAgentHasMessages] = useState(false); @@ -210,7 +212,9 @@ function AppContent() { }); const { error: registryError, refresh: _refreshRegistry } = - useRelayRegistry(); + useRelayRegistry({ + autoLoad: hasTauriDesktopRuntime, + }); useAppStartupEffects({ currentPage, registryError, diff --git a/src/assets/entry-surface/capability-agent-teams-lime.png b/src/assets/entry-surface/capability-agent-teams-lime.png new file mode 100644 index 000000000..aef90486f Binary files /dev/null and b/src/assets/entry-surface/capability-agent-teams-lime.png differ diff --git a/src/assets/entry-surface/capability-automations-lime.png b/src/assets/entry-surface/capability-automations-lime.png new file mode 100644 index 000000000..42e3d020d Binary files /dev/null and b/src/assets/entry-surface/capability-automations-lime.png differ diff --git a/src/assets/entry-surface/capability-browser-assist-lime.png b/src/assets/entry-surface/capability-browser-assist-lime.png new file mode 100644 index 000000000..cd7894ec0 Binary files /dev/null and b/src/assets/entry-surface/capability-browser-assist-lime.png differ diff --git a/src/assets/entry-surface/capability-skills-lime.png b/src/assets/entry-surface/capability-skills-lime.png new file mode 100644 index 000000000..65a8a7132 Binary files /dev/null and b/src/assets/entry-surface/capability-skills-lime.png differ diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index fc56f87d1..fc5fb9c20 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -125,13 +125,16 @@ describe("AppSidebar", () => { await flushEffects(); expect(container.textContent).toContain("能力"); - expect(container.textContent).toContain("视频"); expect(container.textContent).toContain("技能"); - expect(container.textContent).toContain("自动化"); - expect(container.textContent).toContain("IM 配置"); + expect(container.textContent).toContain("消息渠道"); + expect(container.textContent).toContain("资料"); + expect(container.textContent).toContain("资料库"); + expect(container.textContent).toContain("灵感库"); + expect(container.textContent).not.toContain("视频"); + expect(container.textContent).not.toContain("自动化"); }); - it("进入 IM 配置页时应高亮对应能力入口并保留能力分组标题", async () => { + it("进入消息渠道页时应高亮对应能力入口并保留能力分组标题", async () => { const container = mountSidebar({ currentPage: "channels", }); @@ -139,7 +142,7 @@ describe("AppSidebar", () => { expect(container.textContent).toContain("能力"); expect( - container.querySelector('button[aria-label="IM 配置"][aria-current="page"]'), + container.querySelector('button[aria-label="消息渠道"][aria-current="page"]'), ).not.toBeNull(); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 12d4b071a..8aab93916 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -44,6 +44,8 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; +import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; interface AppSidebarProps { currentPage: Page; @@ -56,28 +58,17 @@ type SidebarNavSection = SidebarNavSectionDefinition; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; -const SIDEBAR_PLUGIN_FALLBACK_DELAY_MS = 180; +const SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS = 6000; function scheduleSidebarPluginLoad(task: () => void): () => void { - if (typeof window === "undefined") { - return () => undefined; - } + const minimumDelayMs = hasTauriInvokeCapability() + ? SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS + : SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS; - if (typeof window.requestIdleCallback === "function") { - const idleId = window.requestIdleCallback(() => task(), { - timeout: SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS, - }); - return () => { - if (typeof window.cancelIdleCallback === "function") { - window.cancelIdleCallback(idleId); - } - }; - } - - const timeoutId = window.setTimeout(task, SIDEBAR_PLUGIN_FALLBACK_DELAY_MS); - return () => { - window.clearTimeout(timeoutId); - }; + return scheduleMinimumDelayIdleTask(task, { + minimumDelayMs, + idleTimeoutMs: SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS, + }); } const Container = styled.aside<{ @@ -85,31 +76,31 @@ const Container = styled.aside<{ $themeMode: "light" | "dark"; }>` --sidebar-surface-top: ${({ $themeMode }) => - $themeMode === "dark" ? "#15202b" : "#eef6f0"}; + $themeMode === "dark" ? "#15202b" : "#f6fbf4"}; --sidebar-surface-middle: ${({ $themeMode }) => - $themeMode === "dark" ? "#17232d" : "#f5f5ef"}; + $themeMode === "dark" ? "#17232d" : "#f9fcf6"}; --sidebar-surface-bottom: ${({ $themeMode }) => - $themeMode === "dark" ? "#1a2530" : "#f4f8f8"}; + $themeMode === "dark" ? "#1a2530" : "#fbfff5"}; --sidebar-foreground: ${({ $themeMode }) => - $themeMode === "dark" ? "#eef4f7" : "#1f2937"}; + $themeMode === "dark" ? "#eef4f7" : "#1a3b2b"}; --sidebar-muted: ${({ $themeMode }) => - $themeMode === "dark" ? "#a1afbd" : "#60707d"}; + $themeMode === "dark" ? "#a1afbd" : "#6b826b"}; --sidebar-border: ${({ $themeMode }) => - $themeMode === "dark" ? "#2d3a46" : "#d8e4dc"}; + $themeMode === "dark" ? "#2d3a46" : "#e2f0e2"}; --sidebar-divider: ${({ $themeMode }) => - $themeMode === "dark" ? "rgba(148, 163, 184, 0.14)" : "rgba(148, 163, 184, 0.18)"}; + $themeMode === "dark" ? "rgba(148, 163, 184, 0.14)" : "rgba(132, 204, 22, 0.15)"}; --sidebar-hover: ${({ $themeMode }) => - $themeMode === "dark" ? "#22303c" : "#e4ede6"}; + $themeMode === "dark" ? "#22303c" : "#eef7ee"}; --sidebar-active: ${({ $themeMode }) => - $themeMode === "dark" ? "#2a3e3b" : "#d9e9df"}; + $themeMode === "dark" ? "#2a3e3b" : "#e6f8ea"}; --sidebar-active-foreground: ${({ $themeMode }) => - $themeMode === "dark" ? "#dff4ea" : "#1b4332"}; + $themeMode === "dark" ? "#dff4ea" : "#166534"}; --sidebar-search-bg: ${({ $themeMode }) => - $themeMode === "dark" ? "#1f2b36" : "#fbfcf9"}; + $themeMode === "dark" ? "#1f2b36" : "#fcfff9"}; --sidebar-search-hover: ${({ $themeMode }) => - $themeMode === "dark" ? "#24313d" : "#f2f6f2"}; + $themeMode === "dark" ? "#24313d" : "#f4fdf4"}; --sidebar-search-border-hover: ${({ $themeMode }) => - $themeMode === "dark" ? "#3a4a57" : "#cfded4"}; + $themeMode === "dark" ? "#3a4a57" : "#bbf7d0"}; display: flex; flex-direction: column; width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; @@ -139,13 +130,13 @@ const Container = styled.aside<{ background: radial-gradient( circle at top left, - rgba(88, 160, 123, 0.14) 0%, - rgba(88, 160, 123, 0) 44% + rgba(132, 204, 22, 0.12) 0%, + rgba(132, 204, 22, 0) 44% ), radial-gradient( circle at bottom left, - rgba(109, 153, 219, 0.12) 0%, - rgba(109, 153, 219, 0) 36% + rgba(16, 185, 129, 0.1) 0%, + rgba(16, 185, 129, 0) 36% ); pointer-events: none; z-index: 0; diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 315c2fb90..b09a33d60 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -56,6 +56,7 @@ import { getContent, getGeneralWorkbenchDocumentState, ensureWorkspaceReady, + getOrCreateDefaultProject, type Project, } from "@/lib/api/project"; import { @@ -74,6 +75,7 @@ import { setActiveContentTarget } from "@/lib/activeContentTarget"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import { useImageGen } from "@/components/image-gen/useImageGen"; import { resolveMediaGenerationPreference } from "@/lib/mediaGeneration"; +import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import { buildTeamMemoryShadowRequestMetadata, readTeamMemorySnapshot, @@ -90,7 +92,11 @@ import { type LayoutMode, type ThemeType, } from "@/lib/workspace/workbenchContract"; -import { normalizeProjectId } from "./utils/topicProjectResolution"; +import { + isDefaultProjectIdAlias, + normalizeProjectId, +} from "./utils/topicProjectResolution"; +import { isHiddenInternalArtifactPath } from "./utils/internalArtifactVisibility"; import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata"; import { deriveHarnessSessionState } from "./utils/harnessState"; import { @@ -128,6 +134,7 @@ import { mergeThreadItems } from "./utils/threadTimelineView"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; import { asRecord, + GENERAL_BROWSER_ASSIST_ARTIFACT_ID, mergeMessageArtifactsIntoStore, readFirstString, } from "./workspace/browserAssistArtifact"; @@ -159,6 +166,7 @@ import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspace import { buildImageSkillLaunchRequestMetadata } from "./workspace/imageSkillLaunch"; import { useWorkspaceImageTaskPreviewRuntime } from "./workspace/useWorkspaceImageTaskPreviewRuntime"; import { useWorkspaceVideoTaskPreviewRuntime } from "./workspace/useWorkspaceVideoTaskPreviewRuntime"; +import { useWorkspaceVideoTaskActionRuntime } from "./workspace/useWorkspaceVideoTaskActionRuntime"; import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime"; import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore"; import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime"; @@ -178,7 +186,11 @@ import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSess import { useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceGeneralWorkbenchDocumentPersistenceRuntime"; import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions"; import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl"; -import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; +import { + areArtifactProtocolPathsEquivalent, + normalizeArtifactProtocolPath, + resolveArtifactProtocolFilePath, +} from "@/lib/artifact-protocol"; import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation"; import { @@ -203,6 +215,7 @@ import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDia import { AutomationJobDialog } from "@/components/settings-v2/system/automation/AutomationJobDialog"; const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; +const BLANK_HOME_DEFERRED_LOAD_MS = 6_000; function resolveDefaultSelectedArtifact( activeTheme: string, @@ -245,18 +258,11 @@ function resolveVideoCanvasStatusFromPreview( 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( + const normalizedArtifactPath = normalizeArtifactProtocolPath( target.preview.kind === "video_generate" ? null : target.preview.artifactPath || null, @@ -265,16 +271,26 @@ function resolveTaskPreviewArtifact( if (normalizedArtifactPath) { const matchedArtifact = messageArtifacts.find( (artifact) => - normalizeTaskPreviewArtifactPath(resolveArtifactProtocolFilePath(artifact)) === - normalizedArtifactPath, + !isHiddenInternalArtifactPath(resolveArtifactProtocolFilePath(artifact)) && + (areArtifactProtocolPathsEquivalent( + resolveArtifactProtocolFilePath(artifact), + normalizedArtifactPath, + ) || + normalizeArtifactProtocolPath( + resolveArtifactProtocolFilePath(artifact), + ) === normalizedArtifactPath), ); if (matchedArtifact) { return matchedArtifact; } } - return messageArtifacts.length > 0 - ? (messageArtifacts[messageArtifacts.length - 1] ?? null) + const visibleArtifacts = messageArtifacts.filter( + (artifact) => + !isHiddenInternalArtifactPath(resolveArtifactProtocolFilePath(artifact)), + ); + return visibleArtifacts.length > 0 + ? (visibleArtifacts[visibleArtifacts.length - 1] ?? null) : null; } @@ -438,6 +454,7 @@ export function AgentChatWorkspace({ agentEntry === "new-task" && !contentId; const shouldPreserveBlankHomeSurface = shouldPreserveEntryThemeOnHome && normalizedEntryTheme === "general"; + const shouldDeferBlankHomeAuxiliaryLoads = shouldPreserveBlankHomeSurface; const [isInitialContentLoading, setIsInitialContentLoading] = useState( shouldBootstrapCanvasOnEntry, ); @@ -493,6 +510,80 @@ export function AgentChatWorkspace({ lockTheme, ]); + useEffect(() => { + if (!isDefaultProjectIdAlias(externalProjectId) || projectId) { + return; + } + + let cancelled = false; + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "resolveDefaultProjectAlias.start", { + externalProjectId: externalProjectId ?? null, + }); + + void (async () => { + try { + const defaultProject = await getOrCreateDefaultProject(); + let resolvedRootPath = defaultProject.rootPath; + + try { + const ensuredWorkspace = await ensureWorkspaceReady(defaultProject.id); + resolvedRootPath = ensuredWorkspace.rootPath || resolvedRootPath; + } catch (error) { + logAgentDebug( + "AgentChatPage", + "resolveDefaultProjectAlias.ensureWorkspaceReadyError", + { + error, + projectId: defaultProject.id, + }, + { level: "warn" }, + ); + } + + if (cancelled) { + return; + } + + applyProjectSelection(defaultProject.id); + setProject((current) => + current?.id === defaultProject.id && + current.rootPath === resolvedRootPath + ? current + : { + ...defaultProject, + rootPath: resolvedRootPath, + }, + ); + logAgentDebug("AgentChatPage", "resolveDefaultProjectAlias.success", { + durationMs: Date.now() - startedAt, + projectId: defaultProject.id, + rootPath: resolvedRootPath, + }); + } catch (error) { + if (cancelled) { + return; + } + + console.warn("[AgentChatPage] 默认工作区别名解析失败:", error); + logAgentDebug( + "AgentChatPage", + "resolveDefaultProjectAlias.error", + { + durationMs: Date.now() - startedAt, + error, + externalProjectId: externalProjectId ?? null, + }, + { level: "warn" }, + ); + } + })(); + + return () => { + cancelled = true; + }; + }, [applyProjectSelection, externalProjectId, projectId]); + // 画布状态(支持多种画布类型) const [canvasState, setCanvasState] = useState( () => { @@ -563,6 +654,10 @@ export function AgentChatWorkspace({ preferredProviderId: effectiveImageWorkbenchPreference.preferredProviderId, preferredModelId: effectiveImageWorkbenchPreference.preferredModelId, allowFallback: effectiveImageWorkbenchPreference.allowFallback, + providerLoadMode: shouldPreserveBlankHomeSurface ? "deferred" : "immediate", + providerDeferredDelayMs: shouldPreserveBlankHomeSurface + ? BLANK_HOME_DEFERRED_LOAD_MS + : undefined, }); const { availableProviders: imageWorkbenchProviders, @@ -637,7 +732,10 @@ export function AgentChatWorkspace({ skillsLoading, refreshSkills: loadSkills, } = useLimeSkills({ - autoLoad: "immediate", + autoLoad: shouldPreserveBlankHomeSurface ? "deferred" : "immediate", + deferredDelayMs: shouldPreserveBlankHomeSurface + ? BLANK_HOME_DEFERRED_LOAD_MS + : undefined, logScope: "AgentChatPage", onError: (error) => { console.warn("[AgentChatPage] 加载 skills 失败:", error); @@ -648,7 +746,13 @@ export function AgentChatWorkspace({ isLoading: serviceSkillsLoading, error: serviceSkillsError, recordUsage: recordServiceSkillUsage, - } = useServiceSkills(activeTheme === "general"); + } = useServiceSkills({ + enabled: activeTheme === "general", + loadMode: shouldPreserveBlankHomeSurface ? "deferred" : "immediate", + deferredDelayMs: shouldPreserveBlankHomeSurface + ? BLANK_HOME_DEFERRED_LOAD_MS + : undefined, + }); useEffect(() => { if (activeTheme !== "general" || !serviceSkillsError) { @@ -711,6 +815,33 @@ export function AgentChatWorkspace({ }, [setArtifacts], ); + const hasBrowserAssistArtifact = useMemo( + () => + artifacts.some( + (artifact) => + artifact.id === GENERAL_BROWSER_ASSIST_ARTIFACT_ID && + artifact.type === "browser_assist", + ), + [artifacts], + ); + const clearBrowserAssistCanvasArtifact = useCallback(() => { + setArtifacts((currentArtifacts) => { + const nextArtifacts = currentArtifacts.filter( + (artifact) => + !( + artifact.id === GENERAL_BROWSER_ASSIST_ARTIFACT_ID && + artifact.type === "browser_assist" + ), + ); + return nextArtifacts.length === currentArtifacts.length + ? currentArtifacts + : nextArtifacts; + }); + + if (selectedArtifactId === GENERAL_BROWSER_ASSIST_ARTIFACT_ID) { + setSelectedArtifactId(null); + } + }, [selectedArtifactId, setArtifacts, setSelectedArtifactId]); const defaultSelectedArtifact = useMemo( () => resolveDefaultSelectedArtifact(activeTheme, artifacts), [activeTheme, artifacts], @@ -831,17 +962,25 @@ export function AgentChatWorkspace({ setActiveTheme(theme); } - const memory = await getProjectMemory(projectId); - if (cancelled) { - return; + if (!shouldDeferBlankHomeAuxiliaryLoads) { + const memory = await getProjectMemory(projectId); + if (cancelled) { + return; + } + setProjectMemory(memory); + logAgentDebug("AgentChatPage", "loadData.memoryLoaded", { + charactersCount: memory?.characters?.length ?? 0, + durationMs: Date.now() - startedAt, + hasOutline: Boolean(memory?.outline?.length), + projectId, + }); + } else { + setProjectMemory(null); + logAgentDebug("AgentChatPage", "loadData.memoryDeferred", { + durationMs: Date.now() - startedAt, + projectId, + }); } - setProjectMemory(memory); - logAgentDebug("AgentChatPage", "loadData.memoryLoaded", { - charactersCount: memory?.characters?.length ?? 0, - durationMs: Date.now() - startedAt, - hasOutline: Boolean(memory?.outline?.length), - projectId, - }); if (!contentId) { logAgentDebug("AgentChatPage", "loadData.projectOnlyComplete", { @@ -1011,9 +1150,70 @@ export function AgentChatWorkspace({ contentId, lockTheme, initialTheme, + shouldDeferBlankHomeAuxiliaryLoads, shouldPreserveEntryThemeOnHome, ]); + useEffect(() => { + if (!shouldDeferBlankHomeAuxiliaryLoads) { + return; + } + + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + setProjectMemory(null); + return; + } + + let cancelled = false; + const cancelDeferredLoad = scheduleMinimumDelayIdleTask( + () => { + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "loadDeferredMemory.start", { + projectId: normalizedProjectId, + }); + void getProjectMemory(normalizedProjectId) + .then((memory) => { + if (cancelled) { + return; + } + setProjectMemory(memory); + logAgentDebug("AgentChatPage", "loadDeferredMemory.success", { + charactersCount: memory?.characters?.length ?? 0, + durationMs: Date.now() - startedAt, + hasOutline: Boolean(memory?.outline?.length), + projectId: normalizedProjectId, + }); + }) + .catch((error) => { + if (cancelled) { + return; + } + console.warn("[AgentChatPage] 延后加载项目 Memory 失败:", error); + logAgentDebug( + "AgentChatPage", + "loadDeferredMemory.error", + { + durationMs: Date.now() - startedAt, + error, + projectId: normalizedProjectId, + }, + { level: "warn" }, + ); + }); + }, + { + minimumDelayMs: BLANK_HOME_DEFERRED_LOAD_MS, + idleTimeoutMs: 1_500, + }, + ); + + return () => { + cancelled = true; + cancelDeferredLoad(); + }; + }, [projectId, shouldDeferBlankHomeAuxiliaryLoads]); + useEffect(() => { if (!shouldBootstrapCanvasOnEntry) { return; @@ -1039,43 +1239,68 @@ export function AgentChatWorkspace({ const normalizedId = normalizeProjectId(projectId); if (!normalizedId) return; - const startedAt = Date.now(); - logAgentDebug("AgentChatPage", "workspaceCheck.start", { - projectId: normalizedId, - }); - ensureWorkspaceReady(normalizedId) - .then(({ repaired, rootPath }) => { - if (repaired) { - recordWorkspaceRepair({ - workspaceId: normalizedId, - rootPath, - source: "agent_chat_page", - }); - console.info("[AgentChatPage] workspace 目录已自动修复:", rootPath); - } - logAgentDebug("AgentChatPage", "workspaceCheck.success", { - durationMs: Date.now() - startedAt, - projectId: normalizedId, - repaired, - rootPath, - }); - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err); - console.warn("[AgentChatPage] workspace 目录检查失败:", message); - logAgentDebug( - "AgentChatPage", - "workspaceCheck.error", - { - durationMs: Date.now() - startedAt, - error: err, - projectId: normalizedId, - }, - { level: "warn" }, - ); - setWorkspaceHealthError(true); + let cancelled = false; + const runWorkspaceCheck = () => { + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "workspaceCheck.start", { + projectId: normalizedId, }); - }, [projectId]); + void ensureWorkspaceReady(normalizedId) + .then(({ repaired, rootPath }) => { + if (cancelled) { + return; + } + if (repaired) { + recordWorkspaceRepair({ + workspaceId: normalizedId, + rootPath, + source: "agent_chat_page", + }); + console.info("[AgentChatPage] workspace 目录已自动修复:", rootPath); + } + logAgentDebug("AgentChatPage", "workspaceCheck.success", { + durationMs: Date.now() - startedAt, + projectId: normalizedId, + repaired, + rootPath, + }); + }) + .catch((err: unknown) => { + if (cancelled) { + return; + } + const message = err instanceof Error ? err.message : String(err); + console.warn("[AgentChatPage] workspace 目录检查失败:", message); + logAgentDebug( + "AgentChatPage", + "workspaceCheck.error", + { + durationMs: Date.now() - startedAt, + error: err, + projectId: normalizedId, + }, + { level: "warn" }, + ); + setWorkspaceHealthError(true); + }); + }; + + const cancelDeferredCheck = shouldDeferBlankHomeAuxiliaryLoads + ? scheduleMinimumDelayIdleTask(runWorkspaceCheck, { + minimumDelayMs: BLANK_HOME_DEFERRED_LOAD_MS, + idleTimeoutMs: 1_500, + }) + : null; + + if (!cancelDeferredCheck) { + runWorkspaceCheck(); + } + + return () => { + cancelled = true; + cancelDeferredCheck?.(); + }; + }, [projectId, shouldDeferBlankHomeAuxiliaryLoads]); useEffect(() => { const normalizedProjectId = normalizeProjectId(projectId); @@ -1083,21 +1308,10 @@ export function AgentChatWorkspace({ return; } - if (project && project.id === normalizedProjectId && !project.isArchived) { - rememberProjectId(normalizedProjectId); + if (project?.id === normalizedProjectId && project.isArchived) { return; } - - getProject(normalizedProjectId) - .then((resolvedProject) => { - if (!resolvedProject || resolvedProject.isArchived) { - return; - } - rememberProjectId(resolvedProject.id); - }) - .catch((error) => { - console.warn("[AgentChatPage] 记录最近项目失败:", error); - }); + rememberProjectId(normalizedProjectId); }, [project, projectId, rememberProjectId]); const chatMode = useMemo( @@ -1198,6 +1412,12 @@ export function AgentChatWorkspace({ }, workspaceId: projectId ?? "", disableSessionRestore: shouldDisableSessionRestore, + initialTopicsLoadMode: shouldPreserveBlankHomeSurface + ? "deferred" + : "immediate", + initialTopicsDeferredDelayMs: shouldPreserveBlankHomeSurface + ? BLANK_HOME_DEFERRED_LOAD_MS + : undefined, getSyncedSessionRecentPreferences, }); activeSessionIdRef.current = sessionId; @@ -1439,6 +1659,31 @@ export function AgentChatWorkspace({ }, [], ); + useEffect(() => { + const normalizedSessionId = sessionId?.trim(); + const localSessionKey = localImageWorkbenchSessionKeyRef.current; + if (!normalizedSessionId || normalizedSessionId === localSessionKey) { + return; + } + + const localState = imageWorkbenchBySessionId[localSessionKey]; + if (!localState) { + return; + } + + updateImageWorkbenchStateForSession( + normalizedSessionId, + (current) => current, + { + fallbackState: localState, + removeSessionKeys: [localSessionKey], + }, + ); + }, [ + imageWorkbenchBySessionId, + sessionId, + updateImageWorkbenchStateForSession, + ]); const teamSessionRuntime = useWorkspaceTeamSessionRuntime({ sessionId, topics, @@ -2224,7 +2469,6 @@ export function AgentChatWorkspace({ const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({ cancelImageTask: cancelMediaTaskArtifact, contentId, - createFreshSession, createImageGenerationTask: createImageGenerationTaskArtifact, getImageTask: getMediaTaskArtifact, currentImageWorkbenchState, @@ -2240,7 +2484,6 @@ export function AgentChatWorkspace({ setCanvasState, setInput, setLayoutMode, - updateImageWorkbenchStateForSession, updateCurrentImageWorkbenchState, }); const { handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest } = @@ -2436,7 +2679,7 @@ export function AgentChatWorkspace({ [displayMessages], ); - // 布局层按实际展示内容判断,避免 browser preflight / bootstrap 预览仍被视为空白态。 + // 布局层按实际展示内容判断,避免 bootstrap 预览等临时消息仍被视为空白态。 const hasDisplayMessages = displayMessages.length > 0; const hasMessages = hasDisplayMessages; const effectiveShowChatPanel = @@ -2496,11 +2739,13 @@ export function AgentChatWorkspace({ showTeamWorkspaceBoard: teamSessionRuntime.showTeamWorkspaceBoard, hasCurrentCanvasArtifact: Boolean(currentCanvasArtifact), currentCanvasArtifactType: currentCanvasArtifact?.type, + hasBrowserAssistArtifact, currentImageWorkbenchActive: currentImageWorkbenchState.active, onHasMessagesChange, dismissActiveTeamWorkbenchAutoOpen, suppressGeneralCanvasArtifactAutoOpen, suppressBrowserAssistCanvasAutoOpen, + clearBrowserAssistCanvasArtifact, setShowSidebar, setLayoutMode, setGeneralCanvasState, @@ -2693,6 +2938,7 @@ export function AgentChatWorkspace({ aspectRatio: normalizeVideoAspectRatio(preview.aspectRatio), resolution: normalizeVideoResolution(preview.resolution), status: resolveVideoCanvasStatusFromPreview(target), + selectedTaskId: preview.taskId, videoUrl: preview.videoUrl || undefined, errorMessage: preview.status === "failed" || preview.status === "cancelled" @@ -2709,14 +2955,14 @@ export function AgentChatWorkspace({ return; } - const normalizedArtifactPath = normalizeTaskPreviewArtifactPath( + const normalizedArtifactPath = normalizeArtifactProtocolPath( target.preview.artifactPath || null, ); if (normalizedArtifactPath) { const matchedTaskFile = taskFiles.find( (file) => - normalizeTaskPreviewArtifactPath(file.name) === - normalizedArtifactPath, + areArtifactProtocolPathsEquivalent(file.name, normalizedArtifactPath) || + normalizeArtifactProtocolPath(file.name) === normalizedArtifactPath, ); if (matchedTaskFile?.content?.trim()) { handleWorkspaceFileClick(matchedTaskFile.name, matchedTaskFile.content); @@ -2811,6 +3057,8 @@ export function AgentChatWorkspace({ contentId, projectRootPath: project?.rootPath || null, restoreFromWorkspace: shouldRestoreImageTasksFromWorkspace, + messages, + currentImageWorkbenchState, canvasState, setCanvasState, setChatMessages, @@ -2820,6 +3068,11 @@ export function AgentChatWorkspace({ messages, setChatMessages, }); + useWorkspaceVideoTaskActionRuntime({ + projectId, + contentId, + setChatMessages, + }); const shellChromeRuntime = useWorkspaceShellChromeRuntime({ agentEntry, @@ -3028,6 +3281,12 @@ export function AgentChatWorkspace({ }); const workspaceShellSceneRuntime = useWorkspaceConversationShellSceneRuntime({ + messageListEmptyStateVariant: + agentEntry === "claw" ? "task-center" : "default", + navbarContextVariant: + agentEntry === "claw" ? "task-center" : "default", + sidebarContextVariant: + agentEntry === "claw" ? "task-center" : "default", navigationActions, inputbarScene, canvasScene, diff --git a/src/components/agent/chat/commands/executor.ts b/src/components/agent/chat/commands/executor.ts index a40bf5f90..c53552fd9 100644 --- a/src/components/agent/chat/commands/executor.ts +++ b/src/components/agent/chat/commands/executor.ts @@ -32,11 +32,13 @@ export async function executeCodexSlashCommand( createFreshSession, appendAssistantMessage, notifyInfo, + onExecutedCommand, } = params; switch (command.definition.key) { case "compact": await compactSession(); + onExecutedCommand?.(command); return true; case "clear": if ( @@ -49,6 +51,7 @@ export async function executeCodexSlashCommand( return true; } clearMessages({ toastMessage: "已清空当前任务" }); + onExecutedCommand?.(command); return true; case "new": { if ( @@ -62,13 +65,16 @@ export async function executeCodexSlashCommand( } const sessionName = command.userInput.trim() || undefined; await createFreshSession(sessionName); + onExecutedCommand?.(command); return true; } case "help": appendAssistantMessage(buildCodexSlashHelpMessage()); + onExecutedCommand?.(command); return true; case "status": appendAssistantMessage(buildCodexSlashStatusMessage(statusSnapshot)); + onExecutedCommand?.(command); return true; case "model": if (command.userInput.trim()) { @@ -76,6 +82,7 @@ export async function executeCodexSlashCommand( return true; } appendAssistantMessage(buildCodexSlashModelMessage(statusSnapshot)); + onExecutedCommand?.(command); return true; case "review": case "diff": @@ -83,6 +90,7 @@ export async function executeCodexSlashCommand( const prompt = buildCodexSlashPrompt(command); if (prompt) { await sendPrompt(prompt); + onExecutedCommand?.(command); return true; } return false; diff --git a/src/components/agent/chat/commands/types.ts b/src/components/agent/chat/commands/types.ts index afb4eff0e..21dee389d 100644 --- a/src/components/agent/chat/commands/types.ts +++ b/src/components/agent/chat/commands/types.ts @@ -45,4 +45,5 @@ export interface ExecuteCodexSlashCommandParams { appendAssistantMessage: (content: string) => void; notifyInfo: (message: string) => void; notifySuccess: (message: string) => void; + onExecutedCommand?: (command: ParsedCodexSlashCommand) => void; } diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 71a70e597..1e8f3d10f 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -379,6 +379,22 @@ describe("AgentThreadTimeline", () => { ); }); + it("不应把 .lime/tasks 下的内部任务快照 JSON 渲染到时间线里", () => { + const container = renderTimeline([ + createFileArtifactItem({ + id: "artifact-hidden-task-json", + path: ".lime/tasks/image_generate/task-image-1.json", + content: "{\"status\":\"running\"}", + metadata: {}, + }), + ]); + + expect( + container.querySelector('[data-testid="timeline-file-artifact-card"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("task-image-1.json"); + }); + it("收到 timeline 聚焦请求时应自动展开并高亮目标项", () => { const container = renderTimeline( [ diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 9f39389c7..1bd38f285 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -27,6 +27,7 @@ import { import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI"; import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder"; +import { isHiddenInternalArtifactPath } from "../utils/internalArtifactVisibility"; import { parseAIResponse } from "@/lib/workspace/a2ui"; import type { A2UIResponse } from "@/lib/workspace/a2ui"; import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/lib/workspace/a2ui"; @@ -801,6 +802,10 @@ function renderGroupItemDetails( } if (item.type === "file_artifact") { + if (isHiddenInternalArtifactPath(item.path)) { + return null; + } + return ( = ({ const visibleItems = useMemo( () => items.filter( - (item) => item.type !== "user_message" && item.type !== "agent_message", + (item) => + item.type !== "user_message" && + item.type !== "agent_message" && + !(item.type === "file_artifact" && isHiddenInternalArtifactPath(item.path)), ), [items], ); diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index 8f1cae64a..0b390744b 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -141,6 +141,31 @@ describe("ChatNavbar", () => { expect(container.querySelector('[data-testid="project-selector"]')).toBeNull(); }); + it("任务中心顶栏应展示轻量上下文提示", () => { + const container = renderChatNavbar({ + entryContextLabel: "任务中心", + entryContextHint: "回到进行中的任务、旧历史和最近工作现场。", + }); + + expect(container.textContent).toContain("任务中心"); + expect(container.textContent).toContain( + "回到进行中的任务、旧历史和最近工作现场。", + ); + }); + + it("紧凑顶栏应只保留任务中心标签,不额外展开说明", () => { + const container = renderChatNavbar({ + chrome: "workspace-compact", + entryContextLabel: "任务中心", + entryContextHint: "回到进行中的任务、旧历史和最近工作现场。", + }); + + expect(container.textContent).toContain("任务中心"); + expect(container.textContent).not.toContain( + "回到进行中的任务、旧历史和最近工作现场。", + ); + }); + it("点击顶栏按钮后应切换 Harness 面板显隐", () => { function HarnessToggleHarness() { const [visible, setVisible] = useState(false); diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 6bf5b53dc..c2ba4ee7a 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -17,6 +17,8 @@ import { Navbar } from "../styles"; interface ChatNavbarProps { isRunning: boolean; chrome?: "full" | "workspace-compact"; + entryContextLabel?: string; + entryContextHint?: string; onToggleHistory: () => void; showHistoryToggle?: boolean; onToggleFullscreen: () => void; @@ -59,6 +61,8 @@ const toolbarTextButtonClassName = export const ChatNavbar: React.FC = ({ isRunning: _isRunning, chrome = "full", + entryContextLabel, + entryContextHint, onToggleHistory, showHistoryToggle = true, onToggleFullscreen: _onToggleFullscreen, @@ -111,6 +115,7 @@ export const ChatNavbar: React.FC = ({ const compactProjectSelectorClassName = isWorkspaceCompact ? "min-w-[184px] max-w-[248px]" : "min-w-[196px] max-w-[280px]"; + const showEntryContext = Boolean(entryContextLabel); return ( @@ -194,6 +199,26 @@ export const ChatNavbar: React.FC = ({ ) : null} ) : null} + + {showEntryContext ? ( +
+
+ + {entryContextLabel} + + {!isWorkspaceCompact && entryContextHint ? ( +

+ {entryContextHint} +

+ ) : null} +
+
+ ) : null}
diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index c31078091..87bc29d1e 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -125,6 +125,71 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("任务一"); }); + it("任务中心侧栏空态应展示回访型文案", () => { + const container = renderSidebar({ + contextVariant: "task-center", + topics: [], + currentTopicId: null, + }); + + expect(container.textContent).toContain("工作现场"); + expect(container.textContent).toContain( + "回到进行中的任务、旧历史和最近工作现场。", + ); + expect(container.textContent).toContain("还没有进行中的任务"); + expect(container.textContent).toContain( + "从“新建任务”开始也很自然,创建后会在这里继续回访。", + ); + }); + + it("任务中心侧栏应使用回访型任务分组标题", () => { + const now = Date.now(); + const container = renderSidebar({ + contextVariant: "task-center", + currentTopicId: null, + topics: [ + { + ...defaultTopics[0], + id: "topic-running", + title: "进行中任务", + updatedAt: new Date(now), + status: "running", + sourceSessionId: "topic-running", + }, + { + ...defaultTopics[0], + id: "topic-waiting", + title: "待继续任务", + updatedAt: new Date(now - 1_000), + status: "waiting", + statusReason: "user_action", + sourceSessionId: "topic-waiting", + }, + { + ...defaultTopics[0], + id: "topic-recent", + title: "最近回访任务", + updatedAt: new Date(now - 2_000), + status: "done", + sourceSessionId: "topic-recent", + }, + { + ...defaultTopics[0], + id: "topic-older", + title: "更早任务", + updatedAt: new Date(now - 1000 * 60 * 60 * 24 * 5), + status: "done", + sourceSessionId: "topic-older", + }, + ], + }); + + expect(container.textContent).toContain("正在推进"); + expect(container.textContent).toContain("等你继续"); + expect(container.textContent).toContain("最近回访"); + expect(container.textContent).toContain("更早记录"); + }); + it("Team Runtime 和任务列表应处于同一滚动区域", () => { const container = renderSidebar({ childSubagentSessions: [ diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index 7e2fd668a..a758a74db 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -90,6 +90,8 @@ type TaskSectionKey = | "recent" | "older"; +type ChatSidebarContextVariant = "default" | "task-center"; + interface TaskCardViewModel { id: string; title: string; @@ -111,6 +113,7 @@ interface TaskSection { } interface ChatSidebarProps { + contextVariant?: ChatSidebarContextVariant; onNewChat: () => void; topics: Topic[]; currentTopicId: string | null; @@ -267,7 +270,10 @@ function resolveTaskStatus(params: { }; } -function buildTaskSections(items: TaskCardViewModel[]) { +function buildTaskSections( + items: TaskCardViewModel[], + contextVariant: ChatSidebarContextVariant, +) { const now = Date.now(); const running: TaskCardViewModel[] = []; const waiting: TaskCardViewModel[] = []; @@ -297,11 +303,26 @@ function buildTaskSections(items: TaskCardViewModel[]) { older.push(item); } + const titleSet = + contextVariant === "task-center" + ? { + running: "正在推进", + waiting: "等你继续", + recent: "最近回访", + older: "更早记录", + } + : { + running: "进行中", + waiting: "待处理", + recent: "最近完成", + older: "更早任务", + }; + return [ - { key: "running", title: "进行中", items: sortTaskItems(running) }, - { key: "waiting", title: "待处理", items: sortTaskItems(waiting) }, - { key: "recent", title: "最近完成", items: sortTaskItems(recent) }, - { key: "older", title: "更早任务", items: sortTaskItems(older) }, + { key: "running", title: titleSet.running, items: sortTaskItems(running) }, + { key: "waiting", title: titleSet.waiting, items: sortTaskItems(waiting) }, + { key: "recent", title: titleSet.recent, items: sortTaskItems(recent) }, + { key: "older", title: titleSet.older, items: sortTaskItems(older) }, ] satisfies TaskSection[]; } @@ -407,6 +428,7 @@ function buildCollapsedTeamSummary( } export const ChatSidebar: React.FC = ({ + contextVariant = "default", onNewChat, topics, currentTopicId, @@ -602,11 +624,25 @@ export const ChatSidebar: React.FC = ({ }, [searchKeyword, statusFilter, taskItems]); const sections = useMemo( - () => buildTaskSections(filteredTaskItems), - [filteredTaskItems], + () => buildTaskSections(filteredTaskItems, contextVariant), + [contextVariant, filteredTaskItems], ); const hasAnyTasks = topics.length > 0; const hasFilteredResults = filteredTaskItems.length > 0; + const taskHeadingLabel = + contextVariant === "task-center" ? "工作现场" : "任务"; + const taskHeadingHint = + contextVariant === "task-center" + ? "回到进行中的任务、旧历史和最近工作现场。" + : null; + const emptyStateTitle = + contextVariant === "task-center" ? "还没有进行中的任务" : "还没有任务"; + const emptyStateDescription = + contextVariant === "task-center" + ? "从“新建任务”开始也很自然,创建后会在这里继续回访。" + : "从“新建任务”开始输入需求,创建后会出现在这里。"; + const olderSectionMoreLabel = + contextVariant === "task-center" ? "查看更多旧历史" : "查看更多历史任务"; useEffect(() => { if (editingTopicId && editInputRef.current) { @@ -1024,16 +1060,25 @@ export const ChatSidebar: React.FC = ({
-
- 任务 -
-
- {searchKeyword.trim() - ? `${filteredTaskItems.length} 条结果` - : `${topics.length} 条`} +
+
+
+ {taskHeadingLabel} +
+ {taskHeadingHint ? ( +

+ {taskHeadingHint} +

+ ) : null} +
+
+ {searchKeyword.trim() + ? `${filteredTaskItems.length} 个结果` + : `${topics.length} 个任务`} +
@@ -1043,10 +1088,10 @@ export const ChatSidebar: React.FC = ({
- 还没有任务 + {emptyStateTitle}

- 从“新建任务”开始输入需求,创建后会出现在这里。 + {emptyStateDescription}

) : !hasFilteredResults ? ( @@ -1309,7 +1354,7 @@ export const ChatSidebar: React.FC = ({ onClick={() => setShowAllOlder(true)} className="w-full rounded-2xl border border-dashed border-slate-200 bg-white/75 px-3 py-2 text-sm font-medium text-slate-500 transition hover:border-slate-300 hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300 dark:hover:border-white/20 dark:hover:text-white" > - 查看更多历史任务 + {olderSectionMoreLabel} ) : null} diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 8b2d216b6..576821549 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -237,7 +237,7 @@ function createGithubSearchServiceSkill(): ServiceSkillHomeItem { } describe("EmptyState", () => { - it("新建任务首页应恢复 slogan,而不是任务引导标题", async () => { + it("首页应以 slogan 作为主视觉并保留创作语义", async () => { const container = renderEmptyState({ activeTheme: "general", }); @@ -246,10 +246,11 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("新建任务"); + expect(container.textContent).toContain("创作"); expect(container.textContent).toContain("青柠一下,灵感即来"); - expect(container.textContent).toContain("从一句想法,到成稿、成图、成片、成事。"); - expect(container.textContent).not.toContain("开始一个新任务"); + expect(container.textContent).toContain("说一句目标,剩下的交给 Lime。"); + expect(container.textContent).toContain("成功做法会沉淀成技能"); + expect(container.textContent).not.toContain("新建任务"); }); it("通用首页应展示推荐方案并替换旧快速启动内容", async () => { @@ -263,17 +264,17 @@ describe("EmptyState", () => { }); expect(container.textContent).toContain("推荐方案"); - expect(container.textContent).toContain("浏览器协助办事"); expect(container.textContent).toContain("网页研究简报"); expect(container.textContent).toContain("内容主稿生成"); expect(container.textContent).toContain("前端概念方案"); expect(container.textContent).toContain("演示提纲草案"); + expect(container.textContent).toContain("浏览器协助办事"); expect(container.textContent).toContain("多代理拆任务"); expect(container.textContent).not.toContain("生成配图"); expect(container.textContent).not.toContain("Team 冒烟测试"); }); - it("通用首页能力卡应继续渲染 4 张能力占位图", async () => { + it("通用首页应继续渲染 4 个带视觉预览的支撑能力入口", async () => { const container = renderEmptyState({ activeTheme: "general", onLaunchBrowserAssist: vi.fn(), @@ -283,18 +284,53 @@ describe("EmptyState", () => { await Promise.resolve(); }); + const expectedLabels = [ + "技能", + "自动化", + "多代理", + "浏览器接入", + ]; const expectedAlts = [ "技能能力卡占位图", "自动化能力卡占位图", "多代理协作能力卡占位图", - "浏览器工作台能力卡占位图", + "浏览器接入能力卡占位图", ]; + for (const label of expectedLabels) { + expect(container.textContent).toContain(label); + } + for (const alt of expectedAlts) { const image = container.querySelector(`img[alt="${alt}"]`); expect(image).toBeTruthy(); expect(image?.getAttribute("src")).toBeTruthy(); } + + expect(container.textContent).not.toContain("连接浏览器"); + }); + + it("点击浏览器能力卡图片应触发浏览器接入", async () => { + const onLaunchBrowserAssist = vi.fn(); + const container = renderEmptyState({ + activeTheme: "general", + onLaunchBrowserAssist, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const mediaButton = container.querySelector( + 'button[aria-label="连接浏览器"]', + ) as HTMLButtonElement | null; + expect(mediaButton).toBeTruthy(); + + act(() => { + mediaButton?.click(); + }); + + expect(onLaunchBrowserAssist).toHaveBeenCalledTimes(1); }); it("点击网页研究简报应开启联网搜索并记录最近使用", async () => { @@ -354,7 +390,7 @@ describe("EmptyState", () => { ); }); - it("点击浏览器协助办事应打开浏览器工作台并写入起始动作", async () => { + it("点击浏览器协助办事应触发浏览器接入并写入起始动作", async () => { const setInput = vi.fn<(value: string) => void>(); const onLaunchBrowserAssist = vi.fn(); const container = renderEmptyState({ @@ -849,9 +885,9 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const launchButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("打开浏览器工作台"), - ); + const launchButton = container.querySelector( + 'button[aria-label="连接浏览器"]', + ) as HTMLButtonElement | null; expect(launchButton).toBeTruthy(); act(() => { diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 3054d97ca..1ca48db4d 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -29,7 +29,6 @@ import { EmptyStateQuickActions } from "./EmptyStateQuickActions"; import { EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME, EMPTY_STATE_PAGE_CONTAINER_CLASSNAME, - EMPTY_STATE_SECONDARY_ACTION_BUTTON_CLASSNAME, } from "./emptyStateSurfaceTokens"; import { useActiveSkill } from "../skill-selection/useActiveSkill"; import type { SkillSelectionSourceProps } from "../skill-selection/skillSelectionBindings"; @@ -51,10 +50,10 @@ import { getSiteSkillAutoLaunchExample, hasAutoLaunchableSiteSkill, } from "../service-skills/siteSkillExamplePrompts"; -import capabilitySkillsPlaceholder from "@/assets/entry-surface/capability-skills-placeholder.svg?url"; -import capabilityAutomationsPlaceholder from "@/assets/entry-surface/capability-automations-placeholder.svg?url"; -import capabilityAgentTeamsPlaceholder from "@/assets/entry-surface/capability-agent-teams-placeholder.svg?url"; -import capabilityBrowserAssistPlaceholder from "@/assets/entry-surface/capability-browser-assist-placeholder.svg?url"; +import capabilitySkillsPlaceholder from "@/assets/entry-surface/capability-skills-lime.png"; +import capabilityAutomationsPlaceholder from "@/assets/entry-surface/capability-automations-lime.png"; +import capabilityAgentTeamsPlaceholder from "@/assets/entry-surface/capability-agent-teams-lime.png"; +import capabilityBrowserAssistPlaceholder from "@/assets/entry-surface/capability-browser-assist-lime.png"; const contentReveal = keyframes` from { @@ -76,6 +75,9 @@ const PageContainer = styled.div.attrs({ const ContentWrapper = styled.div.attrs({ className: EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME, })` + display: flex; + flex: 1 1 auto; + min-height: 100%; animation: ${contentReveal} 560ms cubic-bezier(0.22, 1, 0.36, 1) both; @media (prefers-reduced-motion: reduce) { @@ -83,6 +85,92 @@ const ContentWrapper = styled.div.attrs({ } `; +const RecommendationShelf = styled.div` + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + padding: 0 0.3rem 0.1rem; +`; + +const RecommendationShelfHeader = styled.div` + display: flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +`; + +const RecommendationShelfList = styled.div` + display: flex; + align-items: center; + gap: 0; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const RecommendationShelfRow = styled.div` + position: relative; + display: inline-flex; + align-items: center; + gap: 0.3rem; + flex-shrink: 0; + + & + & { + margin-left: 0.55rem; + padding-left: 0.55rem; + } + + & + &::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + width: 1px; + height: 0.72rem; + transform: translateY(-50%); + background: rgba(203, 213, 225, 0.9); + } +`; + +const RecommendationShelfButton = styled.button` + display: inline-flex; + align-items: center; + gap: 0.3rem; + border: none; + background: transparent; + padding: 0; + text-align: left; + color: rgb(100 116 139); + transition: color 180ms ease; + + &:hover { + color: rgb(15 23 42); + } +`; + +const RecommendationShelfTitle = styled.span` + white-space: nowrap; + font-size: 12px; + font-weight: 500; + line-height: 1.55; + color: currentColor; +`; + +const RecommendationShelfMeta = styled.span` + font-size: 9.5px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.01em; + color: rgb(5 150 105 / 0.88); +`; + interface EmptyStateProps extends SkillSelectionSourceProps { input: string; setInput: (value: string) => void; @@ -160,11 +248,10 @@ const THEME_WORKBENCH_COPY: Record< } > = { general: { - title: "青柠一下,灵感即来", - description: - "从一句想法,到成稿、成图、成片、成事。", + title: "", + description: "说一句目标,剩下的交给 Lime。", supportingDescription: - "Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只停留在一次性提问。", + "文案、图片、视频、搜索、整理与网页执行可以围绕同一目标持续推进;成功做法会沉淀成技能,偏好、参考与成果会逐渐沉淀成个人资产。", }, }; @@ -411,7 +498,7 @@ export const EmptyState: React.FC = ({ }; const planEnabled = executionStrategy === "code_orchestrated"; - const executionModeLabel = planEnabled ? "Plan 已开启" : "直接执行"; + const executionModeLabel = planEnabled ? "编排模式已开启" : "直接开工"; const workbenchCopy = THEME_WORKBENCH_COPY[activeTheme] || THEME_WORKBENCH_COPY.general; @@ -480,17 +567,17 @@ export const EmptyState: React.FC = ({ const badges: Array<{ key: string; label: string; - tone?: "slate" | "sky" | "emerald" | "amber"; + tone?: "slate" | "sky" | "emerald" | "amber" | "lime"; }> = [ { key: "theme", label: GENERAL_CATEGORY_LABEL, - tone: "slate", + tone: "lime", }, { key: "execution", label: executionModeLabel, - tone: "sky", + tone: "lime", }, ]; @@ -498,7 +585,7 @@ export const EmptyState: React.FC = ({ badges.push({ key: "creation-mode", label: CREATION_MODE_CONFIG[creationMode].name, - tone: "emerald", + tone: "lime", }); } @@ -506,7 +593,7 @@ export const EmptyState: React.FC = ({ badges.push({ key: "web-search", label: "联网搜索已开启", - tone: "sky", + tone: "lime", }); } @@ -514,7 +601,7 @@ export const EmptyState: React.FC = ({ badges.push({ key: "skill", label: activeSkillDisplayLabel, - tone: "emerald", + tone: "lime", }); } @@ -537,72 +624,70 @@ export const EmptyState: React.FC = ({ icon: React.ReactNode; imageSrc?: string; imageAlt?: string; - tone?: "slate" | "sky" | "emerald" | "amber"; + tone?: "slate" | "sky" | "emerald" | "amber" | "lime"; action?: React.ReactNode; + onMediaAction?: () => void; + mediaActionLabel?: string; + mediaActionDisabled?: boolean; }> = [ { key: "skills", - eyebrow: "能力层", + eyebrow: "支撑能力", title: "技能", value: skillSummaryLabel, description: - "把技能当作任务能力层来用,可把固定工作流、提示链和工具调用打包进一次对话。", + "把跑通过的提示、步骤和工具组合沉淀下来,下次遇到同类任务可以直接复用。", icon: , imageSrc: capabilitySkillsPlaceholder, imageAlt: "技能能力卡占位图", - tone: "emerald", + tone: "lime", }, { key: "automation", - eyebrow: "能力层", + eyebrow: "支撑能力", title: "自动化", - value: planEnabled ? "Plan 编排已开启" : "按当前对话直接执行", + value: planEnabled ? "当前会按步骤推进" : "重复流程可持续跑起来", description: - "支持把复杂任务按步骤推进,适合长链路处理、批量执行和需要持续产出的工作流。", + "适合长链路处理、批量任务和持续产出,让重复动作不再每次都从头手动重做。", icon: , imageSrc: capabilityAutomationsPlaceholder, imageAlt: "自动化能力卡占位图", - tone: "sky", + tone: "lime", }, { key: "agent-teams", - eyebrow: "能力层", + eyebrow: "支撑能力", title: "多代理", - value: subagentEnabled ? "协作模式已开启" : "支持分工协作", + value: subagentEnabled ? "当前任务支持并行协作" : "复杂任务可拆成并行分工", description: - "需要并行研究、拆解方案或多角色协同时,可让任务由多个代理分工处理并回收结论。", + "当研究、方案和执行需要同时推进时,可把任务拆给多个代理并行处理,再统一回收结论。", icon: , imageSrc: capabilityAgentTeamsPlaceholder, imageAlt: "多代理协作能力卡占位图", - tone: "amber", + tone: "lime", }, ]; cards.push({ key: "browser", - eyebrow: "能力层", - title: "浏览器工作台", + eyebrow: "支撑能力", + title: "浏览器接入", value: browserAssistLoading - ? "正在准备浏览器会话" - : "网页登录 / 人工接管", + ? "正在检查连接状态" + : "CDP / 浏览器插件复用", description: - "需要处理登录、验证码或复杂网页操作时,可切到浏览器工作台接管真实浏览器。", + "登录、验证和网页动作可直接复用 CDP 或浏览器插件连接,不必再切到单独工作台。", icon: , imageSrc: capabilityBrowserAssistPlaceholder, - imageAlt: "浏览器工作台能力卡占位图", - tone: "slate", - action: onLaunchBrowserAssist ? ( - - ) : null, + imageAlt: "浏览器接入能力卡占位图", + tone: "lime", + onMediaAction: onLaunchBrowserAssist + ? () => { + void onLaunchBrowserAssist(); + } + : undefined, + mediaActionLabel: browserAssistLoading ? "浏览器连接准备中" : "连接浏览器", + mediaActionDisabled: browserAssistLoading, }); return cards; @@ -614,48 +699,6 @@ export const EmptyState: React.FC = ({ subagentEnabled, ]); - const workspaceFeatures = useMemo(() => { - const features = [ - { - key: "context", - title: "持续上下文", - description: - "一个任务可以连续推进,补充背景、改写结果和追问细节都留在同一会话里。", - }, - { - key: "canvas", - title: "画布承接结果", - description: - hasCanvasContent || hasContentId - ? "当前会话已经接入画布,生成内容可继续整理、扩写和汇总。" - : "生成结果不会只停留在消息气泡里,而是继续进入工作台承接后续整理与交付。", - }, - ]; - - if (isGeneralTheme && onLaunchBrowserAssist) { - features.push({ - key: "browser", - title: "网页任务可接管", - description: - "遇到登录、验证码或复杂网页操作时,可切换到浏览器工作台继续完成任务。", - }); - } else { - features.push({ - key: "quick-start", - title: "任务模板起步", - description: - "先点快速启动卡生成第一轮任务,再在输入框里继续细化,是更顺手的使用路径。", - }); - } - - return features; - }, [ - hasCanvasContent, - hasContentId, - isGeneralTheme, - onLaunchBrowserAssist, - ]); - const quickActionItems = useMemo( () => currentRecommendations.slice(0, 4).map(([shortLabel, fullPrompt]) => ({ @@ -782,31 +825,37 @@ export const EmptyState: React.FC = ({ ); const generalRecommendedSolutionsPanel = ( - ({ - key: solution.id, - title: solution.title, - description: solution.summary, - badge: solution.badge, - prompt: solution.prompt, - actionLabel: solution.actionLabel, - outputHint: solution.outputHint, - statusLabel: solution.statusLabel, - statusTone: solution.statusTone, - testId: `entry-recommended-${solution.id}`, - }))} - embedded - onAction={(item) => { - const solution = entryRecommendedSolutions.find( - (candidate) => candidate.id === item.key, - ); - if (solution) { - handleApplyEntryRecommendedSolution(solution); - } - }} - /> + + +
+ 推荐方案 +
+ {selectedTextPreview ? ( + + 当前会带上选中内容 + + ) : null} +
+ + + {entryRecommendedSolutions.map((solution) => ( + + { + handleApplyEntryRecommendedSolution(solution); + }} + > + {solution.title} + {solution.isRecent ? ( + {solution.badge} + ) : null} + + + ))} + +
); const headerControls = onProjectChange ? ( @@ -851,13 +900,13 @@ export const EmptyState: React.FC = ({ ` @@ -124,13 +208,21 @@ const SupportingShell = styled.div<{ $delay: number }>` const CardsShell = styled.div` display: grid; - gap: 0.75rem; + margin-top: auto; + gap: 0.7rem; + padding-top: clamp(1rem, 4vh, 2.6rem); grid-template-columns: repeat(1, minmax(0, 1fr)); + align-items: stretch; + grid-auto-rows: minmax(0, 1fr); - @media (min-width: 768px) { + @media (min-width: 720px) { grid-template-columns: repeat(2, minmax(0, 1fr)); } + @media (min-width: 1120px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + @media (max-height: 940px) { gap: 0.625rem; } @@ -140,6 +232,10 @@ const HeroCard = styled.article.attrs({ className: EMPTY_STATE_CARD_SURFACE_CLASSNAME, })<{ $index: number }>` position: relative; + display: flex; + height: 100%; + min-height: 210px; + flex-direction: column; transition: transform 220ms ease, box-shadow 220ms ease, @@ -147,27 +243,10 @@ const HeroCard = styled.article.attrs({ animation: ${cardReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both; animation-delay: ${({ $index }) => `${160 + $index * 70}ms`}; - &::after { - content: ""; - position: absolute; - left: 1rem; - right: 1rem; - bottom: 0.9rem; - height: 1px; - background: linear-gradient( - 90deg, - rgba(16, 185, 129, 0) 0%, - rgba(16, 185, 129, 0.28) 32%, - rgba(56, 189, 248, 0.22) 70%, - rgba(56, 189, 248, 0) 100% - ); - opacity: 0.7; - } - &:hover { - transform: translateY(-4px); + transform: translateY(-2px); border-color: rgba(203, 213, 225, 0.96); - box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.18); + box-shadow: 0 14px 28px -24px rgba(15, 23, 42, 0.14); } @media (prefers-reduced-motion: reduce) { @@ -176,51 +255,57 @@ const HeroCard = styled.article.attrs({ } @media (max-width: 1180px), (max-height: 940px) { - padding: 0.875rem; + padding: 0.8rem; .card-icon { - height: 2rem; - width: 2rem; - border-radius: 1rem; + height: 1.9rem; + width: 1.9rem; + border-radius: 0.95rem; } .card-content { - margin-top: 0.625rem; + margin-top: 0.5rem; + min-height: 0; } .card-title { - font-size: 0.95rem; + font-size: 0.92rem; line-height: 1.3; } - .card-value { - margin-top: 0.125rem; - } + } - .card-description { - -webkit-line-clamp: 2; - font-size: 11px; - line-height: 1.45; - } - - .card-preview { - margin-top: 0.75rem; - } - - .card-preview img { - height: 68px; - } + .card-media { + margin-top: auto; + padding-top: 0.8rem; } `; -const FeaturePanel = styled.div` - order: 5; - animation: ${cardReveal} 620ms cubic-bezier(0.22, 1, 0.36, 1) both; - animation-delay: 260ms; +const CardTitleRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +`; - @media (prefers-reduced-motion: reduce) { - animation: none; - } +const CardValueText = styled.div` + margin-top: 0.2rem; + font-size: 11.5px; + font-weight: 600; + line-height: 1.5; + color: rgb(100 116 139); +`; + +const CardEyebrow = styled.span` + border-radius: 9999px; + border: 1px solid rgba(226, 232, 240, 0.88); + background: rgba(255, 255, 255, 0.92); + padding: 0.18rem 0.5rem; + font-size: 9px; + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + color: rgb(148 163 184); `; export interface EmptyStateHeroBadge { @@ -240,22 +325,19 @@ export interface EmptyStateHeroCard { imageAlt?: string; tone?: "slate" | "sky" | "emerald" | "amber"; action?: ReactNode; -} - -export interface EmptyStateHeroFeature { - key: string; - title: string; - description: string; + onMediaAction?: () => void; + mediaActionLabel?: string; + mediaActionDisabled?: boolean; } interface EmptyStateHeroProps { eyebrow: string; title: string; + slogan?: string; description: string; supportingDescription?: string; badges: EmptyStateHeroBadge[]; cards: EmptyStateHeroCard[]; - features?: EmptyStateHeroFeature[]; prioritySlot?: ReactNode; supportingSlot?: ReactNode; headerControls?: ReactNode; @@ -264,11 +346,11 @@ interface EmptyStateHeroProps { export function EmptyStateHero({ eyebrow, title, + slogan, description, supportingDescription, badges, cards, - features = [], prioritySlot, supportingSlot, headerControls, @@ -276,124 +358,132 @@ export function EmptyStateHero({ return ( - - - -
- {eyebrow} -
- {headerControls} -
+ + + + + {eyebrow} + {headerControls} + - -

- {title} -

-

- {description} -

- {supportingDescription ? ( - {supportingDescription} - ) : null} -
+ + + {slogan ? {slogan} : null} + {title ? ( +

+ {title} +

+ ) : null} + {description} + {supportingDescription ? ( + {supportingDescription} + ) : null} +
- {badges.length > 0 ? ( -
- {badges.map((badge) => ( - - {badge.label} - - ))} -
- ) : null} -
- - - {cards.map((card, index) => ( - -
-
- {card.icon} -
- - {card.eyebrow} - -
- -
-
- {card.title} -
-
- {card.value} -
-

- {card.description} -

-
- - {card.imageSrc ? ( -
- {card.imageAlt + {badges.length > 0 ? ( +
+ {badges.map((badge) => ( + + {badge.label} + + ))}
) : null} - {card.action ? ( -
{card.action}
- ) : null} - - ))} - - + + + + + {prioritySlot ? ( - + {prioritySlot} ) : null} {supportingSlot ? ( - + {supportingSlot} ) : null} - {features.length > 0 ? ( - -
- {features.map((feature) => ( + + {cards.map((card, index) => ( + +
-
- {feature.title} -
-

- {feature.description} -

+ {card.icon}
- ))} -
- - ) : null} + {card.eyebrow} +
+ +
+ +
+ {card.title} +
+ +

{card.value}

+

{card.description}

+
+ } + /> + + {card.value} +
+ + {card.imageSrc || card.action ? ( +
+ {card.imageSrc ? ( + card.onMediaAction ? ( + + ) : ( +
+ {card.imageAlt +
+ ) + ) : null} + + {card.action ?
{card.action}
: null} +
+ ) : null} +
+ ))} +
+
); diff --git a/src/components/agent/chat/components/ImageTaskViewer.test.tsx b/src/components/agent/chat/components/ImageTaskViewer.test.tsx index 6d5bcd235..6c6e951b1 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.test.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.test.tsx @@ -136,6 +136,24 @@ describe("ImageTaskViewer", () => { expect(onOpenImage).toHaveBeenCalledWith("https://example.com/image-1.png"); }); + it("大图舞台应保留稳定内边距,避免图片贴近上下边线", () => { + const { container } = renderComponent(); + + const stage = container.querySelector( + '[data-testid="image-task-viewer-stage"]', + ) as HTMLDivElement | null; + const openButton = container.querySelector( + '[data-testid="image-task-viewer-open-image"]', + ) as HTMLButtonElement | null; + + expect(stage?.className).toContain("rounded-[20px]"); + expect(openButton?.className).toContain("rounded-[18px]"); + expect(openButton?.className).toContain("p-4"); + expect(openButton?.className).toContain("border-slate-200/80"); + expect(stage?.firstElementChild?.className).toContain("p-4"); + expect(stage?.firstElementChild?.className).toContain("pt-5"); + }); + it("结果图加载失败时应展示兜底文案并隐藏打开原图入口", () => { const { container } = renderComponent(); diff --git a/src/components/agent/chat/components/ImageTaskViewer.tsx b/src/components/agent/chat/components/ImageTaskViewer.tsx index 5086aa40c..b326e7477 100644 --- a/src/components/agent/chat/components/ImageTaskViewer.tsx +++ b/src/components/agent/chat/components/ImageTaskViewer.tsx @@ -1,4 +1,4 @@ -import { ArrowUpRight, LoaderCircle, Sparkles, X } from "lucide-react"; +import { LoaderCircle, Sparkles, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { RenderableTaskImage } from "./RenderableTaskImage"; import type { ImageTaskViewerProps } from "./imageWorkbenchTypes"; @@ -302,78 +302,85 @@ export function ImageTaskViewer({
-
- {selectedOutput ? ( - ( - - )} - renderFallback={(reason) => ( -
-
- {reason === "empty" && - (selectedTask?.status === "running" || - selectedTask?.status === "routing" || - selectedTask?.status === "queued") ? ( - - ) : ( - - )} -
- {reason === "error" - ? resolveImageUnavailableTitle(selectedTask?.status) - : statusLabel} -
-
- {reason === "error" - ? resolveImageUnavailableDescription(selectedTask?.mode) - : resolveEmptyStateDescription( - selectedTask?.status, - selectedTask?.failureMessage, - selectedTask?.mode, - )} -
-
-
- )} - /> - ) : ( -
-
- {selectedTask?.status === "running" || - selectedTask?.status === "routing" || - selectedTask?.status === "queued" ? ( - - ) : ( - +
+
+ {selectedOutput ? ( + ( + )} -
- {statusLabel} -
-
- {resolveEmptyStateDescription( - selectedTask?.status, - selectedTask?.failureMessage, - selectedTask?.mode, + renderFallback={(reason) => ( +
+
+ {reason === "empty" && + (selectedTask?.status === "running" || + selectedTask?.status === "routing" || + selectedTask?.status === "queued") ? ( + + ) : ( + + )} +
+ {reason === "error" + ? resolveImageUnavailableTitle(selectedTask?.status) + : statusLabel} +
+
+ {reason === "error" + ? resolveImageUnavailableDescription(selectedTask?.mode) + : resolveEmptyStateDescription( + selectedTask?.status, + selectedTask?.failureMessage, + selectedTask?.mode, + )} +
+
+
+ )} + /> + ) : ( +
+
+ {selectedTask?.status === "running" || + selectedTask?.status === "routing" || + selectedTask?.status === "queued" ? ( + + ) : ( + )} +
+ {statusLabel} +
+
+ {resolveEmptyStateDescription( + selectedTask?.status, + selectedTask?.failureMessage, + selectedTask?.mode, + )} +
-
- )} + )} +
{showSourcePanel ? ( diff --git a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx index 5c063d844..f6fd64054 100644 --- a/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchMessagePreview.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react"; +import { LoaderCircle, Sparkles } from "lucide-react"; import { emitImageWorkbenchFocus } from "@/lib/imageWorkbenchEvents"; import { cn } from "@/lib/utils"; import type { MessageImageWorkbenchPreview } from "../types"; @@ -10,20 +10,6 @@ interface ImageWorkbenchMessagePreviewProps { onOpen?: (preview: MessageImageWorkbenchPreview) => void; } -function resolveModeEyebrow( - mode?: MessageImageWorkbenchPreview["mode"], -): string { - switch (mode) { - case "edit": - return "Image Editing"; - case "variation": - return "Image Redraw"; - case "generate": - default: - return "Image Generation"; - } -} - function resolveResultLabel( mode?: MessageImageWorkbenchPreview["mode"], ): string { @@ -34,7 +20,7 @@ function resolveResultLabel( return "重绘结果"; case "generate": default: - return "图片"; + return "图片结果"; } } @@ -44,7 +30,7 @@ function resolveSourceLabel( return mode === "variation" ? "参考图" : "来源图"; } -function resolveStatusLabel(preview: MessageImageWorkbenchPreview): string { +function resolveStatusPrefix(preview: MessageImageWorkbenchPreview): string { switch (preview.status) { case "complete": switch (preview.mode) { @@ -91,25 +77,42 @@ function resolveStatusLabel(preview: MessageImageWorkbenchPreview): string { } } -function resolveStatusTone(preview: MessageImageWorkbenchPreview): string { +function resolveStatusAccentClass( + preview: MessageImageWorkbenchPreview, +): string { switch (preview.status) { case "complete": - return "border-emerald-200 bg-emerald-50 text-emerald-700"; + return "bg-emerald-500"; case "partial": - return "border-amber-200 bg-amber-50 text-amber-700"; + return "bg-amber-500"; case "cancelled": - return "border-slate-200 bg-slate-100 text-slate-600"; + return "bg-slate-400"; case "failed": - return "border-rose-200 bg-rose-50 text-rose-700"; + return "bg-rose-500"; case "running": default: - return "border-sky-200 bg-sky-50 text-sky-700"; + return "bg-sky-500"; } } +function isTransitionStatusMessage(statusMessage: string): boolean { + return ( + statusMessage.includes("正在同步") || + statusMessage.includes("同步任务状态") || + statusMessage.includes("同步到对话") || + statusMessage.includes("异步队列") + ); +} + function resolveDescription(preview: MessageImageWorkbenchPreview): string { const statusMessage = preview.statusMessage?.trim(); - if (statusMessage) { + if ( + statusMessage && + !( + preview.status !== "running" && + isTransitionStatusMessage(statusMessage) + ) + ) { return statusMessage; } @@ -118,35 +121,33 @@ function resolveDescription(preview: MessageImageWorkbenchPreview): string { switch (preview.status) { case "complete": return preview.imageCount && preview.imageCount > 1 - ? `已返回 ${preview.imageCount} 张${resultLabel},打开查看即可。` - : `${resultLabel}已经完成,打开查看即可。`; + ? `已返回 ${preview.imageCount} 张${resultLabel},可在右侧继续查看与使用。` + : `${resultLabel}已经完成,可在右侧继续查看与使用。`; case "partial": return preview.imageCount && preview.imageCount > 0 ? `已返回 ${preview.imageCount} 张${resultLabel},剩余结果未完成。` - : `${resultLabel}任务返回了部分结果。`; + : `${resultLabel}已同步一部分,可在右侧继续查看。`; case "cancelled": return "任务已经取消,当前不会继续生成新的图片结果。"; case "failed": return preview.retryable === false ? "当前错误需要先调整配置或参数。" - : "这次没有拿到可用结果,请稍后重试。"; + : "这次没有拿到可用结果,请调整描述后重试。"; case "running": default: switch (preview.mode) { case "edit": - return "图片编辑中,完成后会直接替换成真实结果。"; + return "正在处理修图,完成后会自动替换成真实结果。"; case "variation": - return "图片重绘中,完成后会直接替换成真实结果。"; + return "正在处理重绘,完成后会自动替换成真实结果。"; case "generate": default: - return "图片生成中,完成后会直接替换成真实结果。"; + return "正在生成图片,完成后会自动替换成真实结果。"; } } } -function resolvePlaceholderLabel( - preview: MessageImageWorkbenchPreview, -): string { +function resolvePlaceholderLabel(preview: MessageImageWorkbenchPreview): string { if (preview.status === "failed") { return "暂未生成成功"; } @@ -156,7 +157,7 @@ function resolvePlaceholderLabel( if (preview.status === "complete" || preview.status === "partial") { return "结果已同步"; } - return resolveStatusLabel(preview); + return resolveStatusPrefix(preview); } function resolveImageUnavailableLabel( @@ -168,14 +169,16 @@ function resolveImageUnavailableLabel( return resolvePlaceholderLabel(preview); } -function shouldShowSourcePanel(preview: MessageImageWorkbenchPreview): boolean { +function shouldShowSourceFootnote( + preview: MessageImageWorkbenchPreview, +): boolean { return Boolean( preview.mode === "edit" || - preview.mode === "variation" || - preview.sourceImageUrl?.trim() || - preview.sourceImagePrompt?.trim() || - preview.sourceImageRef?.trim() || - preview.sourceImageCount, + preview.mode === "variation" || + preview.sourceImageUrl?.trim() || + preview.sourceImagePrompt?.trim() || + preview.sourceImageRef?.trim() || + preview.sourceImageCount, ); } @@ -201,16 +204,41 @@ function resolveSourceSummary(preview: MessageImageWorkbenchPreview): string { : "当前任务会基于已有图片结果继续完成修图。"; } -function resolveSourcePlaceholderLabel( +function resolveSourceFootnote( preview: MessageImageWorkbenchPreview, -): string { - return preview.mode === "variation" ? "参考图待同步" : "来源图待同步"; +): string | null { + if (!shouldShowSourceFootnote(preview)) { + return null; + } + + return `${resolveSourceLabel(preview.mode)}:${resolveSourceSummary(preview)}`; +} + +function renderPlaceholder(preview: MessageImageWorkbenchPreview, reason: string) { + return ( +
+
+ {reason === "empty" && preview.status === "running" ? ( + + ) : ( + + )} +
+ {reason === "error" + ? resolveImageUnavailableLabel(preview) + : resolvePlaceholderLabel(preview)} +
+
+
+ ); } export const ImageWorkbenchMessagePreview: React.FC< ImageWorkbenchMessagePreviewProps > = ({ preview, onOpen }) => { - const showSourcePanel = shouldShowSourcePanel(preview); + const sourceFootnote = resolveSourceFootnote(preview); + const statusPrefix = resolveStatusPrefix(preview); + const statusDescription = resolveDescription(preview); return ( ); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx index d5025de36..0de6b42a7 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx @@ -116,6 +116,15 @@ const renderInputbarCore = async ( }; describe("InputbarCore", () => { + it("挂载时不应主动预取语音配置", async () => { + await renderInputbarCore({ + visualVariant: "default", + toolMode: "default", + }); + + expect(mockGetVoiceInputConfig).not.toHaveBeenCalled(); + }); + it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", async () => { const container = await renderInputbarCore(); const textarea = container.querySelector( @@ -222,6 +231,7 @@ describe("InputbarCore", () => { await Promise.resolve(); }); + expect(mockGetVoiceInputConfig).toHaveBeenCalledTimes(1); expect(mockStartRecording).toHaveBeenCalledTimes(1); const stopDictationButton = container.querySelector( diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx index eb46074d3..442bb76da 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx @@ -112,6 +112,7 @@ export const InputbarCore: React.FC = ({ const isFloatingVariant = visualVariant === "floating"; const { dictationEnabled, + voiceConfigLoaded, dictationState, isDictating, isDictationBusy, @@ -176,7 +177,7 @@ export const InputbarCore: React.FC = ({ : "语音识别中" : isDictating ? "停止语音输入" - : dictationEnabled + : dictationEnabled || !voiceConfigLoaded ? "开始语音输入" : "语音输入未启用"; @@ -247,11 +248,11 @@ export const InputbarCore: React.FC = ({ isFullscreen={isFullscreen} fillHeightWhenFullscreen hasAdditionalContent={pendingImages.length > 0} - maxAutoHeight={isTextareaExpanded ? 320 : isFloatingVariant ? 160 : 120} + maxAutoHeight={isTextareaExpanded ? 360 : isFloatingVariant ? 240 : 120} textareaRef={resolvedTextareaRef} onEscape={() => onToolClick("fullscreen")} allowSendWhileLoading - rows={isTextareaExpanded ? 6 : 1} + rows={isTextareaExpanded ? 7 : isFloatingVariant ? 3 : 1} placeholder={ placeholder || (isFullscreen diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx index 23e93e6da..63499cbc9 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Badge } from "@/components/ui/badge"; import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; import { ChatModelSelector } from "../../ChatModelSelector"; import { getOutputSchemaRuntimeLabel } from "../../../utils/sessionExecutionRuntime"; @@ -28,6 +29,9 @@ export const InputbarModelExtra: React.FC = ({ if (isFullscreen || !providerType || !model) { return null; } + const selectorBackgroundPreload = hasTauriInvokeCapability() + ? "immediate" + : "disabled"; const outputSchemaLabel = getOutputSchemaRuntimeLabel( executionRuntime?.output_schema_runtime, @@ -44,6 +48,7 @@ export const InputbarModelExtra: React.FC = ({ compactTrigger popoverSide="top" onManageProviders={onManageProviders} + backgroundPreload={selectorBackgroundPreload} /> {outputSchemaLabel ? ( ("idle"); const [dictationEnabled, setDictationEnabled] = useState(false); const [soundEnabled, setSoundEnabled] = useState(false); + const [voiceConfigLoaded, setVoiceConfigLoaded] = useState(false); const textRef = useRef(text); const dictationStateRef = useRef("idle"); const voiceConfigRef = useRef(null); @@ -75,23 +76,7 @@ export function useInputbarDictation({ }, [dictationState]); useEffect(() => { - let cancelled = false; - - getVoiceInputConfig() - .then((config) => { - if (cancelled) { - return; - } - voiceConfigRef.current = config; - setDictationEnabled(config.enabled); - setSoundEnabled(config.sound_enabled); - }) - .catch((error) => { - console.error("[输入栏] 加载语音输入配置失败:", error); - }); - return () => { - cancelled = true; if (dictationStateRef.current === "listening") { void cancelRecording().catch((error) => { console.error("[输入栏] 卸载时取消录音失败:", error); @@ -126,6 +111,7 @@ export function useInputbarDictation({ voiceConfigRef.current = config; setDictationEnabled(config.enabled); setSoundEnabled(config.sound_enabled); + setVoiceConfigLoaded(true); return config; }, []); @@ -230,6 +216,7 @@ export function useInputbarDictation({ return { dictationEnabled, + voiceConfigLoaded, dictationState, isDictating: dictationState === "listening", isDictationBusy: dictationState !== "idle", diff --git a/src/components/agent/chat/components/Inputbar/styles.ts b/src/components/agent/chat/components/Inputbar/styles.ts index acc40451c..e407e62e0 100644 --- a/src/components/agent/chat/components/Inputbar/styles.ts +++ b/src/components/agent/chat/components/Inputbar/styles.ts @@ -39,24 +39,24 @@ export const Container = styled.div` export const InputBarContainer = styled.div` position: relative; - border: 1px solid #d7e0ea; + border: 1px solid #dcfce7; border-radius: 22px; padding: 10px 12px 10px 10px; - background: linear-gradient(180deg, #fbfdff 0%, #f5f8fb 100%); + background: linear-gradient(180deg, #fdfffb 0%, #f4fbf5 100%); box-shadow: - 0 10px 28px rgba(15, 23, 42, 0.06), - inset 0 1px 0 rgba(255, 255, 255, 0.85); + 0 10px 28px rgba(22, 101, 52, 0.04), + inset 0 1px 0 rgba(255, 255, 255, 0.9); transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; &:focus-within { - border-color: #c5d3e2; - background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + border-color: #bbf7d0; + background: linear-gradient(180deg, #ffffff 0%, #f0fdf4 100%); box-shadow: - 0 0 0 3px rgba(191, 219, 254, 0.32), - 0 14px 32px rgba(15, 23, 42, 0.08), + 0 0 0 3px rgba(134, 239, 172, 0.35), + 0 14px 32px rgba(22, 101, 52, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.92); } @@ -67,12 +67,12 @@ export const InputBarContainer = styled.div` &.floating-composer { border-radius: 20px; - padding: 8px 10px; - background: linear-gradient(180deg, #fcfdff 0%, #f7f9fc 100%); - border-color: #d7e0ea; + padding: 16px 18px 14px 16px; + background: linear-gradient(180deg, #fcfffc 0%, #f4fcf5 100%); + border-color: #d1fae5; box-shadow: - 0 10px 26px rgba(15, 23, 42, 0.08), - inset 0 1px 0 rgba(255, 255, 255, 0.78); + 0 16px 32px rgba(22, 101, 52, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.85); } &.floating-composer.floating-collapsed { @@ -81,12 +81,12 @@ export const InputBarContainer = styled.div` } &.floating-composer:focus-within { - background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); - border-color: #c5d3e2; + background: linear-gradient(180deg, #ffffff 0%, #ecfdf5 100%); + border-color: #a7f3d0; box-shadow: - 0 0 0 3px rgba(191, 219, 254, 0.38), - 0 12px 28px rgba(15, 23, 42, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.86); + 0 0 0 3px rgba(110, 231, 183, 0.38), + 0 18px 34px rgba(22, 101, 52, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.88); } `; @@ -127,9 +127,9 @@ export const StyledTextarea = styled.textarea` min-height: 34px; &.floating-composer { - font-size: 13px; - line-height: 1.4; - min-height: 28px; + font-size: 16px; + line-height: 1.65; + min-height: 98px; } &.floating-composer.floating-collapsed { @@ -141,7 +141,7 @@ export const StyledTextarea = styled.textarea` } &.composer-expanded { - min-height: 168px; + min-height: 188px; } &::placeholder { @@ -171,8 +171,8 @@ export const BottomBar = styled.div` border-top: 1px solid rgba(148, 163, 184, 0.22); &.floating-composer { - padding-top: 8px; - margin-top: 8px; + padding-top: 10px; + margin-top: 10px; gap: 10px; } diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 64fe50dde..4fb9644d9 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -6,6 +6,7 @@ import { MessageList } from "./MessageList"; import type { Message } from "../types"; const IMAGE_WORKBENCH_FOCUS_EVENT = "lime:image-workbench-focus"; +const VIDEO_WORKBENCH_TASK_ACTION_EVENT = "lime:video-workbench-task-action"; vi.mock("./MarkdownRenderer", () => ({ MarkdownRenderer: ({ content }: { content: string }) => ( @@ -152,6 +153,20 @@ describe("MessageList", () => { expect(container.textContent).not.toContain("开始一段新的对话吧"); }); + it("任务中心空列表时应展示回访型空态而不是普通新对话文案", () => { + const container = render([], { + emptyStateVariant: "task-center", + }); + + expect( + container.querySelector('[data-testid="message-list-empty-task-center"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("任务中心"); + expect(container.textContent).toContain("回到进行中的任务和最近工作现场"); + expect(container.textContent).toContain("左侧会继续显示最近任务"); + expect(container.textContent).not.toContain("开始一段新的对话吧"); + }); + it("应过滤空白 user 消息,避免渲染空白气泡", () => { const now = new Date(); const messages: Message[] = [ @@ -245,9 +260,11 @@ describe("MessageList", () => { '[data-testid="image-workbench-message-preview-task-1"]', ) as HTMLButtonElement | null; - expect(previewCard?.textContent).toContain("Image Generation"); - expect(previewCard?.textContent).toContain("打开查看"); - expect(previewCard?.textContent).toContain("1024x1024"); + expect(previewCard?.textContent).toContain("一颗戴耳机的青柠"); + expect(previewCard?.textContent).toContain("已生成"); + expect(previewCard?.textContent).toContain("可在右侧继续查看与使用"); + expect(previewCard?.className).not.toContain("max-w-[620px]"); + expect(previewCard?.className).toContain("max-w-[360px]"); act(() => { previewCard?.click(); @@ -313,6 +330,137 @@ describe("MessageList", () => { ); }); + it("失败的视频任务卡应提供重新生成动作,并通过事件总线下发而不是误触发打开工作区", () => { + const now = new Date(); + const onOpenMessagePreview = vi.fn(); + const messages: Message[] = [ + { + id: "msg-assistant-video-failed", + role: "assistant", + content: "视频任务失败。", + timestamp: now, + taskPreview: { + kind: "video_generate", + taskId: "task-video-failed-1", + taskType: "video_generate", + prompt: "新品发布会短视频,镜头缓慢推进主角产品", + status: "failed", + durationSeconds: 15, + aspectRatio: "16:9", + resolution: "720p", + projectId: "project-video-1", + contentId: "content-video-1", + }, + }, + ]; + + let actionDetail: Record | null = null; + const handleAction = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + actionDetail = event.detail as Record; + }; + window.addEventListener(VIDEO_WORKBENCH_TASK_ACTION_EVENT, handleAction); + + const container = render(messages, { onOpenMessagePreview }); + const actionButton = container.querySelector( + '[data-testid="task-message-preview-action-task-video-failed-1-retry"]', + ) as HTMLButtonElement | null; + + expect(actionButton?.textContent).toContain("重新生成"); + + act(() => { + actionButton?.click(); + }); + + expect(actionDetail).toEqual({ + action: "retry", + taskId: "task-video-failed-1", + projectId: "project-video-1", + contentId: "content-video-1", + }); + expect(onOpenMessagePreview).not.toHaveBeenCalled(); + + window.removeEventListener(VIDEO_WORKBENCH_TASK_ACTION_EVENT, handleAction); + }); + + it("进行中的视频任务卡应提供取消动作,并继续保留打开工作区能力", () => { + const now = new Date(); + const onOpenMessagePreview = vi.fn(); + const messages: Message[] = [ + { + id: "msg-assistant-video-running-action", + role: "assistant", + content: "视频任务进行中。", + timestamp: now, + taskPreview: { + kind: "video_generate", + taskId: "task-video-running-action-1", + taskType: "video_generate", + prompt: "新品发布会短视频,镜头缓慢推进主角产品", + status: "running", + progress: 18, + durationSeconds: 15, + aspectRatio: "16:9", + resolution: "720p", + projectId: "project-video-1", + contentId: "content-video-1", + }, + }, + ]; + + let actionDetail: Record | null = null; + const handleAction = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + actionDetail = event.detail as Record; + }; + window.addEventListener(VIDEO_WORKBENCH_TASK_ACTION_EVENT, handleAction); + + const container = render(messages, { onOpenMessagePreview }); + const previewCard = container.querySelector( + '[data-testid="task-message-preview-task-video-running-action-1"]', + ) as HTMLButtonElement | null; + const actionButton = container.querySelector( + '[data-testid="task-message-preview-action-task-video-running-action-1-cancel"]', + ) as HTMLButtonElement | null; + + expect(actionButton?.textContent).toContain("取消任务"); + + act(() => { + actionButton?.click(); + }); + + expect(actionDetail).toEqual({ + action: "cancel", + taskId: "task-video-running-action-1", + projectId: "project-video-1", + contentId: "content-video-1", + }); + expect(onOpenMessagePreview).not.toHaveBeenCalled(); + + act(() => { + previewCard?.click(); + }); + + expect(onOpenMessagePreview).toHaveBeenCalledWith( + { + kind: "task", + preview: expect.objectContaining({ + kind: "video_generate", + taskId: "task-video-running-action-1", + }), + }, + expect.objectContaining({ + id: "msg-assistant-video-running-action", + }), + ); + + window.removeEventListener(VIDEO_WORKBENCH_TASK_ACTION_EVENT, handleAction); + }); + it("通用任务消息卡应在聊天区渲染预览并支持打开对应产物", () => { const now = new Date(); const onOpenMessagePreview = vi.fn(); @@ -450,18 +598,11 @@ describe("MessageList", () => { const previewCard = container.querySelector( '[data-testid="image-workbench-message-preview-task-edit-1"]', ); - const sourcePanel = container.querySelector( - '[data-testid="image-workbench-message-preview-source-task-edit-1"]', - ); - expect(previewCard?.textContent).toContain("Image Editing"); expect(previewCard?.textContent).toContain("已修图"); - expect(sourcePanel?.textContent).toContain("来源图"); - expect(sourcePanel?.textContent).toContain("原始街景海报"); - expect(sourcePanel?.textContent).toContain("img-source-1"); - expect( - sourcePanel?.querySelector('img[src="https://example.com/source.png"]'), - ).toBeTruthy(); + expect(previewCard?.textContent).toContain("来源图"); + expect(previewCard?.textContent).toContain("原始街景海报"); + expect(previewCard?.textContent).not.toContain("Image Editing"); }); it("图片任务完成但图片仍在工作台时,不应继续显示生成中占位", () => { @@ -489,10 +630,43 @@ describe("MessageList", () => { ); expect(previewCard?.textContent).toContain("结果已同步"); - expect(previewCard?.textContent).toContain("打开查看"); + expect(previewCard?.textContent).toContain("已生成"); + expect(previewCard?.textContent).toContain("可在右侧继续查看与使用"); expect(previewCard?.textContent).not.toContain("图片任务卡"); }); + it("图片任务已经完成时,不应继续向用户暴露同步中的过渡文案", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-image-workbench-complete-sync-copy", + role: "assistant", + content: "图片任务已完成。", + timestamp: now, + imageWorkbenchPreview: { + taskId: "task-complete-sync-copy", + prompt: "广州塔清晨薄雾氛围图", + status: "complete", + imageUrl: "https://example.com/guangzhou-tower-morning.png", + imageCount: 1, + statusMessage: "图片任务已提交,正在同步任务状态。", + projectId: "project-1", + contentId: "content-1", + }, + }, + ]; + + const container = render(messages); + const previewCard = container.querySelector( + '[data-testid="image-workbench-message-preview-task-complete-sync-copy"]', + ); + + expect(previewCard?.textContent).toContain("已生成"); + expect(previewCard?.textContent).toContain("可在右侧继续查看与使用"); + expect(previewCard?.textContent).not.toContain("正在同步任务状态"); + expect(previewCard?.textContent).not.toContain("图片任务已提交"); + }); + it("失败的图片任务卡应收敛为静态状态卡,不再展示操作按钮", () => { const now = new Date(); const messages: Message[] = [ @@ -517,7 +691,7 @@ describe("MessageList", () => { ); expect(previewCard?.textContent).toContain("生成失败"); - expect(previewCard?.textContent).toContain("稍后重试"); + expect(previewCard?.textContent).toContain("调整描述后重试"); expect( container.querySelector( '[data-testid="image-workbench-message-preview-action-task-failed-1-retry"]', @@ -551,7 +725,6 @@ describe("MessageList", () => { expect(container.textContent).toContain( "任务已进入队列,等待图片服务分配执行槽位。", ); - expect(container.textContent).toContain("第 2 次"); expect( container.querySelector( '[data-testid="image-workbench-message-preview-action-task-running-1-cancel"]', @@ -616,7 +789,7 @@ describe("MessageList", () => { expect(previewCard?.textContent).toContain("已取消"); expect(previewCard?.textContent).toContain("任务已取消"); - expect(previewCard?.textContent).toContain("打开查看"); + expect(previewCard?.textContent).not.toContain("打开查看"); expect( container.querySelector( '[data-testid="image-workbench-message-preview-action-task-cancelled-1-retry"]', @@ -1444,6 +1617,38 @@ describe("MessageList", () => { ); }); + it("不应把 .lime/tasks 下的内部任务快照 JSON 渲染成用户可见产物卡片", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-hidden-task-json", + role: "assistant", + content: "图片任务进行中", + timestamp: now, + artifacts: [ + { + id: "artifact-hidden-task-json", + type: "code", + title: "task-image-1.json", + content: "{\"status\":\"running\"}", + status: "complete", + meta: { + filePath: ".lime/tasks/image_generate/task-image-1.json", + filename: "task-image-1.json", + }, + position: { start: 0, end: 0 }, + createdAt: now.getTime(), + updatedAt: now.getTime(), + }, + ], + }, + ]; + + const container = render(messages); + expect(container.textContent).toContain("图片任务进行中"); + expect(container.textContent).not.toContain("task-image-1.json"); + }); + it("应先渲染思考与过程,再渲染正文,最后再落产物", () => { const now = new Date(); const messages: Message[] = [ diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index e0535841d..94d8a3366 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -39,6 +39,7 @@ import { sanitizeContentPartsForDisplay, sanitizeMessageTextForDisplay, } from "../utils/internalImagePlaceholder"; +import { isHiddenInternalArtifactPath } from "../utils/internalArtifactVisibility"; import { isInternalRoutingTurnSummaryText } from "../utils/turnSummaryPresentation"; import { Message, @@ -63,6 +64,7 @@ import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNaviga interface MessageListProps { messages: Message[]; + emptyStateVariant?: "default" | "task-center"; turns?: AgentThreadTurn[]; threadItems?: AgentThreadItem[]; currentTurnId?: string | null; @@ -229,6 +231,7 @@ function isInlineCoveredTimelineItem( const MessageListInner: React.FC = ({ messages, + emptyStateVariant = "default", turns = [], threadItems = [], currentTurnId = null, @@ -260,6 +263,7 @@ const MessageListInner: React.FC = ({ const scrollRef = useRef(null); const containerRef = useRef(null); const previousVisibleMessageCountRef = useRef(null); + const isTaskCenterEmptyState = emptyStateVariant === "task-center"; const [copiedId, setCopiedId] = useState(null); const [isUserScrolling, setIsUserScrolling] = useState(false); const [shouldAutoScroll, setShouldAutoScroll] = useState(true); @@ -690,13 +694,18 @@ const MessageListInner: React.FC = ({ }; const renderArtifactCards = (artifacts: Artifact[] | undefined) => { - if (!artifacts || artifacts.length === 0) { + const visibleArtifacts = + artifacts?.filter( + (artifact) => + !isHiddenInternalArtifactPath(resolveArtifactProtocolFilePath(artifact)), + ) || []; + if (visibleArtifacts.length === 0) { return null; } return (
- {artifacts.map((artifact) => { + {visibleArtifacts.map((artifact) => { const filePath = resolveArtifactProtocolFilePath(artifact); const writePhase = resolveArtifactWritePhase(artifact); const statusLabel = formatArtifactWritePhaseLabel(writePhase); @@ -779,14 +788,54 @@ const MessageListInner: React.FC = ({
) : ( -
- Lime -

开始一段新的对话吧

-
+ isTaskCenterEmptyState ? ( +
+
+
+
+ Lime +
+ + 任务中心 + +
+ +
+

+ 回到进行中的任务和最近工作现场 +

+

+ 这里会承接最近会话、进行中的任务和刚恢复的工作内容。要开始一个全新目标,随时回到“新建任务”。 +

+
+ +
+ + 左侧会继续显示最近任务 + + + 恢复中的会话会自动回到这里 + +
+
+
+ ) : ( +
+ Lime +

开始一段新的对话吧

+
+ ) ))} {messageGroups.map((group, groupIndex) => { diff --git a/src/components/agent/chat/components/TaskMessagePreview.tsx b/src/components/agent/chat/components/TaskMessagePreview.tsx index 99a9b6c33..0cf4bf70f 100644 --- a/src/components/agent/chat/components/TaskMessagePreview.tsx +++ b/src/components/agent/chat/components/TaskMessagePreview.tsx @@ -9,6 +9,7 @@ import { PlayCircle, Search, } from "lucide-react"; +import { emitVideoWorkbenchTaskAction } from "@/lib/videoWorkbenchEvents"; import { cn } from "@/lib/utils"; import type { MessageTaskPreview } from "../types"; @@ -218,97 +219,141 @@ function renderVideoTaskPreview( onOpen?: (preview: MessageTaskPreview) => void, metaItems: string[] = [], ) { + const actionButtons = + preview.status === "running" + ? [ + { + key: "cancel" as const, + label: "取消任务", + }, + ] + : (preview.status === "failed" || preview.status === "cancelled") && + preview.retryable !== false + ? [ + { + key: "retry" as const, + label: "重新生成", + }, + ] + : []; + return ( - + + + {actionButtons.length > 0 ? ( +
+ {actionButtons.map((action) => ( + + ))} +
+ ) : null} +
); } diff --git a/src/components/agent/chat/components/TokenUsageDisplay.test.tsx b/src/components/agent/chat/components/TokenUsageDisplay.test.tsx new file mode 100644 index 000000000..091285856 --- /dev/null +++ b/src/components/agent/chat/components/TokenUsageDisplay.test.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { TokenUsageDisplay } from "./TokenUsageDisplay"; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("TokenUsageDisplay", () => { + it("应渲染总 token 数提示", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ container, root }); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("31.0K tokens"); + }); + + it("应在不同数量级下渲染紧凑 token 文案", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedRoots.push({ container, root }); + + act(() => { + root.render( +
+ + + +
, + ); + }); + + expect(container.textContent).toContain("999 tokens"); + expect(container.textContent).toContain("1.2K tokens"); + expect(container.textContent).toContain("1.2M tokens"); + }); +}); diff --git a/src/components/agent/chat/components/TokenUsageDisplay.tsx b/src/components/agent/chat/components/TokenUsageDisplay.tsx index 9e5f40178..56b70de6b 100644 --- a/src/components/agent/chat/components/TokenUsageDisplay.tsx +++ b/src/components/agent/chat/components/TokenUsageDisplay.tsx @@ -10,11 +10,17 @@ import styled from "styled-components"; import { Coins } from "lucide-react"; import type { AgentTokenUsage as TokenUsage } from "@/lib/api/agentProtocol"; +const COMPACT_UNITS = [ + { threshold: 1_000_000_000, suffix: "B" }, + { threshold: 1_000_000, suffix: "M" }, + { threshold: 1_000, suffix: "K" }, +] as const; + const UsageContainer = styled.div` display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 10px; + gap: 5px; + padding: 3px 8px; border-radius: 6px; background-color: hsl(var(--muted) / 0.5); font-size: 11px; @@ -25,7 +31,7 @@ const UsageContainer = styled.div` const UsageIcon = styled(Coins)` width: 12px; height: 12px; - opacity: 0.7; + opacity: 0.55; `; const UsageText = styled.span` @@ -34,15 +40,26 @@ const UsageText = styled.span` "Courier New", monospace; `; -const Separator = styled.span` - opacity: 0.5; -`; - interface TokenUsageDisplayProps { usage: TokenUsage; className?: string; } +function formatCompactTokenCount(value: number): string { + if (!Number.isFinite(value)) { + return "0"; + } + + const normalized = Math.max(0, value); + for (const unit of COMPACT_UNITS) { + if (normalized >= unit.threshold) { + return `${(normalized / unit.threshold).toFixed(1)}${unit.suffix}`; + } + } + + return normalized.toLocaleString(); +} + /** * Token 使用量显示组件 * @@ -57,11 +74,7 @@ export const TokenUsageDisplay: React.FC = ({ return ( - {usage.input_tokens.toLocaleString()} in - / - {usage.output_tokens.toLocaleString()} out - · - {total.toLocaleString()} total + {formatCompactTokenCount(total)} tokens ); }; diff --git a/src/components/agent/chat/components/ToolCallDisplay.test.tsx b/src/components/agent/chat/components/ToolCallDisplay.test.tsx index ed0a86cf5..dfea66d72 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.test.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.test.tsx @@ -321,6 +321,12 @@ describe("ToolCallDisplay", () => { content_id: "content-1", project_id: "project-1", title: "GitHub MCP 搜索结果", + project_root_path: "/Users/coso/.proxycast/projects/project-1", + markdown_relative_path: + "exports/x-article-export/github-mcp/index.md", + images_relative_dir: + "exports/x-article-export/github-mcp/images", + image_count: 7, }, saved_project_id: "project-1", saved_by: "context_project", @@ -344,6 +350,15 @@ describe("ToolCallDisplay", () => { expect(container.textContent).toContain( "结果已自动保存到项目 project-1:GitHub MCP 搜索结果 · 来自当前项目上下文", ); + expect(container.textContent).toContain( + "项目目录:/Users/coso/.proxycast/projects/project-1", + ); + expect(container.textContent).toContain( + "Markdown 文件:exports/x-article-export/github-mcp/index.md", + ); + expect(container.textContent).toContain( + "图片资源:7 张 · exports/x-article-export/github-mcp/images", + ); expect(container.textContent).toContain("脚本来源:服务端脚本 · 2026-03-25"); }); diff --git a/src/components/agent/chat/components/ToolCallDisplay.tsx b/src/components/agent/chat/components/ToolCallDisplay.tsx index a0aed815a..9ef917282 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.tsx @@ -260,11 +260,39 @@ function readFirstNonEmptyString( return undefined; } +function readFirstFiniteNumber( + candidates: Array | null | undefined>, + keys: string[], +): number | undefined { + for (const candidate of candidates) { + if (!candidate) continue; + for (const key of keys) { + const value = candidate[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value.trim()); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + } + return undefined; +} + interface SiteToolResultSummary { savedContent?: { contentId?: string; projectId?: string; title?: string; + projectRootPath?: string; + bundleRelativeDir?: string; + markdownRelativePath?: string; + imagesRelativeDir?: string; + metaRelativePath?: string; + imageCount?: number; }; savedProjectId?: string; savedBy?: string; @@ -361,19 +389,43 @@ function normalizeSiteToolResultSummary( } return { - savedContent: hasSavedContent - ? { - contentId: readFirstNonEmptyString([savedContentRecord], [ - "content_id", - "contentId", + savedContent: hasSavedContent + ? { + contentId: readFirstNonEmptyString([savedContentRecord], [ + "content_id", + "contentId", ]), projectId: readFirstNonEmptyString([savedContentRecord], [ "project_id", "projectId", ]), - title: readFirstNonEmptyString([savedContentRecord], ["title"]), - } - : undefined, + title: readFirstNonEmptyString([savedContentRecord], ["title"]), + projectRootPath: readFirstNonEmptyString([savedContentRecord], [ + "project_root_path", + "projectRootPath", + ]), + bundleRelativeDir: readFirstNonEmptyString([savedContentRecord], [ + "bundle_relative_dir", + "bundleRelativeDir", + ]), + markdownRelativePath: readFirstNonEmptyString([savedContentRecord], [ + "markdown_relative_path", + "markdownRelativePath", + ]), + imagesRelativeDir: readFirstNonEmptyString([savedContentRecord], [ + "images_relative_dir", + "imagesRelativeDir", + ]), + metaRelativePath: readFirstNonEmptyString([savedContentRecord], [ + "meta_relative_path", + "metaRelativePath", + ]), + imageCount: readFirstFiniteNumber([savedContentRecord], [ + "image_count", + "imageCount", + ]), + } + : undefined, savedProjectId, savedBy: readFirstNonEmptyString(candidates, ["saved_by", "savedBy"]), saveSkippedProjectId, @@ -790,6 +842,33 @@ export const ToolCallDisplay: React.FC = ({ }); } + if (siteResultSummary.savedContent?.projectRootPath) { + notices.push({ + key: "site-save-project-root", + text: `项目目录:${siteResultSummary.savedContent.projectRootPath}`, + tone: "neutral", + }); + } + + if (siteResultSummary.savedContent?.markdownRelativePath) { + notices.push({ + key: "site-save-markdown-path", + text: `Markdown 文件:${siteResultSummary.savedContent.markdownRelativePath}`, + tone: "neutral", + }); + } + + if (typeof siteResultSummary.savedContent?.imageCount === "number") { + const imageDir = siteResultSummary.savedContent.imagesRelativeDir; + notices.push({ + key: "site-save-images", + text: `图片资源:${siteResultSummary.savedContent.imageCount} 张${ + imageDir ? ` · ${imageDir}` : "" + }`, + tone: "neutral", + }); + } + if (siteResultSummary.saveSkippedProjectId) { let text = toolCall.status === "failed" diff --git a/src/components/agent/chat/components/emptyStateSurfaceTokens.ts b/src/components/agent/chat/components/emptyStateSurfaceTokens.ts index 094dd0502..9fc800f68 100644 --- a/src/components/agent/chat/components/emptyStateSurfaceTokens.ts +++ b/src/components/agent/chat/components/emptyStateSurfaceTokens.ts @@ -1,4 +1,4 @@ -export type EmptyStateTone = "slate" | "sky" | "emerald" | "amber"; +export type EmptyStateTone = "slate" | "sky" | "emerald" | "amber" | "lime"; export const EMPTY_STATE_PANEL_CLASSNAME = "rounded-[26px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5 md:p-5"; @@ -18,6 +18,7 @@ export const EMPTY_STATE_BADGE_TONE_CLASSNAMES: Record = sky: "border-sky-200 bg-sky-50/90 text-sky-700", emerald: "border-emerald-200 bg-emerald-50/90 text-emerald-700", amber: "border-amber-200 bg-amber-50/90 text-amber-700", + lime: "border-lime-200 bg-lime-50/90 text-lime-700", }; export const EMPTY_STATE_ICON_TONE_CLASSNAMES: Record = @@ -26,6 +27,7 @@ export const EMPTY_STATE_ICON_TONE_CLASSNAMES: Record = sky: "border-sky-200 bg-sky-100/90 text-sky-700", emerald: "border-emerald-200 bg-emerald-100/90 text-emerald-700", amber: "border-amber-200 bg-amber-100/90 text-amber-700", + lime: "border-lime-200 bg-lime-100/90 text-lime-700", }; export const EMPTY_STATE_META_PILL_CLASSNAME = @@ -44,10 +46,10 @@ export const EMPTY_STATE_RECOMMENDATION_CARD_CLASSNAME = "group flex min-w-0 flex-col items-start gap-2 rounded-[20px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.97)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors hover:border-slate-300 hover:bg-white"; export const EMPTY_STATE_PAGE_CONTAINER_CLASSNAME = - "relative flex flex-1 flex-col items-stretch justify-start overflow-y-auto bg-[linear-gradient(135deg,rgba(244,250,255,0.94)_0%,rgba(248,250,252,0.98)_44%,rgba(244,250,247,0.96)_100%)] px-3 pb-5 pt-[clamp(8px,1.1vw,12px)] md:px-4"; + "relative flex min-h-full flex-1 flex-col items-stretch justify-start overflow-y-auto bg-transparent px-2 pb-5 pt-[clamp(2px,0.6vw,6px)] md:px-3"; export const EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME = - "relative z-[1] mx-auto flex w-full max-w-[1240px] flex-col items-stretch gap-2.5"; + "relative z-[1] mx-auto flex min-h-full w-full max-w-[1360px] flex-1 flex-col items-stretch gap-3"; export const EMPTY_STATE_THEME_TABS_CONTAINER_CLASSNAME = "flex w-full max-w-full flex-nowrap justify-start gap-1.5 overflow-x-auto overflow-y-hidden rounded-[20px] border border-slate-200/80 bg-slate-50 p-[5px] shadow-[0_10px_24px_-22px_rgba(15,23,42,0.18)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"; @@ -70,6 +72,8 @@ const EMPTY_STATE_TOOL_TOGGLE_TONE_CLASSNAMES: Record = "border-emerald-300 bg-emerald-50 text-emerald-700 hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700", amber: "border-amber-300 bg-amber-50 text-amber-700 hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700", + lime: + "border-lime-300 bg-lime-50 text-lime-700 hover:border-lime-300 hover:bg-lime-50 hover:text-lime-700", }; export function getEmptyStateIconToolButtonClassName( diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index f7ea86dff..9bb9a36c5 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -126,6 +126,185 @@ describe("agentChatHistory", () => { expect(messages[0]?.thinkingContent).toBe("先列提纲,再展开正文"); }); + it("应从历史 assistant 消息恢复 token usage", () => { + const detail: AsterSessionDetail = { + id: "session-usage", + created_at: 1, + updated_at: 2, + messages: [ + { + role: "assistant", + timestamp: 1710000300, + content: [{ type: "output_text", text: "图片已经生成完成" } as never], + usage: { + input_tokens: 12000, + output_tokens: 19000, + }, + }, + ], + }; + + const messages = hydrateSessionDetailMessages(detail, "session-usage"); + + expect(messages[0]?.usage).toEqual({ + input_tokens: 12000, + output_tokens: 19000, + }); + }); + + it("应从历史 tool_response 恢复图片任务预览,并保留同一任务的连续 assistant 轨迹", () => { + const detail: AsterSessionDetail = { + id: "session-history-image-task-preview", + created_at: 1, + updated_at: 2, + messages: [ + { + role: "assistant", + timestamp: 1710000400, + content: [ + { type: "text", text: "正在生成广州塔夜景海报" } as never, + { + type: "tool_request", + id: "tool-image-history-1", + tool_name: "bash", + arguments: { + command: + 'lime media image generate --prompt "广州塔夜景海报" --size 1536x1024 --count 1', + }, + } as never, + ], + }, + { + role: "tool", + timestamp: 1710000401, + content: [ + { + type: "tool_response", + id: "tool-image-history-1", + success: true, + output: "图片任务已提交", + metadata: { + task_id: "task-image-history-1", + task_type: "image_generate", + status: "succeeded", + project_id: "project-history-1", + content_id: "content-history-1", + requested_count: 1, + received_count: 1, + }, + } as never, + ], + }, + ], + }; + + const messages = hydrateSessionDetailMessages( + detail, + "session-history-image-task-preview", + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "assistant", + imageWorkbenchPreview: { + taskId: "task-image-history-1", + prompt: "广州塔夜景海报", + status: "complete", + size: "1536x1024", + imageCount: 1, + projectId: "project-history-1", + contentId: "content-history-1", + }, + }); + expect(messages[0]?.contentParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text" }), + expect.objectContaining({ + type: "tool_use", + toolCall: expect.objectContaining({ + id: "tool-image-history-1", + status: "completed", + }), + }), + ]), + ); + }); + + it("应从历史 tool_response 恢复视频任务预览,并保留视频结果地址与时长信息", () => { + const detail: AsterSessionDetail = { + id: "session-history-video-task-preview", + created_at: 1, + updated_at: 2, + messages: [ + { + role: "assistant", + timestamp: 1710000500, + content: [ + { type: "text", text: "正在生成广州塔城市短片" } as never, + { + type: "tool_request", + id: "tool-video-history-1", + tool_name: "bash", + arguments: { + command: + 'lime media video generate --prompt "广州塔城市短片" --duration 15 --aspect-ratio 16:9 --resolution 720p', + }, + } as never, + ], + }, + { + role: "tool", + timestamp: 1710000501, + content: [ + { + type: "tool_response", + id: "tool-video-history-1", + success: true, + output: "视频任务已完成", + metadata: { + task_id: "task-video-history-1", + task_type: "video_generate", + status: "succeeded", + project_id: "project-video-history-1", + content_id: "content-video-history-1", + result: { + videos: [ + { + url: "https://example.com/history-video.mp4", + duration_ms: 15000, + }, + ], + }, + }, + } as never, + ], + }, + ], + }; + + const messages = hydrateSessionDetailMessages( + detail, + "session-history-video-task-preview", + ); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "assistant", + taskPreview: { + kind: "video_generate", + taskId: "task-video-history-1", + status: "complete", + prompt: "广州塔城市短片", + durationSeconds: 15, + aspectRatio: "16:9", + resolution: "720p", + projectId: "project-video-history-1", + contentId: "content-video-history-1", + videoUrl: "https://example.com/history-video.mp4", + }, + }); + }); + it("应从内容片段中提取合并后的 thinkingContent", () => { expect( extractThinkingContentFromParts([ @@ -204,4 +383,49 @@ describe("agentChatHistory", () => { expect(mergedMessages).toEqual(localMessages); }); + + it("同会话刷新详情时应保留本地 assistant 的 token usage", () => { + const localMessages = [ + { + id: "local-user-1", + role: "user" as const, + content: "请整理成可继续编辑的文稿", + timestamp: new Date("2026-04-07T00:00:00.000Z"), + }, + { + id: "local-assistant-1", + role: "assistant" as const, + content: "已同步到工作区,可继续在画布里阅读、编辑和定位到对应区块。", + timestamp: new Date("2026-04-07T00:00:01.000Z"), + usage: { + input_tokens: 20480, + output_tokens: 10240, + }, + }, + ]; + const hydratedMessages = [ + { + id: "history-user-1", + role: "user" as const, + content: "请整理成可继续编辑的文稿", + timestamp: new Date("2026-04-07T00:00:02.000Z"), + }, + { + id: "history-assistant-1", + role: "assistant" as const, + content: "已同步到工作区,可继续在画布里阅读、编辑和定位到对应区块。", + timestamp: new Date("2026-04-07T00:00:03.000Z"), + }, + ]; + + const mergedMessages = mergeHydratedMessagesWithLocalState( + localMessages, + hydratedMessages, + ); + + expect(mergedMessages[1]?.usage).toEqual({ + input_tokens: 20480, + output_tokens: 10240, + }); + }); }); diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index 9a9fa78a6..4d75cc99a 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -1,8 +1,21 @@ -import type { AgentContextTraceStep as ContextTraceStep } from "@/lib/api/agentProtocol"; -import type { Message, MessageImage, ContentPart } from "../types"; +import type { + AgentContextTraceStep as ContextTraceStep, + AgentTokenUsage, +} from "@/lib/api/agentProtocol"; +import type { + ContentPart, + Message, + MessageImage, + MessageImageWorkbenchPreview, + MessageTaskPreview, +} from "../types"; import type { AsterSessionDetail } from "@/lib/api/agentRuntime"; import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; import { mergeArtifacts } from "../utils/messageArtifacts"; +import { + buildImageTaskPreviewFromToolResult, + buildTaskPreviewFromToolResult, +} from "../utils/taskPreviewFromToolResult"; import { extractLimeToolMetadataBlock, isToolResultSuccessful, @@ -54,6 +67,30 @@ export const normalizeHistoryMessages = (messages: Message[]): Message[] => .map((msg) => normalizeHistoryMessage(msg)) .filter((msg): msg is Message => msg !== null); +const normalizeHistoryUsage = (usage: unknown): AgentTokenUsage | undefined => { + if (!usage || typeof usage !== "object") { + return undefined; + } + + const inputTokens = (usage as { input_tokens?: unknown }).input_tokens; + const outputTokens = (usage as { output_tokens?: unknown }).output_tokens; + if ( + typeof inputTokens !== "number" || + typeof outputTokens !== "number" || + !Number.isFinite(inputTokens) || + !Number.isFinite(outputTokens) || + inputTokens < 0 || + outputTokens < 0 + ) { + return undefined; + } + + return { + input_tokens: inputTokens, + output_tokens: outputTokens, + }; +}; + export const hasLegacyFallbackToolNames = (messages: Message[]): boolean => messages.some((message) => (message.toolCalls || []).some((toolCall) => @@ -135,6 +172,147 @@ export const extractThinkingContentFromParts = ( return thinkingText || undefined; }; +function mergeImageWorkbenchPreview( + previous?: MessageImageWorkbenchPreview, + next?: MessageImageWorkbenchPreview, +): MessageImageWorkbenchPreview | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + if (previous.taskId !== next.taskId) { + return next; + } + return { + ...previous, + ...next, + }; +} + +function mergeTaskPreview( + previous?: MessageTaskPreview, + next?: MessageTaskPreview, +): MessageTaskPreview | undefined { + if (!previous) { + return next; + } + if (!next) { + return previous; + } + if (previous.taskId !== next.taskId) { + return next; + } + return { + ...previous, + ...next, + }; +} + +function normalizePreviewSignatureValue(value: unknown): string { + if (typeof value === "string") { + return normalizeSignatureText(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +function imageWorkbenchPreviewSignature( + preview?: MessageImageWorkbenchPreview, +): string { + if (!preview) { + return ""; + } + + return [ + preview.taskId, + preview.prompt, + preview.mode, + preview.status, + preview.projectId, + preview.contentId, + preview.imageUrl, + preview.imageCount, + preview.sourceImageUrl, + preview.sourceImagePrompt, + preview.sourceImageRef, + preview.sourceImageCount, + preview.size, + preview.phase, + preview.statusMessage, + preview.retryable, + preview.attemptCount, + preview.placeholderText, + ] + .map(normalizePreviewSignatureValue) + .join(":"); +} + +function taskPreviewSignature(preview?: MessageTaskPreview): string { + if (!preview) { + return ""; + } + + const videoFields = + preview.kind === "video_generate" + ? [ + preview.videoUrl, + preview.thumbnailUrl, + preview.durationSeconds, + preview.aspectRatio, + preview.resolution, + preview.progress, + preview.retryable, + ] + : []; + const metaItems = + "metaItems" in preview && Array.isArray(preview.metaItems) + ? preview.metaItems.map((item) => normalizeSignatureText(item)).join("|") + : ""; + const imageCandidates = + "imageCandidates" in preview && Array.isArray(preview.imageCandidates) + ? preview.imageCandidates + .map((candidate) => + [ + candidate.id, + candidate.thumbnailUrl, + candidate.contentUrl, + candidate.hostPageUrl, + candidate.width, + candidate.height, + candidate.name, + ] + .map(normalizePreviewSignatureValue) + .join(":"), + ) + .join("|") + : ""; + + return [ + preview.kind, + preview.taskId, + preview.taskType, + preview.prompt, + "title" in preview ? preview.title : "", + preview.status, + preview.projectId, + preview.contentId, + "artifactPath" in preview ? preview.artifactPath : "", + "providerId" in preview ? preview.providerId : "", + "model" in preview ? preview.model : "", + preview.phase, + preview.statusMessage, + ...videoFields, + metaItems, + imageCandidates, + ] + .map(normalizePreviewSignatureValue) + .join(":"); +} + export const mergeAdjacentAssistantMessages = ( messages: Message[], ): Message[] => { @@ -223,6 +401,14 @@ export const mergeAdjacentAssistantMessages = ( ...(previous.artifacts || []), ...(current.artifacts || []), ]); + const imageWorkbenchPreview = mergeImageWorkbenchPreview( + previous.imageWorkbenchPreview, + current.imageWorkbenchPreview, + ); + const taskPreview = mergeTaskPreview( + previous.taskPreview, + current.taskPreview, + ); merged[merged.length - 1] = { ...previous, @@ -231,6 +417,8 @@ export const mergeAdjacentAssistantMessages = ( toolCalls: toolCalls.length > 0 ? toolCalls : undefined, contextTrace: contextTrace.length > 0 ? contextTrace : undefined, artifacts: artifacts.length > 0 ? artifacts : undefined, + imageWorkbenchPreview, + taskPreview, timestamp: current.timestamp, isThinking: false, thinkingContent: extractThinkingContentFromParts(contentParts), @@ -268,6 +456,38 @@ const findMatchingLocalUserMessageIndex = ( return -1; }; +const findMatchingLocalAssistantMessageIndex = ( + localAssistantMessages: Message[], + targetMessage: Message, + startIndex: number, +): number => { + const targetSignature = buildHistoryMessageSignature({ + ...targetMessage, + usage: undefined, + }); + + for ( + let index = startIndex; + index < localAssistantMessages.length; + index += 1 + ) { + const candidate = localAssistantMessages[index]; + if (!candidate) { + continue; + } + + const candidateSignature = buildHistoryMessageSignature({ + ...candidate, + usage: undefined, + }); + if (candidateSignature === targetSignature) { + return index; + } + } + + return -1; +}; + export const mergeHydratedMessagesWithLocalState = ( localMessages: Message[], hydratedMessages: Message[], @@ -279,14 +499,90 @@ export const mergeHydratedMessagesWithLocalState = ( const localUserMessages = localMessages.filter( (message) => message.role === "user", ); + const localAssistantMessages = localMessages.filter( + (message) => message.role === "assistant", + ); + const localImagePreviewByTaskId = new Map< + string, + MessageImageWorkbenchPreview + >(); + const localTaskPreviewByTaskId = new Map(); - if (localUserMessages.length === 0) { + localMessages.forEach((message) => { + if (message.role !== "assistant") { + return; + } + + const imageTaskId = message.imageWorkbenchPreview?.taskId; + if (imageTaskId) { + localImagePreviewByTaskId.set( + imageTaskId, + message.imageWorkbenchPreview as MessageImageWorkbenchPreview, + ); + } + + const taskPreviewId = message.taskPreview?.taskId; + if (taskPreviewId) { + localTaskPreviewByTaskId.set( + taskPreviewId, + message.taskPreview as MessageTaskPreview, + ); + } + }); + + if ( + localUserMessages.length === 0 && + localAssistantMessages.length === 0 && + localImagePreviewByTaskId.size === 0 && + localTaskPreviewByTaskId.size === 0 + ) { return hydratedMessages; } let localUserCursor = 0; + let localAssistantCursor = 0; return hydratedMessages.map((message) => { + if (message.role === "assistant") { + const matchedAssistantIndex = findMatchingLocalAssistantMessageIndex( + localAssistantMessages, + message, + localAssistantCursor, + ); + const localAssistantMessage = + matchedAssistantIndex >= 0 + ? localAssistantMessages[matchedAssistantIndex] + : undefined; + if (matchedAssistantIndex >= 0) { + localAssistantCursor = matchedAssistantIndex + 1; + } + + const localImagePreview = message.imageWorkbenchPreview?.taskId + ? localImagePreviewByTaskId.get(message.imageWorkbenchPreview.taskId) + : undefined; + const localTaskPreview = message.taskPreview?.taskId + ? localTaskPreviewByTaskId.get(message.taskPreview.taskId) + : undefined; + + if ( + !localImagePreview && + !localTaskPreview && + !localAssistantMessage?.usage + ) { + return message; + } + + return { + ...message, + usage: message.usage ?? localAssistantMessage?.usage, + imageWorkbenchPreview: mergeImageWorkbenchPreview( + localImagePreview, + message.imageWorkbenchPreview, + ), + taskPreview: mergeTaskPreview(localTaskPreview, message.taskPreview), + }; + } + if (message.role !== "user") { return message; } @@ -385,6 +681,9 @@ const messageArtifactsSignature = ( }; const buildHistoryMessageSignature = (message: Message): string => { + const usageSignature = message.usage + ? `${message.usage.input_tokens}:${message.usage.output_tokens}` + : ""; return [ message.role, normalizeSignatureText(message.content), @@ -392,6 +691,9 @@ const buildHistoryMessageSignature = (message: Message): string => { messageToolCallsSignature(message.toolCalls), messageContentPartsSignature(message.contentParts), messageArtifactsSignature(message.artifacts), + imageWorkbenchPreviewSignature(message.imageWorkbenchPreview), + taskPreviewSignature(message.taskPreview), + usageSignature, ].join("::"); }; @@ -425,6 +727,7 @@ export const hydrateSessionDetailMessages = ( topicId: string, ): Message[] => { const historyToolNameById = new Map(); + const historyToolArgumentsById = new Map(); const loadedMessages: Message[] = detail.messages .filter( @@ -438,6 +741,8 @@ export const hydrateSessionDetailMessages = ( const images: MessageImage[] = []; const messageTimestamp = new Date(msg.timestamp * 1000); const rawParts = Array.isArray(msg.content) ? msg.content : []; + let imageWorkbenchPreview: MessageImageWorkbenchPreview | undefined; + let taskPreview: MessageTaskPreview | undefined; const appendText = (value: unknown) => { if (typeof value !== "string") return; @@ -526,6 +831,7 @@ export const hydrateSessionDetailMessages = ( startTime: messageTimestamp, }; historyToolNameById.set(part.id, toolName); + historyToolArgumentsById.set(part.id, toolCall.arguments); toolCalls.push(toolCall); contentParts.push({ type: "tool_use", toolCall }); continue; @@ -554,6 +860,13 @@ export const hydrateSessionDetailMessages = ( ), }; const success = isToolResultSuccessful(normalizedResult); + const normalizedResultRecord = + normalizedResult && + typeof normalizedResult === "object" && + !Array.isArray(normalizedResult) + ? (normalizedResult as Record) + : undefined; + const toolArguments = historyToolArgumentsById.get(part.id); const toolCall = { id: part.id, name: toolName, @@ -567,6 +880,26 @@ export const hydrateSessionDetailMessages = ( }; toolCalls.push(toolCall); contentParts.push({ type: "tool_use", toolCall }); + imageWorkbenchPreview = mergeImageWorkbenchPreview( + imageWorkbenchPreview, + buildImageTaskPreviewFromToolResult({ + toolId: part.id, + toolName, + toolArguments, + toolResult: normalizedResultRecord, + fallbackPrompt: textParts.join("\n").trim(), + }) || undefined, + ); + taskPreview = mergeTaskPreview( + taskPreview, + buildTaskPreviewFromToolResult({ + toolId: part.id, + toolName, + toolArguments, + toolResult: normalizedResultRecord, + fallbackPrompt: textParts.join("\n").trim(), + }) || undefined, + ); continue; } @@ -592,6 +925,7 @@ export const hydrateSessionDetailMessages = ( const rawContent = textParts.join("\n").trim(); let normalizedRole = msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant"); + const usage = normalizeHistoryUsage(msg.usage); const content = sanitizeMessageTextForDisplay(rawContent, { role: normalizedRole, hasImages: images.length > 0, @@ -635,9 +969,12 @@ export const hydrateSessionDetailMessages = ( toolCalls: toolCalls.length > 0 ? toolCalls : undefined, timestamp: messageTimestamp, isThinking: false, + usage: normalizedRole === "assistant" ? usage : undefined, thinkingContent: extractThinkingContentFromParts( sanitizedContentParts, ), + imageWorkbenchPreview, + taskPreview, }, ]; }); diff --git a/src/components/agent/chat/hooks/agentChatSendMessage.test.ts b/src/components/agent/chat/hooks/agentChatSendMessage.test.ts index f4dbb0c4b..8c8d40126 100644 --- a/src/components/agent/chat/hooks/agentChatSendMessage.test.ts +++ b/src/components/agent/chat/hooks/agentChatSendMessage.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SendMessageFn } from "./agentChatShared"; import { createAgentChatSendMessage } from "./agentChatSendMessage"; +import { listSlashEntryUsage } from "../skill-selection/slashEntryUsage"; + +beforeEach(() => { + window.localStorage.clear(); +}); describe("createAgentChatSendMessage", () => { it("普通消息应直接透传到 rawSendMessage", async () => { @@ -74,6 +79,12 @@ describe("createAgentChatSendMessage", () => { expect(appendAssistantMessage).toHaveBeenCalledWith( expect.stringContaining("自动路由"), ); + expect(listSlashEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "command", + entryId: "status", + }), + ]); }); it("命中 prompt slash 命令时应转换 prompt 后透传", async () => { @@ -102,6 +113,12 @@ describe("createAgentChatSendMessage", () => { expect(rawSendMessage).toHaveBeenCalledTimes(1); expect(rawSendMessage.mock.calls[0]?.[0]).toContain("请对以下对象进行代码审查"); expect(rawSendMessage.mock.calls[0]?.[0]).toContain("src-tauri"); + expect(listSlashEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "command", + entryId: "review", + }), + ]); }); it("skipUserMessage 为 true 时应绕过 slash 分流", async () => { diff --git a/src/components/agent/chat/hooks/agentChatSendMessage.ts b/src/components/agent/chat/hooks/agentChatSendMessage.ts index 04f517226..e20d939d5 100644 --- a/src/components/agent/chat/hooks/agentChatSendMessage.ts +++ b/src/components/agent/chat/hooks/agentChatSendMessage.ts @@ -3,6 +3,7 @@ import { executeCodexSlashCommand, parseCodexSlashCommand, } from "../commands"; +import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import type { ClearMessagesOptions, SendMessageFn, @@ -77,6 +78,16 @@ export function createAgentChatSendMessage( appendAssistantMessage, notifyInfo, notifySuccess, + onExecutedCommand: (command) => { + if (command.definition.support !== "supported") { + return; + } + + recordSlashEntryUsage({ + kind: "command", + entryId: command.definition.key, + }); + }, }); if (handled) { return; diff --git a/src/components/agent/chat/hooks/agentChatShared.ts b/src/components/agent/chat/hooks/agentChatShared.ts index 5b24b8f62..da9f17f39 100644 --- a/src/components/agent/chat/hooks/agentChatShared.ts +++ b/src/components/agent/chat/hooks/agentChatShared.ts @@ -47,6 +47,8 @@ export interface UseAsterAgentChatOptions { ) => void; workspaceId: string; disableSessionRestore?: boolean; + initialTopicsLoadMode?: "immediate" | "deferred"; + initialTopicsDeferredDelayMs?: number; getSyncedSessionRecentPreferences?: ( sessionId: string, ) => ChatToolPreferences | null; diff --git a/src/components/agent/chat/hooks/agentStreamEventProcessor.ts b/src/components/agent/chat/hooks/agentStreamEventProcessor.ts index 198db5e95..56c61a3cd 100644 --- a/src/components/agent/chat/hooks/agentStreamEventProcessor.ts +++ b/src/components/agent/chat/hooks/agentStreamEventProcessor.ts @@ -12,6 +12,7 @@ import type { Artifact } from "@/lib/artifact/types"; import { extractArtifactProtocolPaths, extractArtifactProtocolPathsFromValue, + isArtifactProtocolImagePath, resolveArtifactProtocolDocumentPayload, resolveArtifactProtocolFilePath, resolveArtifactProtocolPreviewText, @@ -118,6 +119,18 @@ function extractPatchPath(rawText?: string): string | undefined { return undefined; } +function shouldSkipBinaryArtifactWrite(params: { + filePath: string; + content: string; + source: WriteArtifactContext["source"]; +}): boolean { + return ( + params.content.length === 0 && + isArtifactProtocolImagePath(params.filePath) && + (params.source === "tool_result" || params.source === "artifact_snapshot") + ); +} + function extractPatchText( toolArgs: Record | null, ): string | undefined { @@ -765,6 +778,16 @@ export function handleToolEndEvent({ } for (const artifactPath of artifactPaths) { + if ( + shouldSkipBinaryArtifactWrite({ + filePath: artifactPath, + content: "", + source: "tool_result", + }) + ) { + continue; + } + const writeContext: WriteArtifactContext = { artifactId: `artifact:${assistantMsgId}:${artifactPath}`, source: "tool_result", @@ -822,6 +845,15 @@ export function handleArtifactSnapshotEvent({ const metadata = data.artifact.metadata; const snapshotContent = typeof data.artifact.content === "string" ? data.artifact.content : ""; + if ( + shouldSkipBinaryArtifactWrite({ + filePath: artifactPath, + content: snapshotContent, + source: "artifact_snapshot", + }) + ) { + return; + } const writeContext: WriteArtifactContext = { artifactId: data.artifact.artifactId || `artifact:${assistantMsgId}:${artifactPath}`, diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts new file mode 100644 index 000000000..71d488854 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentEvent } from "@/lib/api/agentProtocol"; +import type { Message } from "../types"; +import { handleTurnStreamEvent } from "./agentStreamRuntimeHandler"; + +describe("agentStreamRuntimeHandler", () => { + it("收到 final_done 时应把 usage 写回 assistant 消息", () => { + let messages: Message[] = [ + { + id: "assistant-1", + role: "assistant", + content: "图片已经生成完成", + timestamp: new Date("2026-04-07T10:00:00.000Z"), + isThinking: true, + }, + ]; + + const setMessages = vi.fn( + (value: Message[] | ((prev: Message[]) => Message[])) => { + messages = typeof value === "function" ? value(messages) : value; + }, + ); + + handleTurnStreamEvent({ + data: { + type: "final_done", + usage: { + input_tokens: 12_000, + output_tokens: 19_000, + }, + } as AgentEvent, + requestState: { + accumulatedContent: "图片已经生成完成", + queuedTurnId: null, + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + }, + callbacks: { + activateStream: () => {}, + isStreamActivated: () => true, + clearOptimisticItem: () => {}, + clearOptimisticTurn: () => {}, + disposeListener: () => {}, + removeQueuedDraftMessages: () => {}, + clearActiveStreamIfMatch: () => true, + upsertQueuedTurn: () => {}, + removeQueuedTurnState: () => {}, + playToolcallSound: () => {}, + playTypewriterSound: () => {}, + appendThinkingToParts: (parts) => parts, + }, + eventName: "agent-runtime-test", + pendingTurnKey: "pending-turn", + pendingItemKey: "pending-item", + assistantMsgId: "assistant-1", + activeSessionId: "session-1", + resolvedWorkspaceId: "workspace-1", + effectiveExecutionStrategy: "react", + content: "图片已经生成完成", + runtime: {} as never, + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + setMessages: setMessages as never, + setPendingActions: vi.fn() as never, + setThreadItems: vi.fn() as never, + setThreadTurns: vi.fn() as never, + setCurrentTurnId: vi.fn() as never, + setExecutionRuntime: vi.fn() as never, + setIsSending: vi.fn() as never, + }); + + expect(setMessages).toHaveBeenCalled(); + expect(messages[0]).toMatchObject({ + isThinking: false, + usage: { + input_tokens: 12_000, + output_tokens: 19_000, + }, + }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts index 46ca6c564..ef502ed0f 100644 --- a/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts +++ b/src/components/agent/chat/hooks/agentStreamRuntimeHandler.ts @@ -34,6 +34,7 @@ import { buildFailedAgentRuntimeStatus, formatAgentRuntimeStatusSummary, } from "../utils/agentRuntimeStatus"; +import { resolveAgentRuntimeErrorPresentation } from "../utils/agentRuntimeErrorPresentation"; import { normalizeLegacyRuntimeStatusTitle } from "@/lib/api/agentTextNormalization"; import { resolveRuntimeWarningToastPresentation } from "./runtimeWarningPresentation"; import { buildQueuedRuntimeStatus } from "./agentStreamSubmitDraft"; @@ -349,7 +350,8 @@ export function handleTurnStreamEvent({ ...data.status, title: normalizeLegacyRuntimeStatusTitle(data.status.title), }; - const nextSummaryText = formatAgentRuntimeStatusSummary(normalizedStatus); + const nextSummaryText = + formatAgentRuntimeStatusSummary(normalizedStatus); const updatedAt = new Date().toISOString(); setThreadItems((prev) => { const runtimeSummaryItem = @@ -362,7 +364,10 @@ export function handleTurnStreamEvent({ item.type === "turn_summary" && item.status === "in_progress", ); - if (!runtimeSummaryItem || runtimeSummaryItem.type !== "turn_summary") { + if ( + !runtimeSummaryItem || + runtimeSummaryItem.type !== "turn_summary" + ) { return prev; } @@ -431,7 +436,10 @@ export function handleTurnStreamEvent({ ...msg, content: requestState.accumulatedContent, thinkingContent: undefined, - contentParts: appendTextToParts(msg.contentParts || [], data.text), + contentParts: appendTextToParts( + msg.contentParts || [], + data.text, + ), } : msg, ), @@ -516,7 +524,9 @@ export function handleTurnStreamEvent({ case "final_done": { clearOptimisticItem(); clearOptimisticTurn(); - removeQueuedTurnState(requestState.queuedTurnId ? [requestState.queuedTurnId] : []); + removeQueuedTurnState( + requestState.queuedTurnId ? [requestState.queuedTurnId] : [], + ); finishRequestLog(requestState, { eventType: "chat_request_complete", status: "success", @@ -537,6 +547,7 @@ export function handleTurnStreamEvent({ isThinking: false, content: finalContent, runtimeStatus: undefined, + usage: data.usage ?? msg.usage, } : msg, ), @@ -548,7 +559,9 @@ export function handleTurnStreamEvent({ case "error": { markFailedTimelineState(data.message); - removeQueuedTurnState(requestState.queuedTurnId ? [requestState.queuedTurnId] : []); + removeQueuedTurnState( + requestState.queuedTurnId ? [requestState.queuedTurnId] : [], + ); finishRequestLog(requestState, { eventType: "chat_request_error", status: "error", @@ -562,7 +575,9 @@ export function handleTurnStreamEvent({ ) { toast.warning("请求过于频繁,请稍后重试"); } else { - toast.error(`响应错误: ${data.message}`); + toast.error( + resolveAgentRuntimeErrorPresentation(data.message).toastMessage, + ); } setMessages((prev) => prev.map((msg) => diff --git a/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.test.ts b/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.test.ts new file mode 100644 index 000000000..bcb5e146d --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PreparedAgentStreamUserInputSend } from "./agentStreamUserInputSendPreparation"; +import { maybeHandleSlashSkillBeforeSend } from "./agentStreamSlashSkillPreflight"; + +const { + mockParseSkillSlashCommand, + mockTryExecuteSlashSkillCommand, +} = vi.hoisted(() => ({ + mockParseSkillSlashCommand: vi.fn(), + mockTryExecuteSlashSkillCommand: vi.fn(), +})); + +vi.mock("./skillCommand", () => ({ + parseSkillSlashCommand: (...args: unknown[]) => + mockParseSkillSlashCommand(...args), + tryExecuteSlashSkillCommand: (...args: unknown[]) => + mockTryExecuteSlashSkillCommand(...args), +})); + +function createPreparedSend( + overrides: Partial = {}, +): PreparedAgentStreamUserInputSend { + return { + content: "/legacy_content_post 写一版主稿", + images: [], + skipUserMessage: false, + expectingQueue: false, + effectiveExecutionStrategy: "react", + effectiveProviderType: "openai", + effectiveModel: "gpt-5.4", + syncedSessionModelPreference: null, + assistantMsgId: "assistant-1", + userMsgId: "user-1", + assistantMsg: { + id: "assistant-1", + role: "assistant", + content: "", + timestamp: new Date("2026-04-07T12:00:00.000Z"), + contentParts: [], + }, + ...overrides, + }; +} + +function createEnv() { + return { + ensureSession: async () => "session-1", + sessionIdRef: { current: null }, + activeStreamRef: { current: null }, + listenerMapRef: { current: new Map() }, + setMessages: vi.fn(), + setIsSending: vi.fn(), + setActiveStream: vi.fn(), + clearActiveStreamIfMatch: vi.fn(() => false), + playTypewriterSound: vi.fn(), + playToolcallSound: vi.fn(), + onWriteFile: vi.fn(), + } as never; +} + +describe("agentStreamSlashSkillPreflight", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockParseSkillSlashCommand.mockReturnValue({ + skillName: "legacy_content_post", + userInput: "写一版主稿", + }); + mockTryExecuteSlashSkillCommand.mockResolvedValue(true); + }); + + it.each([ + { + key: "service_scene_launch", + launch: { + kind: "cloud_scene", + service_scene_run: { + scene_key: "campaign-launch", + }, + }, + }, + { + key: "service_skill_launch", + launch: { + kind: "site_adapter", + skill_id: "x-article-export", + }, + }, + ])( + "已携带 $key metadata 时不应再回退旧 slash skill preflight", + async ({ key, launch }) => { + const env = createEnv(); + const handled = await maybeHandleSlashSkillBeforeSend({ + preparedSend: createPreparedSend({ + content: + key === "service_scene_launch" + ? "/campaign-launch 帮我做一版新品活动方案" + : "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + requestMetadata: { + harness: { + [key]: launch, + }, + }, + }), + env, + }); + + expect(handled).toBe(false); + expect(mockParseSkillSlashCommand).not.toHaveBeenCalled(); + expect(mockTryExecuteSlashSkillCommand).not.toHaveBeenCalled(); + expect(env.setActiveStream).not.toHaveBeenCalled(); + }, + ); + + it("未携带结构化 scene metadata 时仍应继续尝试旧 slash skill", async () => { + const env = createEnv(); + const handled = await maybeHandleSlashSkillBeforeSend({ + preparedSend: createPreparedSend(), + env, + }); + + expect(handled).toBe(true); + expect(mockParseSkillSlashCommand).toHaveBeenCalledWith( + "/legacy_content_post 写一版主稿", + ); + expect(mockTryExecuteSlashSkillCommand).toHaveBeenCalledTimes(1); + expect(env.setActiveStream).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.ts b/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.ts index 57f642ba3..9d2c02395 100644 --- a/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.ts +++ b/src/components/agent/chat/hooks/agentStreamSlashSkillPreflight.ts @@ -2,6 +2,7 @@ import { parseSkillSlashCommand, tryExecuteSlashSkillCommand, } from "./skillCommand"; +import { extractExistingHarnessMetadata } from "../utils/harnessRequestMetadata"; import type { PreparedAgentStreamUserInputSend } from "./agentStreamUserInputSendPreparation"; import type { AgentStreamPreparedSendEnv } from "./agentStreamPreparedSendEnv"; @@ -25,6 +26,31 @@ interface MaybeHandleSlashSkillBeforeSendOptions { env: SlashSkillPreflightEnv; } +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + return value as Record; +} + +function hasStructuredSlashLaunchMetadata( + requestMetadata?: Record, +): boolean { + const harness = extractExistingHarnessMetadata(requestMetadata); + if (!harness) { + return false; + } + + const launch = + asRecord(harness.service_scene_launch) ?? + asRecord(harness.serviceSceneLaunch) ?? + asRecord(harness.service_skill_launch) ?? + asRecord(harness.serviceSkillLaunch); + + return Boolean(launch && Object.keys(launch).length > 0); +} + export async function maybeHandleSlashSkillBeforeSend( options: MaybeHandleSlashSkillBeforeSendOptions, ): Promise { @@ -42,6 +68,10 @@ export async function maybeHandleSlashSkillBeforeSend( return false; } + if (hasStructuredSlashLaunchMetadata(preparedSend.requestMetadata)) { + return false; + } + const parsedSkillCommand = parseSkillSlashCommand(content); if (!parsedSkillCommand) { return false; diff --git a/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts b/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts index 365b20c7e..98ae34c97 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitFailure.ts @@ -7,6 +7,7 @@ import { buildFailedAgentMessageContent, buildFailedAgentRuntimeStatus, } from "../utils/agentRuntimeStatus"; +import { resolveAgentRuntimeErrorPresentation } from "../utils/agentRuntimeErrorPresentation"; import { isWorkspacePathErrorMessage } from "./agentChatCoreUtils"; import type { ActiveStreamState, StreamRequestState } from "./agentStreamSubmissionLifecycle"; import type { WorkspacePathMissingState } from "./agentChatShared"; @@ -78,7 +79,12 @@ export function handleAgentStreamSubmitFailure( } else if (isWorkspacePathErrorMessage(errMsg)) { setWorkspacePathMissing({ content, images }); } else { - toast.error(`发送失败: ${error}`); + const presentation = resolveAgentRuntimeErrorPresentation(errMsg); + toast.error( + presentation.toastMessage.startsWith("响应错误:") + ? presentation.toastMessage.replace(/^响应错误:/, "发送失败:") + : presentation.toastMessage, + ); } markOptimisticFailure(errMsg); diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts index d404925e2..365422faa 100644 --- a/src/components/agent/chat/hooks/index.ts +++ b/src/components/agent/chat/hooks/index.ts @@ -19,6 +19,8 @@ interface UseAgentChatUnifiedOptions { ) => void; workspaceId: string; disableSessionRestore?: boolean; + initialTopicsLoadMode?: "immediate" | "deferred"; + initialTopicsDeferredDelayMs?: number; getSyncedSessionRecentPreferences?: ( sessionId: string, ) => import("../utils/chatToolPreferences").ChatToolPreferences | null; diff --git a/src/components/agent/chat/hooks/skillCommand.test.ts b/src/components/agent/chat/hooks/skillCommand.test.ts index d863a8f6b..74eac5168 100644 --- a/src/components/agent/chat/hooks/skillCommand.test.ts +++ b/src/components/agent/chat/hooks/skillCommand.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Dispatch, SetStateAction } from "react"; import type { UnlistenFn } from "@tauri-apps/api/event"; import type { Message } from "../types"; +import { listSlashEntryUsage } from "../skill-selection/slashEntryUsage"; const { mockSafeListen, @@ -68,6 +69,7 @@ function buildBaseMessage(id = "assistant-1"): Message { beforeEach(() => { vi.clearAllMocks(); + window.localStorage.clear(); mockParseAgentEvent.mockImplementation((payload: unknown) => payload); mockListExecutableSkills.mockResolvedValue([ { @@ -82,6 +84,7 @@ beforeEach(() => { }); afterEach(() => { + window.localStorage.clear(); vi.clearAllMocks(); }); @@ -95,6 +98,14 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => { }); }); + it("中文 scene slash 不应被旧 slash skill 解析器误判", () => { + expect( + parseSkillSlashCommand( + "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + ), + ).toBeNull(); + }); + it("当后端连续发出 write_file 工具事件时应写入主稿与辅助产物", async () => { const store = createMessageStore([buildBaseMessage()]); const onWriteFile = vi.fn(); @@ -233,6 +244,44 @@ describe("tryExecuteSlashSkillCommand 社媒主链路", () => { expect(onWriteFile).not.toHaveBeenCalled(); }); + it("slash skill 真正执行成功后应写入最近使用", async () => { + const store = createMessageStore([buildBaseMessage()]); + + mockExecuteSkill.mockResolvedValue({ + success: true, + output: "Skill 执行完成", + steps_completed: [], + }); + + const handled = await tryExecuteSlashSkillCommand({ + command: { + skillName: "content_post_with_cover", + userInput: "写一版主稿", + }, + rawContent: "/content_post_with_cover 写一版主稿", + assistantMsgId: "assistant-1", + providerType: "anthropic", + model: "claude-sonnet-4-20250514", + ensureSession: async () => "session-1", + setMessages: store.setMessages, + setIsSending: vi.fn(), + setCurrentAssistantMsgId: vi.fn(), + setStreamUnlisten: vi.fn(), + setActiveSessionIdForStop: vi.fn(), + isExecutionCancelled: () => false, + playTypewriterSound: vi.fn(), + playToolcallSound: vi.fn(), + }); + + expect(handled).toBe(true); + expect(listSlashEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "skill", + entryId: "content_post_with_cover", + }), + ]); + }); + it("收到 artifact_snapshot 时应立刻透传给 onWriteFile", async () => { const store = createMessageStore([buildBaseMessage()]); const onWriteFile = vi.fn(); diff --git a/src/components/agent/chat/hooks/skillCommand.ts b/src/components/agent/chat/hooks/skillCommand.ts index 50817a67f..95c7cc728 100644 --- a/src/components/agent/chat/hooks/skillCommand.ts +++ b/src/components/agent/chat/hooks/skillCommand.ts @@ -23,6 +23,7 @@ import { isToolResultSuccessful, normalizeIncomingToolResult, } from "./agentChatToolResult"; +import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import { CONTENT_POST_OUTPUT_DIR, CONTENT_POST_SKILL_KEY, @@ -127,7 +128,7 @@ function buildSocialPostFallbackPath( export function parseSkillSlashCommand( content: string, ): ParsedSkillCommand | null { - const skillMatch = content.match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/); + const skillMatch = content.match(/^\/([a-zA-Z0-9_-]+)(?:\s+([\s\S]*))?$/); if (!skillMatch) { return null; } @@ -913,6 +914,13 @@ ${failureText}` onWriteFile(finalContent, fallbackPath); } + if (!failure) { + recordSlashEntryUsage({ + kind: "skill", + entryId: command.skillName, + }); + } + cleanup(); return true; } catch (error) { diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index 2b2670282..6887cd2a9 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -61,11 +61,16 @@ import { } from "./agentSessionRefresh"; import type { AgentAccessMode } from "./agentChatStorage"; import { normalizeLegacyThreadItems } from "@/lib/api/agentTextNormalization"; +import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; + +const INITIAL_TOPICS_IDLE_TIMEOUT_MS = 1_500; interface UseAgentSessionOptions { runtime: AgentRuntimeAdapter; workspaceId: string; disableSessionRestore: boolean; + initialTopicsLoadMode: "immediate" | "deferred"; + initialTopicsDeferredDelayMs?: number; preserveRestoredMessages: boolean; executionStrategy: AsterExecutionStrategy; accessMode: AgentAccessMode; @@ -110,6 +115,8 @@ export function useAgentSession(options: UseAgentSessionOptions) { runtime, workspaceId, disableSessionRestore, + initialTopicsLoadMode, + initialTopicsDeferredDelayMs, preserveRestoredMessages, executionStrategy, accessMode, @@ -412,53 +419,75 @@ export function useAgentSession(options: UseAgentSessionOptions) { return; } - setTopicsReady(false); - const startedAt = Date.now(); - logAgentDebug("useAgentSession", "listSessions.start", { - workspaceId, - }); - runtime - .listSessions() - .then((sessions) => { - if (cancelled) { - return; - } - const topicList = - filterSessionsByWorkspace(sessions).map(mapSessionToTopic); - logAgentDebug("useAgentSession", "listSessions.success", { - durationMs: Date.now() - startedAt, - sessionsCount: sessions.length, - topicsCount: topicList.length, - workspaceId, - }); - setTopics(topicList); - }) - .catch((error) => { - if (cancelled) { - return; - } - console.error("[AsterChat] 加载话题失败:", error); - logAgentDebug( - "useAgentSession", - "listSessions.error", - { - durationMs: Date.now() - startedAt, - error, - workspaceId, - }, - { level: "error" }, - ); - }) - .finally(() => { - if (!cancelled) { - setTopicsReady(true); - } + const runListSessions = () => { + setTopicsReady(false); + const startedAt = Date.now(); + logAgentDebug("useAgentSession", "listSessions.start", { + workspaceId, }); + runtime + .listSessions() + .then((sessions) => { + if (cancelled) { + return; + } + const topicList = + filterSessionsByWorkspace(sessions).map(mapSessionToTopic); + logAgentDebug("useAgentSession", "listSessions.success", { + durationMs: Date.now() - startedAt, + sessionsCount: sessions.length, + topicsCount: topicList.length, + workspaceId, + }); + setTopics(topicList); + }) + .catch((error) => { + if (cancelled) { + return; + } + console.error("[AsterChat] 加载话题失败:", error); + logAgentDebug( + "useAgentSession", + "listSessions.error", + { + durationMs: Date.now() - startedAt, + error, + workspaceId, + }, + { level: "error" }, + ); + }) + .finally(() => { + if (!cancelled) { + setTopicsReady(true); + } + }); + }; + + if (initialTopicsLoadMode === "deferred") { + setTopicsReady(true); + const cancelDeferredLoad = scheduleMinimumDelayIdleTask(runListSessions, { + minimumDelayMs: initialTopicsDeferredDelayMs, + idleTimeoutMs: INITIAL_TOPICS_IDLE_TIMEOUT_MS, + }); + return () => { + cancelled = true; + cancelDeferredLoad(); + }; + } + + runListSessions(); return () => { cancelled = true; }; - }, [filterSessionsByWorkspace, runtime, workspaceId]); + }, [ + filterSessionsByWorkspace, + initialTopicsDeferredDelayMs, + initialTopicsLoadMode, + runtime, + workspaceId, + ]); const loadTopics = useCallback(async () => { if (!workspaceId?.trim()) { diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 24599feca..68137ff7e 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -2045,6 +2045,69 @@ describe("useAsterAgentChat thread timeline", () => { } }); + it("stream error 命中 Provider 登录态失效时应展示友好提示", async () => { + const workspaceId = "ws-thread-provider-session-expired"; + const rawErrorMessage = + "Agent provider execution failed: Request failed: Bad request (400): Invalid schema for function 'SendMessage': In context=('properties', 'message', 'oneOf', '2'), array schema missing items"; + const friendlyErrorMessage = + "当前 Provider 登录态已失效,常见原因是 Token 已过期。请前往设置重新登录或刷新凭证后重试。"; + seedSession(workspaceId, "session-thread-provider-session-expired"); + const harness = mountHook(workspaceId); + const stream = captureTurnStream(); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("请继续处理", [], false, false, false, "react"); + }); + + act(() => { + stream.emit({ + type: "turn_started", + turn: { + id: "turn-provider-session-expired-1", + thread_id: "session-thread-provider-session-expired", + prompt_text: "请继续处理", + status: "running", + started_at: "2026-04-07T10:00:00.000Z", + created_at: "2026-04-07T10:00:00.000Z", + updated_at: "2026-04-07T10:00:00.000Z", + }, + }); + stream.emit({ + type: "error", + message: rawErrorMessage, + }); + }); + + const assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(assistantMessage?.content).toContain( + `执行失败:${friendlyErrorMessage}`, + ); + expect(assistantMessage?.runtimeStatus).toMatchObject({ + phase: "failed", + title: "当前处理失败", + detail: friendlyErrorMessage, + }); + expect(harness.getValue().turns).toEqual([ + expect.objectContaining({ + id: "turn-provider-session-expired-1", + status: "failed", + error_message: rawErrorMessage, + }), + ]); + expect(mockToast.error).toHaveBeenCalledWith(friendlyErrorMessage); + } finally { + harness.unmount(); + } + }); + it("手动压缩上下文时即使没有 assistant 正文也应完成时间线更新", async () => { const workspaceId = "ws-context-compaction"; seedSession(workspaceId, "session-context-compaction"); @@ -4494,6 +4557,45 @@ describe("useAsterAgentChat action_required 渲染链路", () => { } }); + it("空内容的图片 artifact_snapshot 不应进入通用 artifact 列表", async () => { + const workspaceId = "ws-image-artifact-snapshot-skip"; + seedSession(workspaceId, "session-image-artifact-snapshot-skip"); + const harness = mountHook(workspaceId); + const stream = captureTurnStream(); + + try { + await flushEffects(); + + await act(async () => { + await harness + .getValue() + .sendMessage("生成图片", [], false, false, false, "react"); + }); + + act(() => { + stream.emit({ + type: "artifact_snapshot", + artifact: { + artifactId: "artifact-image-output-1", + filePath: "/tmp/lime/output_image.jpg", + content: "", + metadata: { + complete: true, + }, + }, + }); + }); + + const assistantMessage = [...harness.getValue().messages] + .reverse() + .find((msg) => msg.role === "assistant"); + + expect(assistantMessage?.artifacts || []).toHaveLength(0); + } finally { + harness.unmount(); + } + }); + it("搜索工具来源应在后续 artifact_snapshot 中沉淀到同一文档", async () => { const workspaceId = "ws-artifact-sources-before-snapshot"; seedSession(workspaceId, "session-artifact-sources-before-snapshot"); @@ -5672,6 +5774,88 @@ describe("useAsterAgentChat 偏好持久化", () => { } }); + it("切换话题时应从历史 tool_response 元数据恢复图片任务预览", async () => { + const workspaceId = "ws-history-tool-image-task-preview"; + const now = Math.floor(Date.now() / 1000); + mockGetAgentRuntimeSession.mockResolvedValue({ + id: "topic-tool-image-task-preview", + execution_strategy: "react", + messages: [ + { + role: "assistant", + timestamp: now, + content: [ + { type: "text", text: "正在生成珠江夜景封面" }, + { + type: "tool_request", + id: "tool-image-task-preview-1", + tool_name: "bash", + arguments: { + command: + 'lime media image generate --prompt "珠江夜景封面" --size 1024x1024 --count 1', + }, + }, + ], + }, + { + role: "tool", + timestamp: now + 1, + content: [ + { + type: "tool_response", + id: "tool-image-task-preview-1", + success: true, + output: "图片任务已提交", + metadata: { + task_id: "task-image-history-preview-1", + task_type: "image_generate", + status: "succeeded", + project_id: "project-history-preview-1", + content_id: "content-history-preview-1", + requested_count: 1, + received_count: 1, + }, + }, + ], + }, + ], + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await act(async () => { + await harness.getValue().switchTopic("topic-tool-image-task-preview"); + }); + + const value = harness.getValue(); + expect(value.messages).toHaveLength(1); + expect(value.messages[0]).toMatchObject({ + role: "assistant", + imageWorkbenchPreview: { + taskId: "task-image-history-preview-1", + prompt: "珠江夜景封面", + status: "complete", + size: "1024x1024", + imageCount: 1, + projectId: "project-history-preview-1", + contentId: "content-history-preview-1", + }, + }); + expect( + value.messages[0]?.contentParts?.some( + (part) => + part.type === "tool_use" && + part.toolCall.id === "tool-image-task-preview-1" && + part.toolCall.status === "completed", + ), + ).toBe(true); + } finally { + harness.unmount(); + } + }); + it("切换话题时应清洗 tool_response error 中的 Lime 元数据块", async () => { const workspaceId = "ws-history-tool-error-metadata"; const now = Math.floor(Date.now() / 1000); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index d3b24988b..98c44619a 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -39,6 +39,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { onWriteFile, workspaceId, disableSessionRestore = false, + initialTopicsLoadMode = "immediate", + initialTopicsDeferredDelayMs, getSyncedSessionRecentPreferences, runtimeAdapter, preserveRestoredMessages = false, @@ -74,6 +76,8 @@ export function useAsterAgentChat(options: UseAsterAgentChatRuntimeOptions) { runtime, workspaceId, disableSessionRestore, + initialTopicsLoadMode, + initialTopicsDeferredDelayMs, preserveRestoredMessages, executionStrategy: context.executionStrategy, accessMode: context.accessMode, diff --git a/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx b/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx index 074386704..d116eb073 100644 --- a/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx +++ b/src/components/agent/chat/hooks/useGeneralWorkbenchSendBoundary.test.tsx @@ -121,7 +121,7 @@ describe("useGeneralWorkbenchSendBoundary", () => { } }); - it("需要真实浏览器时应仅保留 requirement 检测,不再启动前置引导", () => { + it("需要真实浏览器时应仅保留 requirement 检测,不再恢复旧状态机", () => { const harness = mountHook(); try { diff --git a/src/components/agent/chat/hooks/useLimeSkills.test.tsx b/src/components/agent/chat/hooks/useLimeSkills.test.tsx index 3d904ae35..0bd2baf8b 100644 --- a/src/components/agent/chat/hooks/useLimeSkills.test.tsx +++ b/src/components/agent/chat/hooks/useLimeSkills.test.tsx @@ -157,12 +157,19 @@ describe("useLimeSkills", () => { vi.clearAllMocks(); }); - it("deferred 模式应在 idle/fallback 阶段加载本地 skills", async () => { + it("deferred 模式应在最小延迟后才加载本地 skills", async () => { const harness = mountHook({ autoLoad: "deferred", logScope: "TestScope" }); try { act(() => { - vi.advanceTimersByTime(200); + vi.advanceTimersByTime(1499); + }); + await flushEffects(); + + expect(mockGetLocal).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); }); await flushEffects(); diff --git a/src/components/agent/chat/hooks/useLimeSkills.ts b/src/components/agent/chat/hooks/useLimeSkills.ts index 5e09e92a1..23f608e71 100644 --- a/src/components/agent/chat/hooks/useLimeSkills.ts +++ b/src/components/agent/chat/hooks/useLimeSkills.ts @@ -1,35 +1,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { logAgentDebug } from "@/lib/agentDebug"; import { skillsApi, type Skill } from "@/lib/api/skills"; +import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; const SKILLS_IDLE_TIMEOUT_MS = 1_500; -const SKILLS_FALLBACK_DELAY_MS = 180; - -function scheduleDeferredSkillsLoad(task: () => void): () => void { - if (typeof window === "undefined") { - task(); - return () => undefined; - } - - if (typeof window.requestIdleCallback === "function") { - const idleId = window.requestIdleCallback(() => task(), { - timeout: SKILLS_IDLE_TIMEOUT_MS, - }); - return () => { - if (typeof window.cancelIdleCallback === "function") { - window.cancelIdleCallback(idleId); - } - }; - } - - const timeoutId = window.setTimeout(task, SKILLS_FALLBACK_DELAY_MS); - return () => { - window.clearTimeout(timeoutId); - }; -} interface UseLimeSkillsOptions { autoLoad?: "immediate" | "deferred" | false; + deferredDelayMs?: number; logScope?: string; onError?: (error: unknown) => void; } @@ -37,6 +15,7 @@ interface UseLimeSkillsOptions { export function useLimeSkills(options: UseLimeSkillsOptions = {}) { const { autoLoad = "immediate", + deferredDelayMs = SKILLS_IDLE_TIMEOUT_MS, logScope = "useLimeSkills", onError, } = options; @@ -114,14 +93,20 @@ export function useLimeSkills(options: UseLimeSkillsOptions = {}) { } if (autoLoad === "deferred") { - return scheduleDeferredSkillsLoad(() => { - void refreshSkills(false); - }); + return scheduleMinimumDelayIdleTask( + () => { + void refreshSkills(false); + }, + { + minimumDelayMs: deferredDelayMs, + idleTimeoutMs: SKILLS_IDLE_TIMEOUT_MS, + }, + ); } void refreshSkills(false); return; - }, [autoLoad, refreshSkills]); + }, [autoLoad, deferredDelayMs, refreshSkills]); useEffect(() => { return () => { diff --git a/src/components/agent/chat/hooks/useTrayModelShortcuts.ts b/src/components/agent/chat/hooks/useTrayModelShortcuts.ts index b5321bab7..207645641 100644 --- a/src/components/agent/chat/hooks/useTrayModelShortcuts.ts +++ b/src/components/agent/chat/hooks/useTrayModelShortcuts.ts @@ -23,6 +23,7 @@ import { import { buildProviderModelsFromRegistry } from "@/lib/model/providerModelsCatalog"; import { filterModelsByTheme } from "../utils/modelThemePolicy"; import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; interface UseTrayModelShortcutsOptions { providerType: string; @@ -342,6 +343,7 @@ export function useTrayModelShortcuts({ activeTheme, deferInitialSync = false, }: UseTrayModelShortcutsOptions) { + const traySyncEnabled = hasTauriInvokeCapability(); const lastSyncedSignatureRef = useRef(""); const initialSyncHandledRef = useRef(false); const latestSelectionRef = useRef({ @@ -365,6 +367,10 @@ export function useTrayModelShortcuts({ }, [model, providerType, setModel, setProviderType]); useEffect(() => { + if (!traySyncEnabled) { + return; + } + const normalizedProviderType = providerType.trim(); const normalizedModel = model.trim(); const normalizedTheme = activeTheme?.trim() || ""; @@ -413,9 +419,13 @@ export function useTrayModelShortcuts({ cancelled = true; cleanupScheduledTask?.(); }; - }, [activeTheme, deferInitialSync, model, providerType]); + }, [activeTheme, deferInitialSync, model, providerType, traySyncEnabled]); useEffect(() => { + if (!traySyncEnabled) { + return; + } + let cancelled = false; let dispose: (() => void) | null = null; @@ -461,5 +471,5 @@ export function useTrayModelShortcuts({ dispose(); } }; - }, []); + }, [traySyncEnabled]); } diff --git a/src/components/agent/chat/hooks/useWechatRuntimeModelSync.ts b/src/components/agent/chat/hooks/useWechatRuntimeModelSync.ts index 0a916b6f9..be0de0738 100644 --- a/src/components/agent/chat/hooks/useWechatRuntimeModelSync.ts +++ b/src/components/agent/chat/hooks/useWechatRuntimeModelSync.ts @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { wechatChannelSetRuntimeModel } from "@/lib/api/channelsRuntime"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; let lastSyncedWechatRuntimeModelKey: string | null = null; @@ -14,7 +15,13 @@ export function useWechatRuntimeModelSync({ modelId, source, }: UseWechatRuntimeModelSyncOptions) { + const syncEnabled = hasTauriInvokeCapability(); + useEffect(() => { + if (!syncEnabled) { + return; + } + const normalizedProviderId = providerId?.trim() || ""; const normalizedModelId = modelId?.trim() || ""; const selectionKey = @@ -54,5 +61,5 @@ export function useWechatRuntimeModelSync({ return () => { cancelled = true; }; - }, [modelId, providerId, source]); + }, [modelId, providerId, source, syncEnabled]); } diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index dde98e1ac..f32bf35ad 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1423,6 +1423,34 @@ describe("AgentChatPage 话题切换项目恢复", () => { expect(workspaceHookOrder).toBeLessThan(switchTopicOrder); }); + it("默认工作区别名应在进入页面时归一为真实项目 ID", async () => { + mockGetOrCreateDefaultProject.mockResolvedValue( + createProject("project-default-real"), + ); + mockEnsureWorkspaceReady.mockResolvedValue({ + workspaceId: "project-default-real", + rootPath: "/tmp/project-default-real", + existed: true, + created: false, + repaired: false, + relocated: false, + previousRootPath: null, + warning: null, + }); + + renderPage({ projectId: "default" }); + await flushEffects(); + + expect(mockGetOrCreateDefaultProject).toHaveBeenCalledTimes(1); + expect(mockEnsureWorkspaceReady).toHaveBeenCalledWith( + "project-default-real", + ); + expect(observedWorkspaceIds).not.toContain("default"); + expect(observedWorkspaceIds[observedWorkspaceIds.length - 1]).toBe( + "project-default-real", + ); + }); + it("存在 newChatAt 时手动选项目不应被重置", async () => { const container = renderPage({ newChatAt: 1234567890 }); await flushEffects(); diff --git a/src/components/agent/chat/service-skills/types.ts b/src/components/agent/chat/service-skills/types.ts index 500f0f0a8..b2b2dac74 100644 --- a/src/components/agent/chat/service-skills/types.ts +++ b/src/components/agent/chat/service-skills/types.ts @@ -10,6 +10,7 @@ import type { ServiceSkillPromptTemplateKey, ServiceSkillReadinessRequirements, ServiceSkillRunnerType, + ServiceSkillSceneBinding, ServiceSkillSlotDefinition, ServiceSkillSlotOption, ServiceSkillSlotType, @@ -34,6 +35,7 @@ export type { ServiceSkillPromptTemplateKey, ServiceSkillReadinessRequirements, ServiceSkillRunnerType, + ServiceSkillSceneBinding, ServiceSkillSlotDefinition, ServiceSkillSlotOption, ServiceSkillSlotType, diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx index 1ec7a0fad..c54f901be 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -138,7 +138,9 @@ function buildLegacySiteCatalog(): SkillCatalog { }; } -function mountHook(): HookHarness { +function mountHook( + options: Parameters[0] = true, +): HookHarness { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -146,7 +148,7 @@ function mountHook(): HookHarness { let hookValue: ReturnType | null = null; function TestComponent() { - hookValue = useServiceSkills(); + hookValue = useServiceSkills(options); return null; } @@ -215,6 +217,7 @@ describe("useServiceSkills", () => { delete window.__LIME_SESSION_TOKEN__; vi.unstubAllGlobals(); vi.restoreAllMocks(); + vi.useRealTimers(); }); it("目录更新事件后应刷新技能列表并保留分组元数据", async () => { diff --git a/src/components/agent/chat/service-skills/useServiceSkills.ts b/src/components/agent/chat/service-skills/useServiceSkills.ts index 33c12910c..00214c205 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.ts +++ b/src/components/agent/chat/service-skills/useServiceSkills.ts @@ -25,6 +25,7 @@ import { getServiceSkillRunnerLabel, getServiceSkillRunnerTone, } from "./skillPresentation"; +import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import { supportsServiceSkillLocalAutomation } from "./automationDraft"; import { getServiceSkillUsageMap, recordServiceSkillUsage } from "./storage"; import type { @@ -35,6 +36,8 @@ import type { ServiceSkillHomeItem, } from "./types"; +const SERVICE_SKILLS_IDLE_TIMEOUT_MS = 1_500; + function shouldExposeServiceSkillHomeItem(item: SkillCatalogItem): boolean { if (item.execution.kind === "site_adapter") { return false; @@ -136,7 +139,21 @@ interface UseServiceSkillsResult { recordUsage: (input: RecordServiceSkillUsageInput) => void; } -export function useServiceSkills(enabled = true): UseServiceSkillsResult { +interface UseServiceSkillsOptions { + enabled?: boolean; + loadMode?: "immediate" | "deferred"; + deferredDelayMs?: number; +} + +export function useServiceSkills( + options: UseServiceSkillsOptions | boolean = true, +): UseServiceSkillsResult { + const normalizedOptions = + typeof options === "boolean" ? { enabled: options } : options; + const enabled = normalizedOptions.enabled ?? true; + const loadMode = normalizedOptions.loadMode ?? "immediate"; + const deferredDelayMs = + normalizedOptions.deferredDelayMs ?? SERVICE_SKILLS_IDLE_TIMEOUT_MS; const [items, setItems] = useState([]); const [groups, setGroups] = useState([]); const [automationStatusMap, setAutomationStatusMap] = useState< @@ -243,8 +260,21 @@ export function useServiceSkills(enabled = true): UseServiceSkillsResult { }, [applyCatalogSnapshot, enabled, loadCurrentCatalog]); useEffect(() => { + if (loadMode === "deferred") { + return scheduleMinimumDelayIdleTask( + () => { + void refresh(); + }, + { + minimumDelayMs: deferredDelayMs, + idleTimeoutMs: SERVICE_SKILLS_IDLE_TIMEOUT_MS, + }, + ); + } + void refresh(); - }, [refresh]); + return; + }, [deferredDelayMs, loadMode, refresh]); useEffect(() => { if (!enabled) { diff --git a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx index 89f89f0a1..5201b61ae 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx @@ -14,6 +14,10 @@ import { import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter"; import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import type { BuiltinInputCommand } from "./builtinCommands"; +import { recordMentionEntryUsage } from "./mentionEntryUsage"; +import { recordSlashEntryUsage } from "./slashEntryUsage"; + +const mockListServiceSkills = vi.hoisted(() => vi.fn()); vi.mock("sonner", () => ({ toast: { @@ -21,6 +25,16 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("@/lib/api/serviceSkills", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + listServiceSkills: () => mockListServiceSkills(), + }; +}); + vi.mock("@/components/ui/popover", () => { const Popover = ({ open, @@ -171,6 +185,10 @@ afterEach(() => { vi.clearAllMocks(); }); +beforeEach(() => { + mockListServiceSkills.mockResolvedValue([]); +}); + interface HarnessProps { characters?: Character[]; skills?: Skill[]; @@ -178,7 +196,10 @@ interface HarnessProps { syncValue?: boolean; onNavigateToSettings?: () => void; onChangeSpy?: (value: string) => void; - onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void; + onSelectBuiltinCommand?: ( + command: BuiltinInputCommand, + options?: { replayText?: string }, + ) => void; onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; } @@ -354,6 +375,53 @@ function createServiceSkill( }; } +function createXArticleSceneServiceSkill( + overrides: Partial = {}, +): ServiceSkillHomeItem { + return createServiceSkill({ + id: "x-article-export", + skillKey: "x-article-export", + title: "X 文章转存", + summary: "复用 X 登录态把长文导出成 Markdown。", + category: "站点采集", + outputHint: "Markdown 正文 + 图片目录", + source: "local_custom", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + slotSchema: [ + { + key: "article_url", + label: "X 文章链接", + type: "url", + required: true, + placeholder: "https://x.com/<账号>/article/<文章ID>", + }, + ], + sceneBinding: { + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + aliases: ["x文章转存", "x转存"], + }, + siteCapabilityBinding: { + adapterName: "x/article-export", + autoRun: true, + requireAttachedSession: true, + saveMode: "project_resource", + slotArgMap: { + article_url: "url", + }, + }, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald", + runnerDescription: "直接复用浏览器登录态执行。", + actionLabel: "启动采集", + ...overrides, + }); +} + function buildCatalogWithSceneEntry(): SkillCatalog { const seeded = getSeededSkillCatalog(); @@ -384,6 +452,35 @@ function buildCatalogWithSceneEntry(): SkillCatalog { }; } +function buildCatalogWithXSceneEntry(): SkillCatalog { + const seeded = getSeededSkillCatalog(); + + return { + ...seeded, + tenantId: "tenant-x-scene-demo", + version: "tenant-x-scene-demo-2026-04-07", + syncedAt: "2026-04-07T12:00:00.000Z", + entries: [ + ...seeded.entries.filter( + (entry) => + entry.kind !== "scene" || + entry.sceneKey !== "x-article-export", + ), + { + id: "scene:x-article-export", + kind: "scene", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + aliases: ["x文章转存", "x转存"], + linkedSkillId: "x-article-export", + executionKind: "site_adapter", + }, + ], + }; +} + describe("CharacterMention", () => { it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => { const container = renderHarness({ @@ -904,12 +1001,319 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - expect(document.body.textContent).toContain("技能组 · 通用技能"); - expect(document.body.textContent).toContain("技能组 · GitHub"); + expect(document.body.textContent).toContain("推荐技能"); expect(document.body.textContent).toContain("每日趋势摘要"); expect(document.body.textContent).toContain("GitHub 仓库雷达"); }); + it("最近使用的服务技能应优先显示在独立分组,且不在技能组里重复", async () => { + const recentSkill = createServiceSkill({ + id: "recent-trend-briefing", + title: "最近趋势摘要", + recentUsedAt: 1_712_345_678_000, + isRecent: true, + }); + const regularSkill = createServiceSkill({ + id: "regular-trend-briefing", + title: "常规趋势摘要", + }); + const container = renderHarness({ + serviceSkills: [recentSkill, regularSkill], + }); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + expect(document.body.textContent).toContain("最近使用"); + expect(document.body.textContent).toContain("推荐技能"); + + const recentButtons = Array.from( + document.body.querySelectorAll("button"), + ).filter((button) => button.textContent?.includes("最近趋势摘要")); + expect(recentButtons).toHaveLength(1); + + const regularButtons = Array.from( + document.body.querySelectorAll("button"), + ).filter((button) => button.textContent?.includes("常规趋势摘要")); + expect(regularButtons).toHaveLength(1); + }); + + it("@ 空查询时应优先显示最近使用的内建命令,且不在内建命令分组重复", async () => { + act(() => { + recordMentionEntryUsage({ + kind: "builtin_command", + entryId: "research", + usedAt: 1_712_345_678_900, + }); + }); + + const container = renderHarness(); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + expect(document.body.textContent).toContain("最近使用"); + + const recentCommandButtons = Array.from( + document.body.querySelectorAll("button"), + ).filter((button) => button.textContent?.includes("@搜索")); + expect(recentCommandButtons).toHaveLength(1); + }); + + it("选择最近使用的 @命令时应回填上次成功草稿", async () => { + const replayText = "关键词:AI Agent 融资 站点:36Kr"; + act(() => { + recordMentionEntryUsage({ + kind: "builtin_command", + entryId: "research", + usedAt: 1_712_345_678_900, + replayText, + }); + }); + + const onChangeSpy = vi.fn<(value: string) => void>(); + const onSelectBuiltinCommand = vi.fn< + (command: BuiltinInputCommand, options?: { replayText?: string }) => void + >(); + const container = renderHarness({ + onChangeSpy, + onSelectBuiltinCommand, + }); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + expect(document.body.textContent).toContain(`上次输入:${replayText}`); + + const recentCommandButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("@搜索")); + expect(recentCommandButton).toBeTruthy(); + + act(() => { + recentCommandButton?.click(); + }); + + expect(onChangeSpy).toHaveBeenLastCalledWith(replayText); + expect(onSelectBuiltinCommand).toHaveBeenCalledWith( + expect.objectContaining({ + key: "research", + }), + expect.objectContaining({ + replayText, + }), + ); + }); + + it("@ 搜索时不应显示最近使用,而应回到普通命令结果", async () => { + act(() => { + recordMentionEntryUsage({ + kind: "builtin_command", + entryId: "research", + usedAt: 1_712_345_678_900, + }); + }); + + const container = renderHarness(); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@搜"; + textarea.setSelectionRange(2, 2); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).not.toContain("最近使用"); + expect(document.body.textContent).toContain("内建命令"); + expect(document.body.textContent).toContain("@搜索"); + }); + + it("只有最近使用的服务技能时,不应同时出现空态文案", async () => { + const container = renderHarness({ + serviceSkills: [ + createServiceSkill({ + id: "recent-trend-briefing", + title: "最近趋势摘要", + recentUsedAt: 1_712_345_678_000, + isRecent: true, + }), + ], + }); + const textarea = getTextarea(container); + + await typeAtAndWait(textarea); + + expect(document.body.textContent).toContain("最近使用"); + expect(document.body.textContent).toContain("最近趋势摘要"); + expect(document.body.textContent).not.toContain("暂无可用角色或技能"); + }); + + it("输入 @ 查询服务技能时,应回到按技能组展示搜索结果", async () => { + const container = renderHarness({ + serviceSkills: [ + createServiceSkill({ + id: "github-repo-radar", + title: "GitHub 仓库雷达", + aliases: ["仓库雷达", "GitHub 搜索"], + groupKey: "github", + recentUsedAt: 1_712_345_678_000, + isRecent: true, + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + runnerLabel: "浏览器协助", + runnerTone: "sky", + runnerDescription: "进入真实浏览器执行只读采集。", + actionLabel: "填写参数", + }), + ], + }); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@git"; + textarea.setSelectionRange(4, 4); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).not.toContain("最近使用"); + expect(document.body.textContent).not.toContain("推荐技能"); + expect(document.body.textContent).toContain("技能组 · GitHub"); + expect(document.body.textContent).toContain("GitHub 仓库雷达"); + }); + + it("输入 @网 时应展示新的内建网页命令", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@网"; + textarea.setSelectionRange(2, 2); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("内建命令"); + expect(document.body.textContent).toContain("@网页"); + }); + + it("输入 @P 时应展示新的内建 PPT 命令", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@P"; + textarea.setSelectionRange(2, 2); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("内建命令"); + expect(document.body.textContent).toContain("@PPT"); + }); + + it("输入 @表 时应展示新的内建表单命令", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@表"; + textarea.setSelectionRange(2, 2); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("内建命令"); + expect(document.body.textContent).toContain("@表单"); + }); + + it("输入 @代 时应展示新的内建代码命令", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@代"; + textarea.setSelectionRange(2, 2); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("内建命令"); + expect(document.body.textContent).toContain("@代码"); + }); + + it("输入 @发 时应展示新的内建发布命令", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + act(() => { + textarea.focus(); + textarea.value = "@发"; + textarea.setSelectionRange(2, 2); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(document.body.textContent).toContain("内建命令"); + expect(document.body.textContent).toContain("@发布"); + }); + it("服务技能过滤应支持命中别名", () => { const filtered = filterMentionableServiceSkills( [ @@ -988,9 +1392,10 @@ describe("CharacterMention", () => { await typeSlashAndWait(textarea); - expect(document.body.textContent).toContain("Lime 命令"); + expect(document.body.textContent).toContain("快捷操作"); expect(document.body.textContent).toContain("/compact"); expect(document.body.textContent).toContain("/review"); + expect(document.body.textContent).not.toContain("/quit"); }); it("统一目录中的 scene 应出现在 slash 面板里", async () => { @@ -1010,6 +1415,81 @@ describe("CharacterMention", () => { ); }); + it("搜索未接入的 slash 命令时,应单独显示暂未接入分组", async () => { + const container = renderHarness(); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea, "/qui"); + + expect(document.body.textContent).not.toContain("快捷操作"); + expect(document.body.textContent).toContain("暂未接入"); + expect(document.body.textContent).toContain("/quit"); + }); + + it("slash 空查询时应优先显示最近使用,且不在原分组重复", async () => { + act(() => { + saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync"); + recordSlashEntryUsage({ + kind: "command", + entryId: "compact", + usedAt: 1_712_345_678_900, + }); + recordSlashEntryUsage({ + kind: "scene", + entryId: "campaign-launch", + usedAt: 1_712_345_678_800, + }); + recordSlashEntryUsage({ + kind: "skill", + entryId: "skill-a", + usedAt: 1_712_345_678_700, + }); + }); + + const container = renderHarness({ + skills: [createSkill("技能A", "skill-a", true)], + }); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea); + + expect(document.body.textContent).toContain("最近使用"); + + const compactButtons = Array.from( + document.body.querySelectorAll("button"), + ).filter((button) => button.textContent?.includes("/compact")); + expect(compactButtons).toHaveLength(1); + + const sceneButtons = Array.from( + document.body.querySelectorAll("button"), + ).filter((button) => button.textContent?.includes("/campaign-launch")); + expect(sceneButtons).toHaveLength(1); + + const skillButtons = Array.from( + document.body.querySelectorAll("button"), + ).filter((button) => button.textContent?.includes("技能A")); + expect(skillButtons).toHaveLength(1); + }); + + it("slash 搜索时不应显示最近使用,而应回到搜索结果分组", async () => { + act(() => { + recordSlashEntryUsage({ + kind: "command", + entryId: "compact", + usedAt: 1_712_345_678_900, + }); + }); + + const container = renderHarness(); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea, "/com"); + + expect(document.body.textContent).not.toContain("最近使用"); + expect(document.body.textContent).toContain("Lime 命令"); + expect(document.body.textContent).toContain("/compact"); + }); + it("slash 面板选择 Lime 命令时应回填到输入框", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const container = renderHarness({ @@ -1056,6 +1536,43 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenCalledWith("/campaign-launch "); }); + it("slash 面板选择带必填参数的 scene 时应交给服务技能弹窗接管", async () => { + act(() => { + saveSkillCatalog(buildCatalogWithXSceneEntry(), "bootstrap_sync"); + }); + mockListServiceSkills.mockResolvedValueOnce([ + createXArticleSceneServiceSkill(), + ]); + + const onChangeSpy = vi.fn<(value: string) => void>(); + const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + const container = renderHarness({ + onChangeSpy, + onSelectServiceSkill, + }); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea, "/x文"); + + const sceneButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("/x文章转存")); + expect(sceneButton).toBeTruthy(); + + await act(async () => { + sceneButton?.click(); + await Promise.resolve(); + }); + + expect(onSelectServiceSkill).toHaveBeenCalledWith( + expect.objectContaining({ + id: "x-article-export", + title: "X 文章转存", + }), + ); + expect(onChangeSpy).not.toHaveBeenCalled(); + }); + it("slash 面板选择已安装技能时应直接回填 slash skill", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const container = renderHarness({ diff --git a/src/components/agent/chat/skill-selection/CharacterMention.tsx b/src/components/agent/chat/skill-selection/CharacterMention.tsx index ca385f349..993042364 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.tsx @@ -19,7 +19,17 @@ import { getSkillCatalog, subscribeSkillCatalogChanged, } from "@/lib/api/skillCatalog"; +import { + listServiceSkills, + type ServiceSkillItem, +} from "@/lib/api/serviceSkills"; import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter"; +import { + getServiceSkillActionLabel, + getServiceSkillRunnerDescription, + getServiceSkillRunnerLabel, + getServiceSkillRunnerTone, +} from "@/components/agent/chat/service-skills/skillPresentation"; import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import { toast } from "sonner"; import { @@ -39,6 +49,7 @@ import { LazyCharacterMentionPanel, preloadCharacterMentionPanel, } from "./characterMentionPanelLoader"; +import { recordSlashEntryUsage } from "./slashEntryUsage"; import { partitionMentionableSkills } from "./skillQuery"; import { useIdleModulePreload } from "./useIdleModulePreload"; @@ -62,7 +73,10 @@ interface CharacterMentionProps { /** 选择技能目录项回调 */ onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; /** 选择内建命令回调 */ - onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void; + onSelectBuiltinCommand?: ( + command: BuiltinInputCommand, + options?: BuiltinCommandSelectionOptions, + ) => void; /** 跳转到设置页安装技能 */ onNavigateToSettings?: () => void; } @@ -75,6 +89,10 @@ interface ActiveTrigger { query: string; } +interface BuiltinCommandSelectionOptions { + replayText?: string; +} + function resolveMentionTrigger(textBeforeCursor: string): ActiveTrigger | null { const lastAtIndex = textBeforeCursor.lastIndexOf("@"); if (lastAtIndex === -1) { @@ -128,6 +146,94 @@ function resolveActiveTrigger( : slashTrigger; } +function normalizeReplayText(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function mergeTriggerSelectionText(params: { + leadingText: string; + insertedText: string; + trailingText: string; +}): { value: string; cursorPos: number } { + const normalizedInsertedText = params.insertedText.trim(); + if (!normalizedInsertedText) { + return { + value: `${params.leadingText}${params.trailingText}`, + cursorPos: params.leadingText.length, + }; + } + + const needsLeadingSpace = + params.leadingText.length > 0 && !/[\s\n]$/.test(params.leadingText); + const needsTrailingSpace = + params.trailingText.length > 0 && !/^[\s\n]/.test(params.trailingText); + const mergedInsertedText = `${needsLeadingSpace ? " " : ""}${normalizedInsertedText}${ + needsTrailingSpace ? " " : "" + }`; + + return { + value: `${params.leadingText}${mergedInsertedText}${params.trailingText}`, + cursorPos: params.leadingText.length + mergedInsertedText.length, + }; +} + +function normalizeSceneToken(value?: string | null): string { + if (typeof value !== "string") { + return ""; + } + + return value.trim().replace(/^\/+/, "").toLowerCase(); +} + +function toServiceSkillHomeItem( + skill: ServiceSkillItem, +): ServiceSkillHomeItem { + return { + ...skill, + badge: skill.source === "cloud_catalog" ? "云目录" : "本地技能", + recentUsedAt: null, + isRecent: false, + runnerLabel: getServiceSkillRunnerLabel(skill), + runnerTone: getServiceSkillRunnerTone(skill), + runnerDescription: getServiceSkillRunnerDescription(skill), + actionLabel: getServiceSkillActionLabel(skill), + automationStatus: null, + cloudStatus: + skill.executionLocation === "cloud_required" ? null : undefined, + }; +} + +function matchesSceneBoundServiceSkill( + skill: ServiceSkillItem, + command: RuntimeSceneSlashCommand, +): boolean { + const binding = skill.sceneBinding; + if (!binding) { + return false; + } + + const commandTokens = new Set( + [command.key, command.commandPrefix, ...(command.aliases ?? [])] + .map((value) => normalizeSceneToken(value)) + .filter(Boolean), + ); + if (commandTokens.size === 0) { + return false; + } + + if (commandTokens.has(normalizeSceneToken(binding.sceneKey))) { + return true; + } + if (commandTokens.has(normalizeSceneToken(binding.commandPrefix))) { + return true; + } + + return (binding.aliases ?? []).some((alias) => + commandTokens.has(normalizeSceneToken(alias)), + ); +} + export function CharacterMention({ characters, skills = [], @@ -387,6 +493,10 @@ export function CharacterMention({ textAfterCursor; onChange(newValue); setShowMentions(false); + recordSlashEntryUsage({ + kind: "skill", + entryId: skill.key, + }); setTimeout(() => { textarea.focus(); @@ -438,7 +548,10 @@ export function CharacterMention({ }); }; - const handleSelectBuiltinCommand = (command: BuiltinInputCommand) => { + const handleSelectBuiltinCommand = ( + command: BuiltinInputCommand, + options?: BuiltinCommandSelectionOptions, + ) => { const textarea = inputRef.current; if (!textarea) return; @@ -450,32 +563,59 @@ export function CharacterMention({ return; } + const leadingText = currentValue.slice(0, activeTrigger.triggerIndex); + const replayText = normalizeReplayText(options?.replayText); + if (onSelectBuiltinCommand) { - const newValue = - currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor; - onChange(newValue.trimEnd() === "" ? "" : newValue); + const nextSelection = replayText + ? mergeTriggerSelectionText({ + leadingText, + insertedText: replayText, + trailingText: textAfterCursor, + }) + : { + value: `${leadingText}${textAfterCursor}`, + cursorPos: Math.max(0, activeTrigger.triggerIndex), + }; + const normalizedValue = + nextSelection.value.trimEnd() === "" ? "" : nextSelection.value; + + onChange(normalizedValue); setShowMentions(false); - onSelectBuiltinCommand(command); + if (options?.replayText) { + onSelectBuiltinCommand(command, options); + } else { + onSelectBuiltinCommand(command); + } setTimeout(() => { textarea.focus(); - const newCursorPos = Math.max(0, activeTrigger.triggerIndex); + const newCursorPos = Math.min( + nextSelection.cursorPos, + normalizedValue.length, + ); textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); return; } - const newValue = - currentValue.slice(0, activeTrigger.triggerIndex) + - `${command.commandPrefix} ` + - textAfterCursor; - onChange(newValue); + const nextSelection = replayText + ? mergeTriggerSelectionText({ + leadingText, + insertedText: `${command.commandPrefix} ${replayText}`, + trailingText: textAfterCursor, + }) + : { + value: `${leadingText}${command.commandPrefix} ${textAfterCursor}`, + cursorPos: activeTrigger.triggerIndex + command.commandPrefix.length + 1, + }; + + onChange(nextSelection.value); setShowMentions(false); setTimeout(() => { textarea.focus(); - const newCursorPos = - activeTrigger.triggerIndex + command.commandPrefix.length + 1; + const newCursorPos = nextSelection.cursorPos; textarea.setSelectionRange(newCursorPos, newCursorPos); }, 0); }; @@ -540,6 +680,12 @@ export function CharacterMention({ onChange(newValue); setShowMentions(false); + if (command.support === "supported") { + recordSlashEntryUsage({ + kind: "command", + entryId: command.key, + }); + } setTimeout(() => { textarea.focus(); @@ -549,9 +695,40 @@ export function CharacterMention({ }, 0); }; - const handleSelectRuntimeSceneCommand = ( + const handleSelectRuntimeSceneCommand = async ( command: RuntimeSceneSlashCommand, ) => { + if (onSelectServiceSkill) { + const matchedVisibleSkill = serviceSkills.find((skill) => + matchesSceneBoundServiceSkill(skill, command), + ); + const matchedSkill = + matchedVisibleSkill || + (await (async () => { + try { + const runtimeSkills = await listServiceSkills(); + const runtimeMatchedSkill = runtimeSkills.find((skill) => + matchesSceneBoundServiceSkill(skill, command), + ); + return runtimeMatchedSkill + ? toServiceSkillHomeItem(runtimeMatchedSkill) + : null; + } catch { + return null; + } + })()); + + if (matchedSkill && matchedSkill.slotSchema.some((slot) => slot.required)) { + onSelectServiceSkill(matchedSkill); + setShowMentions(false); + recordSlashEntryUsage({ + kind: "scene", + entryId: command.key, + }); + return; + } + } + const textarea = inputRef.current; if (!textarea) return; @@ -570,6 +747,10 @@ export function CharacterMention({ onChange(newValue); setShowMentions(false); + recordSlashEntryUsage({ + kind: "scene", + entryId: command.key, + }); setTimeout(() => { textarea.focus(); diff --git a/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx b/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx index 570d7c56b..da56b15fe 100644 --- a/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx @@ -22,6 +22,15 @@ import type { BuiltinInputCommand, RuntimeSceneSlashCommand, } from "./builtinCommands"; +import { + getMentionEntryUsageMap, + getMentionEntryUsageRecordKey, +} from "./mentionEntryUsage"; +import { + getSlashEntryUsageMap, + getSlashEntryUsageRecordKey, + type SlashEntryUsageKind, +} from "./slashEntryUsage"; interface MentionServiceSkillGroup { key: string; @@ -30,6 +39,92 @@ interface MentionServiceSkillGroup { skills: ServiceSkillHomeItem[]; } +interface RecentSlashEntry { + key: string; + kind: SlashEntryUsageKind; + kindLabel: string; + commandPrefix: string; + title: string; + description: string; + usedAt: number; +} + +interface RecentMentionEntry { + key: string; + kind: "builtin_command" | "service_skill"; + kindLabel: string; + title: string; + description: string; + usedAt: number; + replayText?: string; + commandKey?: string; + commandPrefix?: string; + skillId?: string; +} + +const FEATURED_SERVICE_SKILL_LIMIT = 4; +const RECENT_REPLAY_TEXT_PREVIEW_LIMIT = 48; + +function compareRecentServiceSkills( + left: ServiceSkillHomeItem, + right: ServiceSkillHomeItem, +): number { + const leftUsedAt = left.recentUsedAt ?? 0; + const rightUsedAt = right.recentUsedAt ?? 0; + if (leftUsedAt !== rightUsedAt) { + return rightUsedAt - leftUsedAt; + } + return left.title.localeCompare(right.title, "zh-CN"); +} + +function compareRecentSlashEntries( + left: RecentSlashEntry, + right: RecentSlashEntry, +): number { + if (left.usedAt !== right.usedAt) { + return right.usedAt - left.usedAt; + } + return left.commandPrefix.localeCompare(right.commandPrefix, "zh-CN"); +} + +function compareRecentMentionEntries( + left: RecentMentionEntry, + right: RecentMentionEntry, +): number { + if (left.usedAt !== right.usedAt) { + return right.usedAt - left.usedAt; + } + return left.title.localeCompare(right.title, "zh-CN"); +} + +function resolveRecentBuiltinCommandDescription( + command: BuiltinInputCommand, + replayText?: string, +): string { + const normalizedReplayText = replayText?.replace(/\s+/g, " ").trim(); + if (normalizedReplayText) { + const preview = + normalizedReplayText.length <= RECENT_REPLAY_TEXT_PREVIEW_LIMIT + ? normalizedReplayText + : `${normalizedReplayText + .slice(0, RECENT_REPLAY_TEXT_PREVIEW_LIMIT) + .trimEnd()}...`; + return `上次输入:${preview}`; + } + + if (command.description?.trim()) { + return `${command.label} · ${command.description}`; + } + return command.label; +} + +function resolveRecentSlashSkillDescription(skill: Skill): string { + if (skill.description?.trim()) { + return `${skill.name} · ${skill.description}`; + } + return skill.name; +} + const SERVICE_SKILL_GROUP_META: Record< string, { title: string; sort: number } @@ -98,7 +193,10 @@ interface CharacterMentionPanelProps { availableSkills: Skill[]; commandRef: React.RefObject; onQueryChange: (query: string) => void; - onSelectBuiltinCommand: (command: BuiltinInputCommand) => void; + onSelectBuiltinCommand: ( + command: BuiltinInputCommand, + options?: { replayText?: string }, + ) => void; onSelectServiceSkill: (skill: ServiceSkillHomeItem) => void; onSelectSlashCommand: (command: CodexSlashCommandDefinition) => void; onSelectSceneCommand: (command: RuntimeSceneSlashCommand) => void; @@ -129,22 +227,271 @@ export const CharacterMentionPanel: React.FC = ({ onSelectAvailableSkill, onNavigateToSettings, }) => { - const visibleBuiltinCommands = mode === "mention" ? builtinCommands : []; - const visibleServiceSkillGroups = React.useMemo( + const isEmptyQuery = mentionQuery.trim().length === 0; + const allSupportedSlashCommands = React.useMemo( () => - mode === "mention" ? groupMentionServiceSkills(mentionServiceSkills) : [], - [mentionServiceSkills, mode], + mode === "slash" + ? slashCommands.filter((command) => command.support === "supported") + : [], + [mode, slashCommands], ); + const visibleRecentSlashEntries = React.useMemo(() => { + if (mode !== "slash" || !isEmptyQuery) { + return []; + } + + const usageMap = getSlashEntryUsageMap(); + const recentEntries: RecentSlashEntry[] = []; + + for (const command of allSupportedSlashCommands) { + const recentRecord = usageMap.get( + getSlashEntryUsageRecordKey("command", command.key), + ); + if (!recentRecord) { + continue; + } + + recentEntries.push({ + key: `command:${command.key}`, + kind: "command", + kindLabel: "快捷操作", + commandPrefix: command.commandPrefix, + title: command.label, + description: command.description, + usedAt: recentRecord.usedAt, + }); + } + + for (const command of sceneCommands) { + const recentRecord = usageMap.get( + getSlashEntryUsageRecordKey("scene", command.key), + ); + if (!recentRecord) { + continue; + } + + recentEntries.push({ + key: `scene:${command.key}`, + kind: "scene", + kindLabel: "场景", + commandPrefix: command.commandPrefix, + title: command.label, + description: command.description, + usedAt: recentRecord.usedAt, + }); + } + + for (const skill of installedSkills) { + const recentRecord = usageMap.get( + getSlashEntryUsageRecordKey("skill", skill.key), + ); + if (!recentRecord) { + continue; + } + + recentEntries.push({ + key: `skill:${skill.key}`, + kind: "skill", + kindLabel: "技能", + commandPrefix: `/${skill.key}`, + title: skill.name, + description: resolveRecentSlashSkillDescription(skill), + usedAt: recentRecord.usedAt, + }); + } + + return recentEntries.sort(compareRecentSlashEntries); + }, [allSupportedSlashCommands, installedSkills, isEmptyQuery, mode, sceneCommands]); + const recentSlashCommandKeys = React.useMemo( + () => + new Set( + visibleRecentSlashEntries + .filter((entry) => entry.kind === "command") + .map((entry) => entry.commandPrefix), + ), + [visibleRecentSlashEntries], + ); + const recentSlashSceneKeys = React.useMemo( + () => + new Set( + visibleRecentSlashEntries + .filter((entry) => entry.kind === "scene") + .map((entry) => entry.commandPrefix), + ), + [visibleRecentSlashEntries], + ); + const recentSlashSkillKeys = React.useMemo( + () => + new Set( + visibleRecentSlashEntries + .filter((entry) => entry.kind === "skill") + .map((entry) => entry.commandPrefix), + ), + [visibleRecentSlashEntries], + ); + const visibleSupportedSlashCommands = React.useMemo( + () => + allSupportedSlashCommands.filter( + (command) => !recentSlashCommandKeys.has(command.commandPrefix), + ), + [allSupportedSlashCommands, recentSlashCommandKeys], + ); + const visibleUnsupportedSlashCommands = React.useMemo( + () => + mode === "slash" && !isEmptyQuery + ? slashCommands.filter((command) => command.support === "unsupported") + : [], + [isEmptyQuery, mode, slashCommands], + ); + const visibleRecentServiceSkills = React.useMemo(() => { + if (mode !== "mention" || !isEmptyQuery) { + return []; + } + + return mentionServiceSkills + .filter((skill) => skill.isRecent && skill.recentUsedAt) + .sort(compareRecentServiceSkills); + }, [isEmptyQuery, mentionServiceSkills, mode]); + const visibleRecentMentionEntries = React.useMemo(() => { + if (mode !== "mention" || !isEmptyQuery) { + return []; + } + + const usageMap = getMentionEntryUsageMap(); + const recentEntries: RecentMentionEntry[] = []; + + for (const command of builtinCommands) { + const recentRecord = usageMap.get( + getMentionEntryUsageRecordKey("builtin_command", command.key), + ); + if (!recentRecord) { + continue; + } + + recentEntries.push({ + key: `builtin-command:${command.key}`, + kind: "builtin_command", + kindLabel: "命令", + title: command.commandPrefix, + description: resolveRecentBuiltinCommandDescription( + command, + recentRecord.replayText, + ), + usedAt: recentRecord.usedAt, + replayText: recentRecord.replayText, + commandKey: command.key, + commandPrefix: command.commandPrefix, + }); + } + + for (const skill of visibleRecentServiceSkills) { + if (!skill.recentUsedAt) { + continue; + } + + recentEntries.push({ + key: `service-skill:${skill.id}`, + kind: "service_skill", + kindLabel: "技能", + title: skill.title, + description: resolveServiceSkillEntryDescription(skill), + usedAt: skill.recentUsedAt, + skillId: skill.id, + }); + } + + return recentEntries.sort(compareRecentMentionEntries); + }, [ + builtinCommands, + isEmptyQuery, + mode, + visibleRecentServiceSkills, + ]); + const recentMentionCommandKeys = React.useMemo( + () => + new Set( + visibleRecentMentionEntries + .filter((entry) => entry.kind === "builtin_command") + .map((entry) => entry.commandKey) + .filter((entry): entry is string => Boolean(entry)), + ), + [visibleRecentMentionEntries], + ); + const visibleBuiltinCommands = React.useMemo(() => { + if (mode !== "mention") { + return []; + } + + if (!isEmptyQuery) { + return builtinCommands; + } + + return builtinCommands.filter((command) => !recentMentionCommandKeys.has(command.key)); + }, [builtinCommands, isEmptyQuery, mode, recentMentionCommandKeys]); + const visibleFeaturedServiceSkills = React.useMemo(() => { + if (mode !== "mention" || !isEmptyQuery) { + return []; + } + + const recentSkillIds = new Set( + visibleRecentServiceSkills.map((skill) => skill.id), + ); + + return mentionServiceSkills + .filter((skill) => !recentSkillIds.has(skill.id)) + .slice(0, FEATURED_SERVICE_SKILL_LIMIT); + }, [isEmptyQuery, mentionServiceSkills, mode, visibleRecentServiceSkills]); + const visibleServiceSkillGroups = React.useMemo(() => { + if (mode !== "mention") { + return []; + } + + const excludedSkillIds = new Set( + visibleRecentServiceSkills.map((skill) => skill.id), + ); + for (const skill of visibleFeaturedServiceSkills) { + excludedSkillIds.add(skill.id); + } + + return groupMentionServiceSkills( + mentionServiceSkills.filter((skill) => !excludedSkillIds.has(skill.id)), + ); + }, [ + mentionServiceSkills, + mode, + visibleFeaturedServiceSkills, + visibleRecentServiceSkills, + ]); const visibleCharacters = mode === "mention" ? filteredCharacters : []; - const visibleSlashCommands = mode === "slash" ? slashCommands : []; - const visibleSceneCommands = mode === "slash" ? sceneCommands : []; + const visibleSceneCommands = React.useMemo( + () => + mode === "slash" + ? sceneCommands.filter( + (command) => !recentSlashSceneKeys.has(command.commandPrefix), + ) + : [], + [mode, recentSlashSceneKeys, sceneCommands], + ); + const visibleInstalledSkills = React.useMemo(() => { + if (mode !== "slash" || !isEmptyQuery) { + return installedSkills; + } + + return installedSkills.filter( + (skill) => !recentSlashSkillKeys.has(`/${skill.key}`), + ); + }, [installedSkills, isEmptyQuery, mode, recentSlashSkillKeys]); const hasFilteredResults = - visibleSlashCommands.length > 0 || + visibleRecentSlashEntries.length > 0 || + visibleSupportedSlashCommands.length > 0 || + visibleUnsupportedSlashCommands.length > 0 || visibleSceneCommands.length > 0 || + visibleRecentMentionEntries.length > 0 || visibleBuiltinCommands.length > 0 || + visibleFeaturedServiceSkills.length > 0 || visibleServiceSkillGroups.length > 0 || visibleCharacters.length > 0 || - installedSkills.length > 0 || + visibleInstalledSkills.length > 0 || availableSkills.length > 0; return ( @@ -174,9 +521,142 @@ export const CharacterMentionPanel: React.FC = ({ ) : null}
) : null} - {visibleSlashCommands.length > 0 ? ( - - {visibleSlashCommands.map((command) => ( + {visibleRecentMentionEntries.length > 0 ? ( + + {visibleRecentMentionEntries.map((entry) => { + if (entry.kind === "builtin_command") { + return ( + { + const command = builtinCommands.find( + (item) => item.key === entry.commandKey, + ); + if (command) { + if (entry.replayText) { + onSelectBuiltinCommand(command, { + replayText: entry.replayText, + }); + } else { + onSelectBuiltinCommand(command); + } + } + }} + className="cursor-pointer" + > + +
+
+
{entry.commandPrefix}
+ + {entry.kindLabel} + +
+
+ {entry.description} +
+
+
+ ); + } + + return ( + { + const skill = mentionServiceSkills.find( + (item) => item.id === entry.skillId, + ); + if (skill) { + onSelectServiceSkill(skill); + } + }} + className="cursor-pointer" + > + +
+
+
{entry.title}
+ + {entry.kindLabel} + +
+
+ {entry.description} +
+
+
+ ); + })} +
+ ) : null} + {visibleRecentSlashEntries.length > 0 ? ( + + {visibleRecentSlashEntries.map((entry) => { + const iconClassName = + entry.kind === "command" + ? "mr-2 h-4 w-4 text-emerald-600" + : entry.kind === "scene" + ? "mr-2 h-4 w-4 text-sky-600" + : "mr-2 h-4 w-4 text-primary"; + + return ( + { + if (entry.kind === "command") { + const command = allSupportedSlashCommands.find( + (item) => item.commandPrefix === entry.commandPrefix, + ); + if (command) { + onSelectSlashCommand(command); + } + return; + } + + if (entry.kind === "scene") { + const sceneCommand = sceneCommands.find( + (item) => item.commandPrefix === entry.commandPrefix, + ); + if (sceneCommand) { + onSelectSceneCommand(sceneCommand); + } + return; + } + + const skill = installedSkills.find( + (item) => `/${item.key}` === entry.commandPrefix, + ); + if (skill) { + onSelectInstalledSkill(skill); + } + }} + className="cursor-pointer" + > + {entry.kind === "command" ? ( + + ) : ( + + )} +
+
+
{entry.commandPrefix}
+ + {entry.kindLabel} + +
+
+ {entry.description} +
+
+
+ ); + })} +
+ ) : null} + {visibleSupportedSlashCommands.length > 0 ? ( + + {visibleSupportedSlashCommands.map((command) => ( onSelectSlashCommand(command)} @@ -200,6 +680,30 @@ export const CharacterMentionPanel: React.FC = ({ ))} ) : null} + {visibleUnsupportedSlashCommands.length > 0 ? ( + + {visibleUnsupportedSlashCommands.map((command) => ( + onSelectSlashCommand(command)} + className="cursor-pointer" + > + +
+
+ {command.commandPrefix} + + 暂未支持 + +
+
+ {command.description} +
+
+
+ ))} +
+ ) : null} {visibleSceneCommands.length > 0 ? ( {visibleSceneCommands.map((command) => ( @@ -238,6 +742,25 @@ export const CharacterMentionPanel: React.FC = ({ ))} ) : null} + {visibleFeaturedServiceSkills.length > 0 ? ( + + {visibleFeaturedServiceSkills.map((skill) => ( + onSelectServiceSkill(skill)} + className="cursor-pointer" + > + +
+
{skill.title}
+
+ {resolveServiceSkillEntryDescription(skill)} +
+
+
+ ))} +
+ ) : null} {visibleServiceSkillGroups.map((group) => ( {group.skills.map((skill) => ( @@ -283,9 +806,9 @@ export const CharacterMentionPanel: React.FC = ({ ))} ) : null} - {installedSkills.length > 0 ? ( + {visibleInstalledSkills.length > 0 ? ( - {installedSkills.map((skill) => ( + {visibleInstalledSkills.map((skill) => ( onSelectInstalledSkill(skill)} diff --git a/src/components/agent/chat/skill-selection/mentionEntryUsage.ts b/src/components/agent/chat/skill-selection/mentionEntryUsage.ts new file mode 100644 index 000000000..dd3b1c787 --- /dev/null +++ b/src/components/agent/chat/skill-selection/mentionEntryUsage.ts @@ -0,0 +1,125 @@ +export type MentionEntryUsageKind = "builtin_command"; + +export interface MentionEntryUsageRecord { + kind: MentionEntryUsageKind; + entryId: string; + usedAt: number; + replayText?: string; +} + +export interface RecordMentionEntryUsageInput { + kind: MentionEntryUsageKind; + entryId: string; + usedAt?: number; + replayText?: string; +} + +const MENTION_ENTRY_USAGE_STORAGE_KEY = "lime:mention-entry-usage:v1"; +const MAX_MENTION_ENTRY_USAGE_RECORDS = 12; +const MAX_MENTION_ENTRY_REPLAY_TEXT_LENGTH = 400; + +export function getMentionEntryUsageRecordKey( + kind: MentionEntryUsageKind, + entryId: string, +): string { + return `${kind}:${entryId}`; +} + +function isValidMentionEntryUsageRecord( + value: unknown, +): value is MentionEntryUsageRecord { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + record.kind === "builtin_command" && + typeof record.entryId === "string" && + record.entryId.length > 0 && + typeof record.usedAt === "number" && + Number.isFinite(record.usedAt) && + (record.replayText === undefined || typeof record.replayText === "string") + ); +} + +function normalizeMentionEntryReplayText( + value: string | undefined, +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.slice(0, MAX_MENTION_ENTRY_REPLAY_TEXT_LENGTH).trim(); +} + +export function listMentionEntryUsage(): MentionEntryUsageRecord[] { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(MENTION_ENTRY_USAGE_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isValidMentionEntryUsageRecord) + .sort((left, right) => right.usedAt - left.usedAt) + .slice(0, MAX_MENTION_ENTRY_USAGE_RECORDS); + } catch { + return []; + } +} + +export function getMentionEntryUsageMap(): Map { + return new Map( + listMentionEntryUsage().map((record) => [ + getMentionEntryUsageRecordKey(record.kind, record.entryId), + record, + ]), + ); +} + +export function recordMentionEntryUsage( + input: RecordMentionEntryUsageInput, +): MentionEntryUsageRecord[] { + const nextRecord: MentionEntryUsageRecord = { + kind: input.kind, + entryId: input.entryId, + usedAt: input.usedAt ?? Date.now(), + replayText: normalizeMentionEntryReplayText(input.replayText), + }; + + const nextRecords = [ + nextRecord, + ...listMentionEntryUsage().filter( + (record) => + !( + record.kind === nextRecord.kind && record.entryId === nextRecord.entryId + ), + ), + ].slice(0, MAX_MENTION_ENTRY_USAGE_RECORDS); + + if (typeof window === "undefined") { + return nextRecords; + } + + try { + window.localStorage.setItem( + MENTION_ENTRY_USAGE_STORAGE_KEY, + JSON.stringify(nextRecords), + ); + } catch { + // ignore write errors + } + + return nextRecords; +} diff --git a/src/components/agent/chat/skill-selection/slashEntryUsage.ts b/src/components/agent/chat/skill-selection/slashEntryUsage.ts new file mode 100644 index 000000000..3f99a64c8 --- /dev/null +++ b/src/components/agent/chat/skill-selection/slashEntryUsage.ts @@ -0,0 +1,111 @@ +export type SlashEntryUsageKind = "command" | "scene" | "skill"; + +export interface SlashEntryUsageRecord { + kind: SlashEntryUsageKind; + entryId: string; + usedAt: number; +} + +export interface RecordSlashEntryUsageInput { + kind: SlashEntryUsageKind; + entryId: string; + usedAt?: number; +} + +const SLASH_ENTRY_USAGE_STORAGE_KEY = "lime:slash-entry-usage:v1"; +const MAX_SLASH_ENTRY_USAGE_RECORDS = 12; + +export function getSlashEntryUsageRecordKey( + kind: SlashEntryUsageKind, + entryId: string, +): string { + return `${kind}:${entryId}`; +} + +function isValidSlashEntryUsageRecord( + value: unknown, +): value is SlashEntryUsageRecord { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + (record.kind === "command" || + record.kind === "scene" || + record.kind === "skill") && + typeof record.entryId === "string" && + record.entryId.length > 0 && + typeof record.usedAt === "number" && + Number.isFinite(record.usedAt) + ); +} + +export function listSlashEntryUsage(): SlashEntryUsageRecord[] { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(SLASH_ENTRY_USAGE_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isValidSlashEntryUsageRecord) + .sort((left, right) => right.usedAt - left.usedAt) + .slice(0, MAX_SLASH_ENTRY_USAGE_RECORDS); + } catch { + return []; + } +} + +export function getSlashEntryUsageMap(): Map { + return new Map( + listSlashEntryUsage().map((record) => [ + getSlashEntryUsageRecordKey(record.kind, record.entryId), + record, + ]), + ); +} + +export function recordSlashEntryUsage( + input: RecordSlashEntryUsageInput, +): SlashEntryUsageRecord[] { + const nextRecord: SlashEntryUsageRecord = { + kind: input.kind, + entryId: input.entryId, + usedAt: input.usedAt ?? Date.now(), + }; + + const nextRecords = [ + nextRecord, + ...listSlashEntryUsage().filter( + (record) => + !( + record.kind === nextRecord.kind && record.entryId === nextRecord.entryId + ), + ), + ].slice(0, MAX_SLASH_ENTRY_USAGE_RECORDS); + + if (typeof window === "undefined") { + return nextRecords; + } + + try { + window.localStorage.setItem( + SLASH_ENTRY_USAGE_STORAGE_KEY, + JSON.stringify(nextRecords), + ); + } catch { + // ignore write errors + } + + return nextRecords; +} diff --git a/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts new file mode 100644 index 000000000..0ea12b9ec --- /dev/null +++ b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAgentRuntimeErrorPresentation } from "./agentRuntimeErrorPresentation"; + +describe("agentRuntimeErrorPresentation", () => { + it("普通错误应保留原始文案", () => { + expect(resolveAgentRuntimeErrorPresentation("模型执行失败")).toEqual({ + displayMessage: "模型执行失败", + toastMessage: "响应错误: 模型执行失败", + }); + }); + + it("鉴权失效错误应转换为友好提示", () => { + expect( + resolveAgentRuntimeErrorPresentation( + "Request failed: 401 unauthorized, token expired", + ), + ).toEqual({ + displayMessage: + "当前 Provider 鉴权已失效,请前往设置检查登录态、API Key 或重新授权后重试。", + toastMessage: + "当前 Provider 鉴权已失效,请前往设置检查登录态、API Key 或重新授权后重试。", + }); + }); + + it("SendMessage schema 错误应转换为登录态失效提示", () => { + expect( + resolveAgentRuntimeErrorPresentation( + "Agent provider execution failed: Request failed: Bad request (400): Invalid schema for function 'SendMessage': In context=('properties', 'message', 'oneOf', '2'), array schema missing items", + ), + ).toEqual({ + displayMessage: + "当前 Provider 登录态已失效,常见原因是 Token 已过期。请前往设置重新登录或刷新凭证后重试。", + toastMessage: + "当前 Provider 登录态已失效,常见原因是 Token 已过期。请前往设置重新登录或刷新凭证后重试。", + }); + }); +}); diff --git a/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts new file mode 100644 index 000000000..8fe1a3ca7 --- /dev/null +++ b/src/components/agent/chat/utils/agentRuntimeErrorPresentation.ts @@ -0,0 +1,101 @@ +const DEFAULT_RUNTIME_ERROR_MESSAGE = "执行链路返回失败,请查看详情后重试。"; + +const PROVIDER_AUTH_ERROR_MESSAGE = + "当前 Provider 鉴权已失效,请前往设置检查登录态、API Key 或重新授权后重试。"; + +const PROVIDER_SESSION_EXPIRED_ERROR_MESSAGE = + "当前 Provider 登录态已失效,常见原因是 Token 已过期。请前往设置重新登录或刷新凭证后重试。"; + +const PROVIDER_CHANNEL_ERROR_MESSAGE = + "当前模型通道暂不可用,常见原因是登录态 / Token 已过期或渠道异常。请先刷新凭证,必要时切换模型后重试。"; + +function normalizeRuntimeErrorMessage(errorMessage: string): string { + const normalized = errorMessage.trim(); + return normalized || DEFAULT_RUNTIME_ERROR_MESSAGE; +} + +function looksLikeHttpStatus(message: string, status: "401" | "403"): boolean { + return new RegExp(`(^|\\D)${status}(\\D|$)`).test(message); +} + +function includesAny(message: string, candidates: readonly string[]): boolean { + return candidates.some((candidate) => message.includes(candidate)); +} + +function isLikelyProviderAuthError(message: string): boolean { + return ( + looksLikeHttpStatus(message, "401") || + looksLikeHttpStatus(message, "403") || + includesAny(message, [ + "unauthorized", + "forbidden", + "authentication failed", + "auth failed", + "authorization expired", + "credential expired", + "invalid api key", + "api key not valid", + "bad credentials", + "no access token", + "refresh token", + "session expired", + "token expired", + "login expired", + "reauth", + "凭证已过期", + "授权已过期", + "鉴权未通过", + "鉴权失败", + "认证失败", + "权限不足", + "token 过期", + "需要重新登录", + "请重新登录", + ]) + ); +} + +function isLikelyProviderSessionExpiredError(message: string): boolean { + return ( + message.includes("invalid schema for function 'sendmessage'") && + message.includes("array schema missing items") + ); +} + +function isLikelyProviderChannelError(message: string): boolean { + return message.includes("no available channel for model"); +} + +export function resolveAgentRuntimeErrorPresentation(errorMessage: string): { + displayMessage: string; + toastMessage: string; +} { + const normalizedMessage = normalizeRuntimeErrorMessage(errorMessage); + const lowerMessage = normalizedMessage.toLowerCase(); + + if (isLikelyProviderSessionExpiredError(lowerMessage)) { + return { + displayMessage: PROVIDER_SESSION_EXPIRED_ERROR_MESSAGE, + toastMessage: PROVIDER_SESSION_EXPIRED_ERROR_MESSAGE, + }; + } + + if (isLikelyProviderChannelError(lowerMessage)) { + return { + displayMessage: PROVIDER_CHANNEL_ERROR_MESSAGE, + toastMessage: PROVIDER_CHANNEL_ERROR_MESSAGE, + }; + } + + if (isLikelyProviderAuthError(lowerMessage)) { + return { + displayMessage: PROVIDER_AUTH_ERROR_MESSAGE, + toastMessage: PROVIDER_AUTH_ERROR_MESSAGE, + }; + } + + return { + displayMessage: normalizedMessage, + toastMessage: `响应错误: ${normalizedMessage}`, + }; +} diff --git a/src/components/agent/chat/utils/agentRuntimeStatus.ts b/src/components/agent/chat/utils/agentRuntimeStatus.ts index d7515be4a..420ba1f1c 100644 --- a/src/components/agent/chat/utils/agentRuntimeStatus.ts +++ b/src/components/agent/chat/utils/agentRuntimeStatus.ts @@ -1,6 +1,7 @@ import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; import type { AgentContextTraceStep as ContextTraceStep } from "@/lib/api/agentProtocol"; import type { AgentRuntimeStatus } from "../types"; +import { resolveAgentRuntimeErrorPresentation } from "./agentRuntimeErrorPresentation"; function buildExecutionLabel(strategy: AsterExecutionStrategy): string { switch (strategy) { @@ -16,8 +17,7 @@ function buildExecutionLabel(strategy: AsterExecutionStrategy): string { } function normalizeRuntimeErrorDetail(errorMessage: string): string { - const detail = errorMessage.trim(); - return detail || "执行链路返回失败,请查看详情后重试。"; + return resolveAgentRuntimeErrorPresentation(errorMessage).displayMessage; } export function buildInitialAgentRuntimeStatus(options: { diff --git a/src/components/agent/chat/utils/codeWorkbenchCommand.test.ts b/src/components/agent/chat/utils/codeWorkbenchCommand.test.ts new file mode 100644 index 000000000..0cae01596 --- /dev/null +++ b/src/components/agent/chat/utils/codeWorkbenchCommand.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { parseCodeWorkbenchCommand } from "./codeWorkbenchCommand"; + +describe("parseCodeWorkbenchCommand", () => { + it("应解析带显式任务类型的 @代码 命令", () => { + const result = parseCodeWorkbenchCommand( + "@代码 类型:重构 重构聊天区时间线组件,合并重复状态分支并补测试", + ); + + expect(result).toMatchObject({ + trigger: "@代码", + taskType: "refactor", + prompt: "重构聊天区时间线组件,合并重复状态分支并补测试", + }); + }); + + it("应兼容英文触发并识别 code review 意图", () => { + const result = parseCodeWorkbenchCommand( + "@code review src/components/agent/chat/workspace/useWorkspaceSendActions.ts 的发送边界", + ); + + expect(result).toMatchObject({ + trigger: "@code", + taskType: "code_review", + prompt: + "src/components/agent/chat/workspace/useWorkspaceSendActions.ts 的发送边界", + }); + }); + + it("未显式声明类型时也应从正文推断 bug fix 意图", () => { + const result = parseCodeWorkbenchCommand( + "@开发 帮我修复消息历史切换后图片卡片丢失的问题", + ); + + expect(result).toMatchObject({ + trigger: "@开发", + taskType: "bug_fix", + prompt: "帮我修复消息历史切换后图片卡片丢失的问题", + }); + }); + + it("非代码命令应返回空", () => { + expect(parseCodeWorkbenchCommand("@表单 帮我做一个报名表")).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/codeWorkbenchCommand.ts b/src/components/agent/chat/utils/codeWorkbenchCommand.ts new file mode 100644 index 000000000..503af04d6 --- /dev/null +++ b/src/components/agent/chat/utils/codeWorkbenchCommand.ts @@ -0,0 +1,168 @@ +export type CodeWorkbenchCommandTrigger = + | "@代码" + | "@code" + | "@coding" + | "@开发"; + +export type CodeWorkbenchTaskType = + | "code_review" + | "bug_fix" + | "implementation" + | "refactor" + | "explain"; + +export interface ParsedCodeWorkbenchCommand { + rawText: string; + trigger: CodeWorkbenchCommandTrigger; + body: string; + prompt: string; + taskType?: CodeWorkbenchTaskType; +} + +const CODE_COMMAND_PREFIX_REGEX = + /^\s*(@代码|@code|@coding|@开发)(?:\s+|$)([\s\S]*)$/i; +const EXPLICIT_TASK_TYPE_REGEX = + /(?:类型|任务|任务类型|type|task|kind|模式|mode)\s*[::=]?\s*(代码评审|代码审查|code(?:\s+review)?|review|修复|修\s*bug|bug(?:\s+fix)?|fix(?:\s+bug)?|debug|实现|开发|implementation|implement|build|重构|refactor|解释|讲解|说明|分析代码|explain)(?=$|[\s,,。;;::])/i; +const LEADING_TASK_TYPE_REGEX = + /^(代码评审|代码审查|code(?:\s+review)?|review|修复|修\s*bug|bug(?:\s+fix)?|fix(?:\s+bug)?|debug|实现|开发|implementation|implement|build|重构|refactor|解释|讲解|说明|分析代码|explain)(?=$|[\s,,。;;::])/i; + +function trimDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+|[,\s,。;;::]+$/g, "").trim(); +} + +function normalizeTrigger(value: string): CodeWorkbenchCommandTrigger { + const normalized = value.trim().toLowerCase(); + if (normalized === "@code") { + return "@code"; + } + if (normalized === "@coding") { + return "@coding"; + } + if (normalized === "@开发") { + return "@开发"; + } + return "@代码"; +} + +function normalizeTaskType( + value: string | undefined, +): CodeWorkbenchTaskType | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + if ( + normalized === "代码评审" || + normalized === "代码审查" || + normalized === "code review" || + normalized === "code" || + normalized === "review" + ) { + return "code_review"; + } + if ( + normalized === "修复" || + normalized === "修 bug" || + normalized === "修bug" || + normalized === "bug fix" || + normalized === "bug" || + normalized === "fix bug" || + normalized === "debug" + ) { + return "bug_fix"; + } + if ( + normalized === "实现" || + normalized === "开发" || + normalized === "implementation" || + normalized === "implement" || + normalized === "build" + ) { + return "implementation"; + } + if (normalized === "重构" || normalized === "refactor") { + return "refactor"; + } + if ( + normalized === "解释" || + normalized === "讲解" || + normalized === "说明" || + normalized === "分析代码" || + normalized === "explain" + ) { + return "explain"; + } + return undefined; +} + +function inferTaskType(body: string): CodeWorkbenchTaskType | undefined { + const normalized = body.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + if ( + /(?:代码评审|代码审查|code\s+review|\breview\b)/i.test(normalized) + ) { + return "code_review"; + } + if (/(?:修复|修\s*bug|bug\s+fix|fix\s+bug|\bdebug\b|\bbug\b)/i.test(normalized)) { + return "bug_fix"; + } + if (/(?:重构|\brefactor\b)/i.test(normalized)) { + return "refactor"; + } + if (/(?:解释|讲解|说明|分析代码|\bexplain\b)/i.test(normalized)) { + return "explain"; + } + if ( + /(?:实现|开发|\bimplementation\b|\bimplement\b|\bbuild\b)/i.test( + normalized, + ) + ) { + return "implementation"; + } + return undefined; +} + +function stripPromptDecorations(body: string, leadingTaskType?: string): string { + const leadingTaskTypeRegex = leadingTaskType + ? new RegExp( + `^${leadingTaskType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s,,。;;::])`, + "i", + ) + : /^$/; + + return trimDecorations( + body + .replace(EXPLICIT_TASK_TYPE_REGEX, " ") + .trimStart() + .replace(leadingTaskTypeRegex, "") + .replace(/\s+/g, " "), + ); +} + +export function parseCodeWorkbenchCommand( + text: string, +): ParsedCodeWorkbenchCommand | null { + const matched = text.match(CODE_COMMAND_PREFIX_REGEX); + if (!matched) { + return null; + } + + const body = (matched[2] || "").trim(); + const explicitTaskType = body.match(EXPLICIT_TASK_TYPE_REGEX)?.[1]?.trim(); + const leadingTaskType = body.match(LEADING_TASK_TYPE_REGEX)?.[1]?.trim(); + const taskType = + normalizeTaskType(explicitTaskType || leadingTaskType) || inferTaskType(body); + const prompt = stripPromptDecorations(body, explicitTaskType || leadingTaskType); + + return { + rawText: text, + trigger: normalizeTrigger(matched[1] || ""), + body, + prompt: prompt || body, + taskType, + }; +} diff --git a/src/components/agent/chat/utils/formWorkbenchCommand.test.ts b/src/components/agent/chat/utils/formWorkbenchCommand.test.ts new file mode 100644 index 000000000..aa72f4414 --- /dev/null +++ b/src/components/agent/chat/utils/formWorkbenchCommand.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parseFormWorkbenchCommand } from "./formWorkbenchCommand"; + +describe("parseFormWorkbenchCommand", () => { + it("应解析带类型、风格、受众和字段数的 @表单 命令", () => { + const result = parseFormWorkbenchCommand( + "@表单 类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表", + ); + + expect(result).toMatchObject({ + trigger: "@表单", + formType: "registration_form", + style: "简洁专业", + audience: "活动嘉宾", + fieldCount: 8, + prompt: "帮我做一个 AI Workshop 报名表", + }); + }); + + it("应兼容 @form 英文触发", () => { + const result = parseFormWorkbenchCommand( + "@form survey form for beta users with product satisfaction and feature requests", + ); + + expect(result).toMatchObject({ + trigger: "@form", + formType: "survey_form", + prompt: "for beta users with product satisfaction and feature requests", + }); + }); + + it("没有显式字段时也应保留表单生成意图", () => { + const result = parseFormWorkbenchCommand( + "@问卷 帮我整理一个面向内部员工满意度调研的匿名问卷", + ); + + expect(result).toMatchObject({ + trigger: "@问卷", + formType: undefined, + prompt: "帮我整理一个面向内部员工满意度调研的匿名问卷", + }); + }); + + it("非表单命令应返回空", () => { + expect(parseFormWorkbenchCommand("@网页 帮我做个官网")).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/formWorkbenchCommand.ts b/src/components/agent/chat/utils/formWorkbenchCommand.ts new file mode 100644 index 000000000..024f2a82e --- /dev/null +++ b/src/components/agent/chat/utils/formWorkbenchCommand.ts @@ -0,0 +1,236 @@ +export type FormWorkbenchCommandTrigger = + | "@表单" + | "@form" + | "@survey" + | "@问卷"; + +export type FormType = + | "survey_form" + | "lead_form" + | "registration_form" + | "feedback_form" + | "application_form"; + +export interface ParsedFormWorkbenchCommand { + rawText: string; + trigger: FormWorkbenchCommandTrigger; + body: string; + prompt: string; + formType?: FormType; + style?: string; + audience?: string; + fieldCount?: number; +} + +const FORM_COMMAND_PREFIX_REGEX = + /^\s*(@表单|@form|@survey|@问卷)(?:\s+|$)([\s\S]*)$/i; +const EXPLICIT_FORM_TYPE_REGEX = + /(?:类型|表单类型|type)\s*[::=]?\s*(问卷表单|调查表单|线索表单|报名表单|反馈表单|申请表单|survey(?:\s+form)?|lead(?:\s+form)?|registration(?:\s+form)?|feedback(?:\s+form)?|application(?:\s+form)?)(?=$|[\s,,。;;::])/i; +const LEADING_FORM_TYPE_REGEX = + /^(问卷表单|调查表单|线索表单|报名表单|反馈表单|申请表单|survey(?:\s+form)?|lead(?:\s+form)?|registration(?:\s+form)?|feedback(?:\s+form)?|application(?:\s+form)?)(?=$|[\s,,。;;::])/i; +const LEADING_EXPLICIT_FORM_TYPE_REGEX = + /^(?:类型|表单类型|type)\s*[::=]?\s*(问卷表单|调查表单|线索表单|报名表单|反馈表单|申请表单|survey(?:\s+form)?|lead(?:\s+form)?|registration(?:\s+form)?|feedback(?:\s+form)?|application(?:\s+form)?)(?=$|[\s,,。;;::])/i; +const FORM_PROMPT_BOUNDARY_PATTERN = + String.raw`(?:\s+(?:帮我|给我|请|生成|制作|设计|整理|输出|做一个|做个|create|generate|build|draft))`; +const LEADING_EXPLICIT_STYLE_REGEX = new RegExp( + String.raw`^(?:风格|style)\s*[::=]?\s*(.+?)(?=$|[,,。;;\n]|(?:\s+(?:受众|对象|audience|字段数|题数|问题数|fields?|questions?|类型|表单类型|type)\s*[::=]?)|${FORM_PROMPT_BOUNDARY_PATTERN})`, + "i", +); +const LEADING_EXPLICIT_AUDIENCE_REGEX = new RegExp( + String.raw`^(?:受众|对象|audience)\s*[::=]?\s*(.+?)(?=$|[,,。;;\n]|(?:\s+(?:风格|style|字段数|题数|问题数|fields?|questions?|类型|表单类型|type)\s*[::=]?)|${FORM_PROMPT_BOUNDARY_PATTERN})`, + "i", +); +const LEADING_EXPLICIT_FIELD_COUNT_REGEX = + /^(?:字段数|题数|问题数|fields?|questions?)\s*[::=]?\s*(\d{1,2})/i; +const PROMPT_PREFIX_REGEX = + /^\s*(生成|制作|设计|整理|输出|做一个|做个|create|generate|build|draft)(?:\s|$|[::])*/i; + +function normalizeTrigger(value: string): FormWorkbenchCommandTrigger { + const normalized = value.trim().toLowerCase(); + if (normalized === "@form") { + return "@form"; + } + if (normalized === "@survey") { + return "@survey"; + } + if (normalized === "@问卷") { + return "@问卷"; + } + return "@表单"; +} + +function trimDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+|[,\s,。;;::]+$/g, "").trim(); +} + +function trimLeadingDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+/g, "").trimStart(); +} + +function normalizeFormType(value: string | undefined): FormType | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + if ( + normalized === "问卷表单" || + normalized === "调查表单" || + normalized === "survey" || + normalized === "survey form" + ) { + return "survey_form"; + } + if (normalized === "线索表单" || normalized === "lead" || normalized === "lead form") { + return "lead_form"; + } + if ( + normalized === "报名表单" || + normalized === "registration" || + normalized === "registration form" + ) { + return "registration_form"; + } + if ( + normalized === "反馈表单" || + normalized === "feedback" || + normalized === "feedback form" + ) { + return "feedback_form"; + } + if ( + normalized === "申请表单" || + normalized === "application" || + normalized === "application form" + ) { + return "application_form"; + } + return undefined; +} + +function stripField(body: string, regex: RegExp): string { + return body.replace(regex, " "); +} + +function consumeLeadingFormFields(body: string): { + formType?: FormType; + style?: string; + audience?: string; + fieldCount?: number; + promptBody: string; +} { + let remaining = body.trim(); + let formType = normalizeFormType(remaining.match(LEADING_FORM_TYPE_REGEX)?.[1]?.trim()); + let style: string | undefined; + let audience: string | undefined; + let fieldCount: number | undefined; + + if (formType) { + remaining = trimLeadingDecorations(stripField(remaining, LEADING_FORM_TYPE_REGEX)); + } + + while (remaining) { + const explicitFormType = normalizeFormType( + remaining.match(LEADING_EXPLICIT_FORM_TYPE_REGEX)?.[1]?.trim(), + ); + if (explicitFormType) { + formType = formType || explicitFormType; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_FORM_TYPE_REGEX), + ); + continue; + } + + const explicitStyle = trimDecorations( + remaining.match(LEADING_EXPLICIT_STYLE_REGEX)?.[1] || "", + ); + if (explicitStyle) { + style = style || explicitStyle; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_STYLE_REGEX), + ); + continue; + } + + const explicitAudience = trimDecorations( + remaining.match(LEADING_EXPLICIT_AUDIENCE_REGEX)?.[1] || "", + ); + if (explicitAudience) { + audience = audience || explicitAudience; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_AUDIENCE_REGEX), + ); + continue; + } + + const fieldCountRaw = + remaining.match(LEADING_EXPLICIT_FIELD_COUNT_REGEX)?.[1]?.trim(); + if (fieldCountRaw) { + const parsedFieldCount = Number.parseInt(fieldCountRaw, 10); + fieldCount = + Number.isFinite(parsedFieldCount) && parsedFieldCount > 0 + ? Math.max(1, parsedFieldCount) + : fieldCount; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_FIELD_COUNT_REGEX), + ); + continue; + } + + break; + } + + return { + formType, + style, + audience, + fieldCount, + promptBody: remaining, + }; +} + +function stripPromptDecorations(body: string, formType?: string): string { + const leadingFormTypeRegex = formType + ? new RegExp( + `^${formType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s,,。;;::])`, + "i", + ) + : /^$/; + + return trimDecorations( + body + .replace(leadingFormTypeRegex, "") + .replace(PROMPT_PREFIX_REGEX, "") + .replace(/\s+/g, " "), + ); +} + +export function parseFormWorkbenchCommand( + text: string, +): ParsedFormWorkbenchCommand | null { + const matched = text.match(FORM_COMMAND_PREFIX_REGEX); + if (!matched) { + return null; + } + + const body = (matched[2] || "").trim(); + const { formType, style, audience, fieldCount, promptBody } = + consumeLeadingFormFields(body); + const explicitFormType = body.match(EXPLICIT_FORM_TYPE_REGEX)?.[1]?.trim(); + const leadingFormType = body.match(LEADING_FORM_TYPE_REGEX)?.[1]?.trim(); + const prompt = stripPromptDecorations( + promptBody, + explicitFormType || leadingFormType, + ); + + return { + rawText: text, + trigger: normalizeTrigger(matched[1] || ""), + body, + prompt, + formType, + style: style || undefined, + audience: audience || undefined, + fieldCount, + }; +} diff --git a/src/components/agent/chat/utils/internalArtifactVisibility.test.ts b/src/components/agent/chat/utils/internalArtifactVisibility.test.ts new file mode 100644 index 000000000..acc3d19bd --- /dev/null +++ b/src/components/agent/chat/utils/internalArtifactVisibility.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { isHiddenInternalArtifactPath } from "./internalArtifactVisibility"; + +describe("isHiddenInternalArtifactPath", () => { + it("应隐藏 .lime/tasks 下的内部任务快照 JSON", () => { + expect( + isHiddenInternalArtifactPath(".lime/tasks/image_generate/task-image-1.json"), + ).toBe(true); + expect( + isHiddenInternalArtifactPath( + "/workspace/demo/.lime/tasks/image_generate/task-image-1.json", + ), + ).toBe(true); + }); + + it("不应隐藏用户可消费的正式产物", () => { + expect( + isHiddenInternalArtifactPath("content-posts/demo.publish-pack.json"), + ).toBe(false); + expect( + isHiddenInternalArtifactPath(".lime/artifacts/thread-1/report.artifact.json"), + ).toBe(false); + expect(isHiddenInternalArtifactPath("content-posts/demo-cover.png")).toBe( + false, + ); + }); +}); diff --git a/src/components/agent/chat/utils/internalArtifactVisibility.ts b/src/components/agent/chat/utils/internalArtifactVisibility.ts new file mode 100644 index 000000000..e4c47e6d4 --- /dev/null +++ b/src/components/agent/chat/utils/internalArtifactVisibility.ts @@ -0,0 +1,17 @@ +import { normalizeArtifactProtocolPath } from "@/lib/artifact-protocol"; + +function normalizeArtifactPath(path?: string | null): string { + return path ? normalizeArtifactProtocolPath(path) : ""; +} + +export function isHiddenInternalArtifactPath(path?: string | null): boolean { + const normalizedPath = normalizeArtifactPath(path); + if (!normalizedPath || !normalizedPath.endsWith(".json")) { + return false; + } + + return ( + normalizedPath.startsWith(".lime/tasks/") || + normalizedPath.includes("/.lime/tasks/") + ); +} diff --git a/src/components/agent/chat/utils/messageArtifacts.test.ts b/src/components/agent/chat/utils/messageArtifacts.test.ts index 7039b236e..8914341d8 100644 --- a/src/components/agent/chat/utils/messageArtifacts.test.ts +++ b/src/components/agent/chat/utils/messageArtifacts.test.ts @@ -4,6 +4,8 @@ import type { Artifact } from "@/lib/artifact/types"; import { ARTIFACT_DOCUMENT_SCHEMA_VERSION } from "@/lib/artifact-document"; import { buildArtifactFromWrite, + findMessageArtifact, + mergeArtifacts, resolveDefaultArtifactViewMode, } from "./messageArtifacts"; @@ -247,3 +249,60 @@ describe("messageArtifacts.resolveDefaultArtifactViewMode", () => { ).toBe("preview"); }); }); + +describe("messageArtifacts 路径归一", () => { + it("应把文件名、相对路径与绝对路径视作同一个 artifact", () => { + const message = { + artifacts: [ + createArtifact({ + id: "artifact-output-image", + title: "output_image.jpg", + meta: { + filePath: + "/Users/coso/Documents/dev/ai/aiclientproxy/lime/.lime/tasks/image/output_image.jpg", + filename: "output_image.jpg", + }, + }), + ], + }; + + expect( + findMessageArtifact(message, { + filePath: "output_image.jpg", + })?.id, + ).toBe("artifact-output-image"); + expect( + findMessageArtifact(message, { + filePath: ".lime/tasks/image/output_image.jpg", + })?.id, + ).toBe("artifact-output-image"); + }); + + it("应在 mergeArtifacts 时收敛同一路径的重复 artifact 并保留更完整路径", () => { + const merged = mergeArtifacts([ + createArtifact({ + id: "artifact-image-short", + title: "output_image.jpg", + meta: { + filePath: "output_image.jpg", + filename: "output_image.jpg", + }, + }), + createArtifact({ + id: "artifact-image-absolute", + title: "output_image.jpg", + updatedAt: 3, + meta: { + filePath: + "/Users/coso/Documents/dev/ai/aiclientproxy/lime/.lime/tasks/image/output_image.jpg", + filename: "output_image.jpg", + }, + }), + ]); + + expect(merged).toHaveLength(1); + expect(merged[0]?.meta.filePath).toBe( + "/Users/coso/Documents/dev/ai/aiclientproxy/lime/.lime/tasks/image/output_image.jpg", + ); + }); +}); diff --git a/src/components/agent/chat/utils/messageArtifacts.ts b/src/components/agent/chat/utils/messageArtifacts.ts index a518d7392..a8c558201 100644 --- a/src/components/agent/chat/utils/messageArtifacts.ts +++ b/src/components/agent/chat/utils/messageArtifacts.ts @@ -5,7 +5,9 @@ import type { ArtifactType, } from "@/lib/artifact/types"; import { + areArtifactProtocolPathsEquivalent, hasArtifactProtocolDocumentMetadata, + normalizeArtifactProtocolPath, resolveArtifactProtocolDocumentPayload, resolveArtifactProtocolFilePath, resolveArtifactProtocolPreviewText, @@ -71,7 +73,7 @@ const ARTIFACT_TYPE_ALIASES: Record = { const ARTIFACT_PREVIEW_MAX_CHARS = 240; function normalizePath(path: string): string { - return path.replace(/\\/g, "/").trim(); + return normalizeArtifactProtocolPath(path); } function fileNameFromPath(path: string): string { @@ -193,7 +195,7 @@ export function findMessageArtifact( const artifactPath = normalizePath( resolveArtifactProtocolFilePath(artifact), ); - return artifactPath === normalizedPath; + return areArtifactProtocolPathsEquivalent(artifactPath, normalizedPath); }); } @@ -422,9 +424,17 @@ export function upsertMessageArtifact( artifact: Artifact, ): Message { const currentArtifacts = message.artifacts || []; - const existingIndex = currentArtifacts.findIndex( - (item) => item.id === artifact.id, - ); + const artifactPath = resolveArtifactProtocolFilePath(artifact); + const existingIndex = currentArtifacts.findIndex((item) => { + if (item.id === artifact.id) { + return true; + } + + return areArtifactProtocolPathsEquivalent( + resolveArtifactProtocolFilePath(item), + artifactPath, + ); + }); if (existingIndex < 0) { return { @@ -434,12 +444,20 @@ export function upsertMessageArtifact( } const nextArtifacts = [...currentArtifacts]; + const existing = nextArtifacts[existingIndex]; + const existingPath = resolveArtifactProtocolFilePath(existing); + const preferredPath = + normalizePath(artifactPath).length >= normalizePath(existingPath).length + ? artifactPath + : existingPath; nextArtifacts[existingIndex] = { - ...nextArtifacts[existingIndex], + ...existing, ...artifact, meta: { - ...nextArtifacts[existingIndex].meta, + ...existing.meta, ...artifact.meta, + filePath: preferredPath, + filename: fileNameFromPath(preferredPath), }, updatedAt: artifact.updatedAt, }; @@ -484,27 +502,46 @@ export function updateMessageArtifactsStatus( } export function mergeArtifacts(artifacts: Artifact[]): Artifact[] { - const merged = new Map(); + const merged: Artifact[] = []; for (const artifact of artifacts) { - const existing = merged.get(artifact.id); - if (!existing) { - merged.set(artifact.id, artifact); + const artifactPath = resolveArtifactProtocolFilePath(artifact); + const existingIndex = merged.findIndex((existingArtifact) => { + if (existingArtifact.id === artifact.id) { + return true; + } + + return areArtifactProtocolPathsEquivalent( + resolveArtifactProtocolFilePath(existingArtifact), + artifactPath, + ); + }); + + if (existingIndex < 0) { + merged.push(artifact); continue; } - merged.set(artifact.id, { + const existing = merged[existingIndex]; + const existingPath = resolveArtifactProtocolFilePath(existing); + const preferredPath = + normalizePath(artifactPath).length >= normalizePath(existingPath).length + ? artifactPath + : existingPath; + merged[existingIndex] = { ...existing, ...artifact, meta: { ...existing.meta, ...artifact.meta, + filePath: preferredPath, + filename: fileNameFromPath(preferredPath), }, updatedAt: Math.max(existing.updatedAt, artifact.updatedAt), - }); + }; } - return Array.from(merged.values()).sort((left, right) => { + return merged.sort((left, right) => { if (left.updatedAt !== right.updatedAt) { return left.updatedAt - right.updatedAt; } diff --git a/src/components/agent/chat/utils/presentationWorkbenchCommand.test.ts b/src/components/agent/chat/utils/presentationWorkbenchCommand.test.ts new file mode 100644 index 000000000..5bf6625bf --- /dev/null +++ b/src/components/agent/chat/utils/presentationWorkbenchCommand.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parsePresentationWorkbenchCommand } from "./presentationWorkbenchCommand"; + +describe("parsePresentationWorkbenchCommand", () => { + it("应解析带类型、风格、受众和页数的 @PPT 命令", () => { + const result = parsePresentationWorkbenchCommand( + "@PPT 类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿", + ); + + expect(result).toMatchObject({ + trigger: "@PPT", + deckType: "pitch_deck", + style: "极简科技", + audience: "投资人", + slideCount: 10, + prompt: "帮我做一个 AI 助手创业项目融资演示稿", + }); + }); + + it("应兼容 @slides 英文触发", () => { + const result = parsePresentationWorkbenchCommand( + "@slides pitch deck for an AI note startup audience: seed investors", + ); + + expect(result).toMatchObject({ + trigger: "@slides", + deckType: "pitch_deck", + prompt: "for an AI note startup audience: seed investors", + }); + }); + + it("没有显式字段时也应保留演示稿生成意图", () => { + const result = parsePresentationWorkbenchCommand( + "@演示 帮我整理一份面向内部复盘会的项目汇报稿", + ); + + expect(result).toMatchObject({ + trigger: "@演示", + deckType: undefined, + prompt: "帮我整理一份面向内部复盘会的项目汇报稿", + }); + }); + + it("非演示命令应返回空", () => { + expect(parsePresentationWorkbenchCommand("@网页 帮我做个官网")).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/presentationWorkbenchCommand.ts b/src/components/agent/chat/utils/presentationWorkbenchCommand.ts new file mode 100644 index 000000000..fc869cdc3 --- /dev/null +++ b/src/components/agent/chat/utils/presentationWorkbenchCommand.ts @@ -0,0 +1,245 @@ +export type PresentationWorkbenchCommandTrigger = + | "@PPT" + | "@ppt" + | "@slides" + | "@演示"; + +export type PresentationDeckType = + | "pitch_deck" + | "sales_deck" + | "training_deck" + | "report_deck" + | "proposal_deck"; + +export interface ParsedPresentationWorkbenchCommand { + rawText: string; + trigger: PresentationWorkbenchCommandTrigger; + body: string; + prompt: string; + deckType?: PresentationDeckType; + style?: string; + audience?: string; + slideCount?: number; +} + +const PRESENTATION_COMMAND_PREFIX_REGEX = + /^\s*(@PPT|@ppt|@slides|@演示)(?:\s+|$)([\s\S]*)$/i; +const EXPLICIT_DECK_TYPE_REGEX = + /(?:类型|演示类型|type)\s*[::=]?\s*(路演PPT|融资PPT|销售PPT|培训PPT|汇报PPT|方案PPT|pitch(?:\s+deck)?|sales(?:\s+deck)?|training(?:\s+deck)?|report(?:\s+deck)?|proposal(?:\s+deck)?)(?=$|[\s,,。;;::])/i; +const LEADING_DECK_TYPE_REGEX = + /^(路演PPT|融资PPT|销售PPT|培训PPT|汇报PPT|方案PPT|pitch(?:\s+deck)?|sales(?:\s+deck)?|training(?:\s+deck)?|report(?:\s+deck)?|proposal(?:\s+deck)?)(?=$|[\s,,。;;::])/i; +const LEADING_EXPLICIT_DECK_TYPE_REGEX = + /^(?:类型|演示类型|type)\s*[::=]?\s*(路演PPT|融资PPT|销售PPT|培训PPT|汇报PPT|方案PPT|pitch(?:\s+deck)?|sales(?:\s+deck)?|training(?:\s+deck)?|report(?:\s+deck)?|proposal(?:\s+deck)?)(?=$|[\s,,。;;::])/i; +const PRESENTATION_PROMPT_BOUNDARY_PATTERN = + String.raw`(?:\s+(?:帮我|给我|请|生成|制作|整理|输出|做一个|做个|create|generate|build|draft))`; +const LEADING_EXPLICIT_STYLE_REGEX = new RegExp( + String.raw`^(?:风格|style)\s*[::=]?\s*(.+?)(?=$|[,,。;;\n]|(?:\s+(?:受众|对象|audience|页数|页|slides?|类型|演示类型|type)\s*[::=]?)|${PRESENTATION_PROMPT_BOUNDARY_PATTERN})`, + "i", +); +const LEADING_EXPLICIT_AUDIENCE_REGEX = new RegExp( + String.raw`^(?:受众|对象|audience)\s*[::=]?\s*(.+?)(?=$|[,,。;;\n]|(?:\s+(?:风格|style|页数|页|slides?|类型|演示类型|type)\s*[::=]?)|${PRESENTATION_PROMPT_BOUNDARY_PATTERN})`, + "i", +); +const LEADING_EXPLICIT_SLIDE_COUNT_REGEX = + /^(?:页数|页|slides?)\s*[::=]?\s*(\d{1,2})/i; +const PROMPT_PREFIX_REGEX = + /^\s*(生成|制作|整理|输出|做一个|做个|create|generate|build|draft)(?:\s|$|[::])*/i; + +function normalizeTrigger(value: string): PresentationWorkbenchCommandTrigger { + const normalized = value.trim().toLowerCase(); + if (normalized === "@ppt") { + return "@PPT"; + } + if (normalized === "@slides") { + return "@slides"; + } + if (normalized === "@演示") { + return "@演示"; + } + return "@PPT"; +} + +function trimDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+|[,\s,。;;::]+$/g, "").trim(); +} + +function trimLeadingDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+/g, "").trimStart(); +} + +function normalizeDeckType( + value: string | undefined, +): PresentationDeckType | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + if ( + normalized === "路演ppt" || + normalized === "融资ppt" || + normalized === "pitch" || + normalized === "pitch deck" + ) { + return "pitch_deck"; + } + if (normalized === "销售ppt" || normalized === "sales" || normalized === "sales deck") { + return "sales_deck"; + } + if ( + normalized === "培训ppt" || + normalized === "training" || + normalized === "training deck" + ) { + return "training_deck"; + } + if ( + normalized === "汇报ppt" || + normalized === "report" || + normalized === "report deck" + ) { + return "report_deck"; + } + if ( + normalized === "方案ppt" || + normalized === "proposal" || + normalized === "proposal deck" + ) { + return "proposal_deck"; + } + return undefined; +} + +function stripField(body: string, regex: RegExp): string { + return body.replace(regex, " "); +} + +function consumeLeadingPresentationFields(body: string): { + deckType?: PresentationDeckType; + style?: string; + audience?: string; + slideCount?: number; + promptBody: string; +} { + let remaining = body.trim(); + let deckType = normalizeDeckType(remaining.match(LEADING_DECK_TYPE_REGEX)?.[1]?.trim()); + let style: string | undefined; + let audience: string | undefined; + let slideCount: number | undefined; + + if (deckType) { + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_DECK_TYPE_REGEX), + ); + } + + while (remaining) { + const explicitDeckType = normalizeDeckType( + remaining.match(LEADING_EXPLICIT_DECK_TYPE_REGEX)?.[1]?.trim(), + ); + if (explicitDeckType) { + deckType = deckType || explicitDeckType; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_DECK_TYPE_REGEX), + ); + continue; + } + + const explicitStyle = trimDecorations( + remaining.match(LEADING_EXPLICIT_STYLE_REGEX)?.[1] || "", + ); + if (explicitStyle) { + style = style || explicitStyle; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_STYLE_REGEX), + ); + continue; + } + + const explicitAudience = trimDecorations( + remaining.match(LEADING_EXPLICIT_AUDIENCE_REGEX)?.[1] || "", + ); + if (explicitAudience) { + audience = audience || explicitAudience; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_AUDIENCE_REGEX), + ); + continue; + } + + const slideCountRaw = + remaining.match(LEADING_EXPLICIT_SLIDE_COUNT_REGEX)?.[1]?.trim(); + if (slideCountRaw) { + const parsedSlideCount = Number.parseInt(slideCountRaw, 10); + slideCount = + Number.isFinite(parsedSlideCount) && parsedSlideCount > 0 + ? Math.max(1, parsedSlideCount) + : slideCount; + remaining = trimLeadingDecorations( + stripField(remaining, LEADING_EXPLICIT_SLIDE_COUNT_REGEX), + ); + continue; + } + + break; + } + + return { + deckType, + style, + audience, + slideCount, + promptBody: remaining, + }; +} + +function stripPromptDecorations(body: string, deckType?: string): string { + const leadingDeckTypeRegex = deckType + ? new RegExp( + `^${deckType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s,,。;;::])`, + "i", + ) + : /^$/; + + return trimDecorations( + body + .replace(leadingDeckTypeRegex, "") + .replace(PROMPT_PREFIX_REGEX, "") + .replace(/\s+/g, " "), + ); +} + +export function parsePresentationWorkbenchCommand( + text: string, +): ParsedPresentationWorkbenchCommand | null { + const matched = text.match(PRESENTATION_COMMAND_PREFIX_REGEX); + if (!matched) { + return null; + } + + const body = (matched[2] || "").trim(); + const { + deckType, + style, + audience, + slideCount, + promptBody, + } = consumeLeadingPresentationFields(body); + const explicitDeckType = body.match(EXPLICIT_DECK_TYPE_REGEX)?.[1]?.trim(); + const leadingDeckType = body.match(LEADING_DECK_TYPE_REGEX)?.[1]?.trim(); + const prompt = stripPromptDecorations( + promptBody, + explicitDeckType || leadingDeckType, + ); + + return { + rawText: text, + trigger: normalizeTrigger(matched[1] || ""), + body, + prompt, + deckType, + style: style || undefined, + audience: audience || undefined, + slideCount, + }; +} diff --git a/src/components/agent/chat/utils/publishWorkbenchCommand.test.ts b/src/components/agent/chat/utils/publishWorkbenchCommand.test.ts new file mode 100644 index 000000000..d035acff0 --- /dev/null +++ b/src/components/agent/chat/utils/publishWorkbenchCommand.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { parsePublishWorkbenchCommand } from "./publishWorkbenchCommand"; + +describe("parsePublishWorkbenchCommand", () => { + it("应解析带显式平台的 @发布 命令", () => { + const result = parsePublishWorkbenchCommand( + "@发布 平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本", + ); + + expect(result).toMatchObject({ + trigger: "@发布", + platformType: "wechat_official_account", + platformLabel: "微信公众号后台", + prompt: "帮我把这篇文章整理成可直接发布的版本", + }); + }); + + it("应兼容英文触发与英文平台名", () => { + const result = parsePublishWorkbenchCommand( + "@publish xiaohongshu turn this draft into a ready-to-post version", + ); + + expect(result).toMatchObject({ + trigger: "@publish", + platformType: "xiaohongshu", + platformLabel: "小红书", + prompt: "turn this draft into a ready-to-post version", + }); + }); + + it("没有显式平台时也应保留发布意图", () => { + const result = parsePublishWorkbenchCommand( + "@发文 请帮我整理这篇稿子,并给出发布前检查清单", + ); + + expect(result).toMatchObject({ + trigger: "@发文", + platformType: undefined, + prompt: "请帮我整理这篇稿子,并给出发布前检查清单", + }); + }); + + it("非发布命令应返回空", () => { + expect(parsePublishWorkbenchCommand("@表单 做个报名表")).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/publishWorkbenchCommand.ts b/src/components/agent/chat/utils/publishWorkbenchCommand.ts new file mode 100644 index 000000000..70048f20c --- /dev/null +++ b/src/components/agent/chat/utils/publishWorkbenchCommand.ts @@ -0,0 +1,138 @@ +export type PublishWorkbenchCommandTrigger = + | "@发布" + | "@publish" + | "@发文" + | "@投稿"; + +export type PublishPlatformType = + | "wechat_official_account" + | "xiaohongshu" + | "zhihu" + | "douyin" + | "bilibili"; + +export interface ParsedPublishWorkbenchCommand { + rawText: string; + trigger: PublishWorkbenchCommandTrigger; + body: string; + prompt: string; + platformType?: PublishPlatformType; + platformLabel?: string; +} + +const PUBLISH_COMMAND_PREFIX_REGEX = + /^\s*(@发布|@publish|@发文|@投稿)(?:\s+|$)([\s\S]*)$/i; +const EXPLICIT_PLATFORM_REGEX = + /(?:平台|渠道|platform|channel)\s*[::=]?\s*(微信公众号后台|微信公众平台|公众号后台|公众号|小红书|知乎|抖音|B站|bilibili|wechat|wechat official account|xiaohongshu|zhihu|douyin)(?=$|[\s,,。;;::])/i; +const LEADING_PLATFORM_REGEX = + /^(微信公众号后台|微信公众平台|公众号后台|公众号|小红书|知乎|抖音|B站|bilibili|wechat|wechat official account|xiaohongshu|zhihu|douyin)(?=$|[\s,,。;;::])/i; + +function trimDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+|[,\s,。;;::]+$/g, "").trim(); +} + +function normalizeTrigger(value: string): PublishWorkbenchCommandTrigger { + const normalized = value.trim().toLowerCase(); + if (normalized === "@publish") { + return "@publish"; + } + if (normalized === "@发文") { + return "@发文"; + } + if (normalized === "@投稿") { + return "@投稿"; + } + return "@发布"; +} + +function normalizePlatform(value?: string): { + platformType?: PublishPlatformType; + platformLabel?: string; +} { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return {}; + } + + if ( + normalized === "微信公众号后台" || + normalized === "微信公众平台" || + normalized === "公众号后台" || + normalized === "公众号" || + normalized === "wechat" || + normalized === "wechat official account" + ) { + return { + platformType: "wechat_official_account", + platformLabel: "微信公众号后台", + }; + } + if (normalized === "小红书" || normalized === "xiaohongshu") { + return { + platformType: "xiaohongshu", + platformLabel: "小红书", + }; + } + if (normalized === "知乎" || normalized === "zhihu") { + return { + platformType: "zhihu", + platformLabel: "知乎", + }; + } + if (normalized === "抖音" || normalized === "douyin") { + return { + platformType: "douyin", + platformLabel: "抖音", + }; + } + if (normalized === "b站" || normalized === "bilibili") { + return { + platformType: "bilibili", + platformLabel: "B站", + }; + } + return {}; +} + +function stripPromptDecorations(body: string, platformText?: string): string { + const leadingPlatformRegex = platformText + ? new RegExp( + `^${platformText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s,,。;;::])`, + "i", + ) + : /^$/; + + return trimDecorations( + body + .replace(EXPLICIT_PLATFORM_REGEX, " ") + .trimStart() + .replace(leadingPlatformRegex, "") + .replace(/\s+/g, " "), + ); +} + +export function parsePublishWorkbenchCommand( + text: string, +): ParsedPublishWorkbenchCommand | null { + const matched = text.match(PUBLISH_COMMAND_PREFIX_REGEX); + if (!matched) { + return null; + } + + const body = (matched[2] || "").trim(); + const explicitPlatform = body.match(EXPLICIT_PLATFORM_REGEX)?.[1]?.trim(); + const leadingPlatform = body.match(LEADING_PLATFORM_REGEX)?.[1]?.trim(); + const { platformType, platformLabel } = normalizePlatform( + explicitPlatform || leadingPlatform, + ); + const prompt = stripPromptDecorations(body, explicitPlatform || leadingPlatform); + + return { + rawText: text, + trigger: normalizeTrigger(matched[1] || ""), + body, + prompt: prompt || body, + platformType, + platformLabel, + }; +} diff --git a/src/components/agent/chat/utils/taskPreviewFromToolResult.test.ts b/src/components/agent/chat/utils/taskPreviewFromToolResult.test.ts new file mode 100644 index 000000000..4811da94b --- /dev/null +++ b/src/components/agent/chat/utils/taskPreviewFromToolResult.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { + buildImageTaskPreviewFromToolResult, + buildTaskPreviewFromToolResult, +} from "./taskPreviewFromToolResult"; + +describe("buildImageTaskPreviewFromToolResult", () => { + it("应在图片任务完成后输出更友好的完成态摘要", () => { + const preview = buildImageTaskPreviewFromToolResult({ + toolId: "tool-1", + toolName: "Bash", + toolArguments: JSON.stringify({ + command: 'lime media image generate --prompt "未来感青柠实验室"', + }), + toolResult: { + metadata: { + task_id: "task-1", + task_type: "image_generate", + status: "succeeded", + prompt: "未来感青柠实验室", + size: "1024x1024", + project_id: "project-1", + content_id: "content-1", + requested_count: 2, + received_count: 2, + }, + }, + fallbackPrompt: "@配图 未来感青柠实验室", + }); + + expect(preview).toMatchObject({ + taskId: "task-1", + prompt: "未来感青柠实验室", + status: "complete", + imageCount: 2, + size: "1024x1024", + projectId: "project-1", + contentId: "content-1", + phase: "succeeded", + statusMessage: "图片已生成完成,共 2 张。", + }); + }); + + it("应在图片任务失败时输出失败态摘要", () => { + const preview = buildImageTaskPreviewFromToolResult({ + toolId: "tool-2", + toolName: "Bash", + toolArguments: JSON.stringify({ + command: 'lime media image generate --prompt "未来感青柠实验室"', + }), + toolResult: { + metadata: { + task_id: "task-2", + task_type: "image_generate", + status: "failed", + }, + }, + fallbackPrompt: "@配图 未来感青柠实验室", + }); + + expect(preview).toMatchObject({ + taskId: "task-2", + status: "failed", + phase: "failed", + statusMessage: "图片任务执行失败,请查看工具结果或任务详情。", + }); + }); + + it("图片任务完成但尚未带回数量时,应输出面向用户的完成态文案", () => { + const preview = buildImageTaskPreviewFromToolResult({ + toolId: "tool-3", + toolName: "Bash", + toolArguments: JSON.stringify({ + command: 'lime media image generate --prompt "清晨广州塔"', + }), + toolResult: { + metadata: { + task_id: "task-3", + task_type: "image_generate", + status: "succeeded", + prompt: "清晨广州塔", + size: "1024x1024", + }, + }, + fallbackPrompt: "@配图 清晨广州塔", + }); + + expect(preview).toMatchObject({ + taskId: "task-3", + status: "complete", + phase: "succeeded", + statusMessage: "图片结果已生成完成,可在右侧查看与使用。", + }); + }); + + it("图片任务刚提交时,应输出用户可理解的排队态文案", () => { + const preview = buildImageTaskPreviewFromToolResult({ + toolId: "tool-4", + toolName: "Bash", + toolArguments: JSON.stringify({ + command: 'lime media image generate --prompt "广州塔夜景"', + }), + toolResult: { + metadata: { + task_id: "task-4", + task_type: "image_generate", + status: "queued", + prompt: "广州塔夜景", + }, + }, + fallbackPrompt: "@配图 广州塔夜景", + }); + + expect(preview).toMatchObject({ + taskId: "task-4", + status: "running", + phase: "queued", + statusMessage: "图片任务已提交,正在排队处理。", + }); + }); +}); + +describe("buildTaskPreviewFromToolResult video", () => { + it("视频任务完成但尚未带回结果地址时,应输出完成态同步文案", () => { + const preview = buildTaskPreviewFromToolResult({ + toolId: "tool-video-1", + toolName: "Bash", + toolArguments: JSON.stringify({ + command: + 'lime media video generate --prompt "广州塔城市宣传片" --duration 15 --aspect-ratio 16:9 --resolution 720p', + }), + toolResult: { + metadata: { + task_id: "task-video-1", + task_type: "video_generate", + status: "succeeded", + prompt: "广州塔城市宣传片", + }, + }, + fallbackPrompt: "@视频 15秒 广州塔城市宣传片,16:9,720p", + }); + + expect(preview).toMatchObject({ + kind: "video_generate", + taskId: "task-video-1", + status: "complete", + phase: "succeeded", + statusMessage: "视频已经生成完成,正在同步最终结果。", + }); + }); + + it("视频任务排队中时,应输出用户可理解的排队态文案", () => { + const preview = buildTaskPreviewFromToolResult({ + toolId: "tool-video-2", + toolName: "Bash", + toolArguments: JSON.stringify({ + command: + 'lime media video generate --prompt "新品发布短视频" --duration 15 --aspect-ratio 16:9 --resolution 720p', + }), + toolResult: { + metadata: { + task_id: "task-video-2", + task_type: "video_generate", + status: "queued", + prompt: "新品发布短视频", + }, + }, + fallbackPrompt: "@视频 15秒 新品发布短视频,16:9,720p", + }); + + expect(preview).toMatchObject({ + kind: "video_generate", + taskId: "task-video-2", + status: "running", + phase: "queued", + statusMessage: "视频任务已进入排队队列,稍后会自动开始生成。", + }); + }); +}); diff --git a/src/components/agent/chat/utils/taskPreviewFromToolResult.ts b/src/components/agent/chat/utils/taskPreviewFromToolResult.ts index c9dd2bd4c..9c14e9b37 100644 --- a/src/components/agent/chat/utils/taskPreviewFromToolResult.ts +++ b/src/components/agent/chat/utils/taskPreviewFromToolResult.ts @@ -149,6 +149,18 @@ function resolveTaskPreviewStatus( function resolveTaskPreviewPhase(status: string | undefined): string { switch ((status || "").trim().toLowerCase()) { + case "completed": + case "success": + case "succeeded": + return "succeeded"; + case "partial": + return "partial"; + case "failed": + case "error": + return "failed"; + case "cancelled": + case "canceled": + return "cancelled"; case "queued": case "pending_submit": case "pending": @@ -414,9 +426,7 @@ function readWebImageSearchResult(params: ToolResultPreviewParams): { `图片候选 ${index + 1}`, }; }) - .filter( - (item): item is MessageTaskPreviewImageCandidate => item !== null, - ); + .filter((item): item is MessageTaskPreviewImageCandidate => item !== null); if (hits.length === 0) { return null; @@ -642,18 +652,60 @@ export function buildImageTaskPreviewFromToolResult( params.toolArguments, ); const status = readMetadataString([metadata], ["status"]); + const previewStatus = resolveTaskPreviewStatus(status); + const requestedCount = + parsedArguments.imageCount || + readMetadataPositiveNumber( + [metadata], + [ + "requested_count", + "requestedCount", + "count", + "image_count", + "imageCount", + ], + ); + const receivedCount = readMetadataPositiveNumber( + [metadata], + ["received_count", "receivedCount"], + ); + const resolvedImageCount = + previewStatus === "running" + ? requestedCount + : receivedCount || requestedCount; + const statusMessage = + previewStatus === "complete" + ? receivedCount && receivedCount > 0 + ? `图片已生成完成,共 ${receivedCount} 张。` + : "图片结果已生成完成,可在右侧查看与使用。" + : previewStatus === "partial" + ? receivedCount && receivedCount > 0 + ? `图片已返回部分结果,共 ${receivedCount} 张。` + : "图片已返回部分结果,可在右侧继续查看。" + : previewStatus === "failed" + ? "图片任务执行失败,请查看工具结果或任务详情。" + : previewStatus === "cancelled" + ? "图片任务已取消。" + : "图片任务已提交,正在排队处理。"; return { taskId, prompt: parsedArguments.prompt || + readMetadataString([metadata], ["prompt"]) || params.fallbackPrompt.trim() || "图片任务进行中", - status: resolveTaskPreviewStatus(status), - imageCount: parsedArguments.imageCount, - size: parsedArguments.size, + status: previewStatus, + projectId: + readMetadataString([metadata], ["project_id", "projectId"]) || null, + contentId: + readMetadataString([metadata], ["content_id", "contentId"]) || null, + imageCount: resolvedImageCount, + size: + parsedArguments.size || + readMetadataString([metadata], ["size", "resolution"]), phase: resolveTaskPreviewPhase(status), - statusMessage: "任务已提交到异步队列,正在同步任务状态。", + statusMessage, }; } @@ -662,9 +714,10 @@ function buildVideoTaskPreviewFromToolResult( ): MessageVideoTaskPreview | null { const resultRecord = asRecord(params.toolResult); const metadata = asRecord(resultRecord?.metadata); - const taskResult = asRecord(resultRecord?.result); + const taskResult = + asRecord(resultRecord?.result) || asRecord(metadata?.result); const firstVideo = readFirstArrayRecord( - [taskResult, resultRecord], + [taskResult, metadata, resultRecord], ["videos", "results"], ); const taskId = readMetadataString( @@ -693,11 +746,11 @@ function buildVideoTaskPreviewFromToolResult( ["status"], ); const videoUrl = readMetadataString( - [firstVideo, taskResult, resultRecord], + [firstVideo, taskResult, metadata, resultRecord], ["url", "result_url", "resultUrl"], ); const thumbnailUrl = readMetadataString( - [firstVideo, taskResult, resultRecord], + [firstVideo, taskResult, metadata, resultRecord], ["thumbnail_url", "thumbnailUrl", "poster_url", "posterUrl"], ); const durationMs = readMetadataPositiveNumber( @@ -707,6 +760,27 @@ function buildVideoTaskPreviewFromToolResult( const durationSeconds = parsedArguments.durationSeconds || (durationMs ? Math.max(1, Math.round(durationMs / 1000)) : undefined); + const previewStatus = resolveTaskPreviewStatus(status); + const phase = resolveTaskPreviewPhase(status); + const statusMessage = + previewStatus === "complete" + ? videoUrl + ? "视频结果已同步,打开查看即可继续预览。" + : "视频已经生成完成,正在同步最终结果。" + : previewStatus === "partial" + ? videoUrl + ? "视频已返回部分结果,打开查看可继续确认当前片段。" + : "视频已返回部分结果,工作区正在补齐剩余结果。" + : previewStatus === "failed" + ? readMetadataString( + [metadata, resultRecord, taskResult], + ["error", "error_message", "errorMessage"], + ) || "视频生成失败,请稍后重试。" + : previewStatus === "cancelled" + ? "视频任务已取消,当前不会继续生成新的结果。" + : phase === "queued" + ? "视频任务已进入排队队列,稍后会自动开始生成。" + : "视频任务正在生成中,工作区会继续同步最新状态。"; return { kind: "video_generate", @@ -751,10 +825,8 @@ function buildVideoTaskPreviewFromToolResult( parsedArguments.model || readMetadataString([metadata, resultRecord, taskResult], ["model"]) || null, - phase: resolveTaskPreviewPhase(status), - statusMessage: videoUrl - ? "视频结果已同步,打开查看即可继续预览。" - : "视频任务已提交到异步队列,正在同步任务状态。", + phase, + statusMessage, }; } diff --git a/src/components/agent/chat/utils/topicProjectResolution.test.ts b/src/components/agent/chat/utils/topicProjectResolution.test.ts index a49fbe433..3ea2cdac1 100644 --- a/src/components/agent/chat/utils/topicProjectResolution.test.ts +++ b/src/components/agent/chat/utils/topicProjectResolution.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isDefaultProjectIdAlias, isLockedProjectConflict, normalizeProjectId, resolveTopicProjectId, @@ -12,6 +13,8 @@ describe("normalizeProjectId", () => { expect(normalizeProjectId(" ")).toBeNull(); expect(normalizeProjectId("__invalid__")).toBeNull(); expect(normalizeProjectId("[object Promise]")).toBeNull(); + expect(normalizeProjectId("default")).toBeNull(); + expect(normalizeProjectId(" Default ")).toBeNull(); }); it("应返回合法项目 ID", () => { @@ -19,6 +22,14 @@ describe("normalizeProjectId", () => { }); }); +describe("isDefaultProjectIdAlias", () => { + it("应识别默认工作区别名", () => { + expect(isDefaultProjectIdAlias("default")).toBe(true); + expect(isDefaultProjectIdAlias(" Default ")).toBe(true); + expect(isDefaultProjectIdAlias("project-1")).toBe(false); + }); +}); + describe("resolveTopicProjectId", () => { it("应优先使用话题绑定项目", () => { expect( diff --git a/src/components/agent/chat/utils/topicProjectResolution.ts b/src/components/agent/chat/utils/topicProjectResolution.ts index ff9029b48..272f92373 100644 --- a/src/components/agent/chat/utils/topicProjectResolution.ts +++ b/src/components/agent/chat/utils/topicProjectResolution.ts @@ -5,6 +5,14 @@ export interface ResolveTopicProjectIdOptions { } const INVALID_PROJECT_IDS = new Set(["__invalid__", "[object Promise]"]); +const DEFAULT_PROJECT_ID_ALIAS = "default"; + +export function isDefaultProjectIdAlias(projectId: unknown): boolean { + return ( + typeof projectId === "string" && + projectId.trim().toLowerCase() === DEFAULT_PROJECT_ID_ALIAS + ); +} export function normalizeProjectId(projectId: unknown): string | null { if (typeof projectId !== "string") { @@ -12,7 +20,11 @@ export function normalizeProjectId(projectId: unknown): string | null { } const normalized = projectId.trim(); - if (!normalized || INVALID_PROJECT_IDS.has(normalized)) { + if ( + !normalized || + INVALID_PROJECT_IDS.has(normalized) || + isDefaultProjectIdAlias(normalized) + ) { return null; } diff --git a/src/components/agent/chat/utils/webpageWorkbenchCommand.test.ts b/src/components/agent/chat/utils/webpageWorkbenchCommand.test.ts new file mode 100644 index 000000000..926499726 --- /dev/null +++ b/src/components/agent/chat/utils/webpageWorkbenchCommand.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { parseWebpageWorkbenchCommand } from "./webpageWorkbenchCommand"; + +describe("parseWebpageWorkbenchCommand", () => { + it("应解析带页面类型、风格和技术约束的 @网页 命令", () => { + const result = parseWebpageWorkbenchCommand( + "@网页 类型:落地页 风格:未来感 技术:原生 HTML 帮我做一个 AI 代码助手官网", + ); + + expect(result).toMatchObject({ + trigger: "@网页", + pageType: "landing_page", + style: "未来感", + techStack: "原生 HTML", + prompt: "帮我做一个 AI 代码助手官网", + }); + }); + + it("应兼容 @webpage 英文触发", () => { + const result = parseWebpageWorkbenchCommand( + "@webpage landing page for an AI note app with calm editorial style", + ); + + expect(result).toMatchObject({ + trigger: "@webpage", + pageType: "landing_page", + prompt: "for an AI note app with calm editorial style", + }); + }); + + it("没有显式字段时也应保留网页生成意图", () => { + const result = parseWebpageWorkbenchCommand( + "@网页 帮我生成一个新品发布活动页,突出预约按钮和核心卖点", + ); + + expect(result).toMatchObject({ + trigger: "@网页", + pageType: undefined, + prompt: "帮我生成一个新品发布活动页,突出预约按钮和核心卖点", + }); + }); + + it("非网页命令应返回空", () => { + expect(parseWebpageWorkbenchCommand("@排版 帮我整理这段文案")).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/utils/webpageWorkbenchCommand.ts b/src/components/agent/chat/utils/webpageWorkbenchCommand.ts new file mode 100644 index 000000000..84662717f --- /dev/null +++ b/src/components/agent/chat/utils/webpageWorkbenchCommand.ts @@ -0,0 +1,168 @@ +export type WebpageWorkbenchCommandTrigger = + | "@网页" + | "@webpage" + | "@landing"; + +export type WebpageType = + | "landing_page" + | "homepage" + | "campaign_page" + | "product_page" + | "docs_page" + | "portfolio" + | "resume_page"; + +export interface ParsedWebpageWorkbenchCommand { + rawText: string; + trigger: WebpageWorkbenchCommandTrigger; + body: string; + prompt: string; + pageType?: WebpageType; + style?: string; + techStack?: string; +} + +const WEBPAGE_COMMAND_PREFIX_REGEX = + /^\s*(@网页|@webpage|@landing)(?:\s+|$)([\s\S]*)$/i; +const EXPLICIT_PAGE_TYPE_REGEX = + /(?:类型|页面类型|type)\s*[::=]?\s*(落地页|landing(?:\s+page)?|官网|首页|home(?:\s+page)?|活动页|campaign(?:\s+page)?|产品页|product(?:\s+page)?|文档页|docs?(?:\s+page)?|作品集|portfolio|简历页|resume(?:\s+page)?)(?=$|[\s,,。;;::])/i; +const LEADING_PAGE_TYPE_REGEX = + /^(落地页|landing(?:\s+page)?|官网|首页|home(?:\s+page)?|活动页|campaign(?:\s+page)?|产品页|product(?:\s+page)?|文档页|docs?(?:\s+page)?|作品集|portfolio|简历页|resume(?:\s+page)?)(?=$|[\s,,。;;::])/i; +const PROMPT_BOUNDARY_PATTERN = + String.raw`(?:\s+(?:帮我|给我|请|生成|制作|搭建|做一个|做个|create|generate|build))`; +const EXPLICIT_STYLE_REGEX = + new RegExp( + String.raw`(?:风格|style)\s*[::=]?\s*(.+?)(?=$|[,,。;;\n]|(?:\s+(?:技术|栈|tech|stack|framework|类型|页面类型|type)\s*[::=]?)|${PROMPT_BOUNDARY_PATTERN})`, + "i", + ); +const EXPLICIT_TECH_STACK_REGEX = + new RegExp( + String.raw`(?:技术|栈|tech|stack|framework)\s*[::=]?\s*(.+?)(?=$|[,,。;;\n]|(?:\s+(?:风格|style|类型|页面类型|type)\s*[::=]?)|${PROMPT_BOUNDARY_PATTERN})`, + "i", + ); +const PROMPT_PREFIX_REGEX = + /^\s*(生成|制作|搭建|做一个|做个|create|generate|build)(?:\s|$|[::])*/i; + +function normalizeTrigger(value: string): WebpageWorkbenchCommandTrigger { + const normalized = value.trim().toLowerCase(); + if (normalized === "@webpage") { + return "@webpage"; + } + if (normalized === "@landing") { + return "@landing"; + } + return "@网页"; +} + +function trimDecorations(value: string): string { + return value.replace(/^[,\s,。;;::]+|[,\s,。;;::]+$/g, "").trim(); +} + +function normalizePageType(value: string | undefined): WebpageType | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + if (normalized === "落地页" || normalized === "landing page" || normalized === "landing") { + return "landing_page"; + } + if ( + normalized === "官网" || + normalized === "首页" || + normalized === "home page" || + normalized === "homepage" || + normalized === "home" + ) { + return "homepage"; + } + if ( + normalized === "活动页" || + normalized === "campaign" || + normalized === "campaign page" + ) { + return "campaign_page"; + } + if ( + normalized === "产品页" || + normalized === "product" || + normalized === "product page" + ) { + return "product_page"; + } + if ( + normalized === "文档页" || + normalized === "docs" || + normalized === "doc" || + normalized === "docs page" || + normalized === "doc page" + ) { + return "docs_page"; + } + if (normalized === "作品集" || normalized === "portfolio") { + return "portfolio"; + } + if ( + normalized === "简历页" || + normalized === "resume" || + normalized === "resume page" + ) { + return "resume_page"; + } + return undefined; +} + +function stripField(body: string, regex: RegExp): string { + return body.replace(regex, " "); +} + +function stripPromptDecorations(body: string, pageType?: string): string { + const leadingPageTypeRegex = pageType + ? new RegExp( + `^${pageType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?=$|[\\s,,。;;::])`, + "i", + ) + : /^$/; + + return trimDecorations( + stripField( + stripField( + stripField( + body.replace(leadingPageTypeRegex, "").replace(PROMPT_PREFIX_REGEX, ""), + EXPLICIT_PAGE_TYPE_REGEX, + ), + EXPLICIT_STYLE_REGEX, + ), + EXPLICIT_TECH_STACK_REGEX, + ) + .replace(PROMPT_PREFIX_REGEX, "") + .replace(/\s+/g, " "), + ); +} + +export function parseWebpageWorkbenchCommand( + text: string, +): ParsedWebpageWorkbenchCommand | null { + const matched = text.match(WEBPAGE_COMMAND_PREFIX_REGEX); + if (!matched) { + return null; + } + + const body = (matched[2] || "").trim(); + const explicitPageType = body.match(EXPLICIT_PAGE_TYPE_REGEX)?.[1]?.trim(); + const leadingPageType = body.match(LEADING_PAGE_TYPE_REGEX)?.[1]?.trim(); + const pageType = normalizePageType(explicitPageType || leadingPageType); + const style = trimDecorations(body.match(EXPLICIT_STYLE_REGEX)?.[1] || ""); + const techStack = trimDecorations(body.match(EXPLICIT_TECH_STACK_REGEX)?.[1] || ""); + const prompt = stripPromptDecorations(body, explicitPageType || leadingPageType); + + return { + rawText: text, + trigger: normalizeTrigger(matched[1] || ""), + body, + prompt, + pageType, + style: style || undefined, + techStack: techStack || undefined, + }; +} diff --git a/src/components/agent/chat/workspace/ServiceSkillExecutionCard.test.tsx b/src/components/agent/chat/workspace/ServiceSkillExecutionCard.test.tsx index ff96e826e..3de492bb7 100644 --- a/src/components/agent/chat/workspace/ServiceSkillExecutionCard.test.tsx +++ b/src/components/agent/chat/workspace/ServiceSkillExecutionCard.test.tsx @@ -103,12 +103,27 @@ describe("ServiceSkillExecutionCard", () => { content_id: "content-1", project_id: "project-1", title: "GitHub 仓库线索", + project_root_path: "/Users/coso/.proxycast/projects/project-1", + markdown_relative_path: + "exports/x-article-export/github-mcp/index.md", + images_relative_dir: + "exports/x-article-export/github-mcp/images", + image_count: 7, }, }, }, }); expect(container.textContent).toContain("已沉淀内容:GitHub 仓库线索"); + expect(container.textContent).toContain( + "项目目录:/Users/coso/.proxycast/projects/project-1", + ); + expect(container.textContent).toContain( + "Markdown 文件:exports/x-article-export/github-mcp/index.md", + ); + expect(container.textContent).toContain( + "图片资源:7 张 · exports/x-article-export/github-mcp/images", + ); expect( container.querySelector( '[data-testid="service-skill-execution-open-browser-runtime"]', diff --git a/src/components/agent/chat/workspace/ServiceSkillExecutionCard.tsx b/src/components/agent/chat/workspace/ServiceSkillExecutionCard.tsx index ccaeb69fd..4b96b101a 100644 --- a/src/components/agent/chat/workspace/ServiceSkillExecutionCard.tsx +++ b/src/components/agent/chat/workspace/ServiceSkillExecutionCard.tsx @@ -30,6 +30,13 @@ export function ServiceSkillExecutionCard({ } const resultTitle = state.result?.saved_content?.title?.trim(); + const projectRootPath = + state.result?.saved_content?.project_root_path?.trim() || ""; + const markdownRelativePath = + state.result?.saved_content?.markdown_relative_path?.trim() || ""; + const imagesRelativeDir = + state.result?.saved_content?.images_relative_dir?.trim() || ""; + const imageCount = state.result?.saved_content?.image_count; return (
) : null} + {projectRootPath ? ( +

+ 项目目录:{projectRootPath} +

+ ) : null} + {markdownRelativePath ? ( +

+ Markdown 文件:{markdownRelativePath} +

+ ) : null} + {typeof imageCount === "number" ? ( +

+ 图片资源:{imageCount} 张 + {imagesRelativeDir ? ` · ${imagesRelativeDir}` : ""} +

+ ) : null} {state.sourceUrl ? (

来源页面:{state.sourceUrl} diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index 83a3b0a8f..9c1473e00 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -107,6 +107,7 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainSceneProps { navbarVisible: boolean; isRunning: boolean; navbarChrome: ComponentProps["chrome"]; + navbarContextVariant?: "default" | "task-center"; onToggleHistory: NonNullable["onToggleHistory"]>; showHistoryToggle: boolean; onBackToProjectManagement?: ComponentProps< @@ -195,6 +196,7 @@ export function WorkspaceConversationScene({ navbarVisible, isRunning, navbarChrome, + navbarContextVariant = "default", onToggleHistory, showHistoryToggle, onBackToProjectManagement, @@ -304,6 +306,7 @@ export function WorkspaceConversationScene({ visible: navbarVisible, isRunning, chrome: navbarChrome, + navbarContextVariant, onToggleHistory, showHistoryToggle, onBackToProjectManagement, diff --git a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx index 31ad6f22d..20f1d8200 100644 --- a/src/components/agent/chat/workspace/WorkspaceShellScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceShellScene.tsx @@ -9,6 +9,7 @@ type WorkspacePageShellProps = Omit< >; interface WorkspaceShellSceneProps extends WorkspacePageShellProps { + sidebarContextVariant?: ComponentProps["contextVariant"]; currentTopicId: ComponentProps["currentTopicId"]; topics: ComponentProps["topics"]; onNewChat: ComponentProps["onNewChat"]; @@ -44,6 +45,7 @@ export function WorkspaceShellScene({ showGeneralWorkbenchLeftExpandButton, onExpandGeneralWorkbenchSidebar, mainAreaNode, + sidebarContextVariant = "default", currentTopicId, topics, onNewChat, @@ -64,6 +66,7 @@ export function WorkspaceShellScene({ const chatSidebarProps = !isThemeWorkbench && showChatPanel && showSidebar ? buildWorkspaceChatSidebarProps({ + contextVariant: sidebarContextVariant, onNewChat, topics, currentTopicId, diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts index 6450c1b5c..898de8635 100644 --- a/src/components/agent/chat/workspace/chatSurfaceProps.ts +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -228,6 +228,7 @@ interface BuildWorkspaceNavbarPropsParams { visible: boolean; isRunning: boolean; chrome: ComponentProps["chrome"]; + navbarContextVariant?: "default" | "task-center"; onToggleHistory: NonNullable["onToggleHistory"]>; showHistoryToggle: boolean; onBackToProjectManagement?: ComponentProps< @@ -263,6 +264,7 @@ export function buildWorkspaceNavbarProps({ visible, isRunning, chrome, + navbarContextVariant = "default", onToggleHistory, showHistoryToggle, onBackToProjectManagement, @@ -289,9 +291,18 @@ export function buildWorkspaceNavbarProps({ return null; } + const entryContextCopy = + navbarContextVariant === "task-center" + ? { + entryContextLabel: "任务中心", + entryContextHint: "回到进行中的任务、旧历史和最近工作现场。", + } + : {}; + return { isRunning, chrome, + ...entryContextCopy, onToggleHistory, showHistoryToggle, onToggleFullscreen: () => undefined, diff --git a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts index 7f44201de..6851a0302 100644 --- a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts +++ b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.test.ts @@ -9,7 +9,10 @@ import { const mockGetSkillCatalog = vi.hoisted(() => vi.fn()); const mockListSkillCatalogSceneEntries = vi.hoisted(() => vi.fn()); +const mockListServiceSkills = vi.hoisted(() => vi.fn()); +const mockGetOrCreateDefaultProject = vi.hoisted(() => vi.fn()); const mockResolveOemCloudRuntimeContext = vi.hoisted(() => vi.fn()); +const mockSiteGetAdapterLaunchReadiness = vi.hoisted(() => vi.fn()); vi.mock("@/lib/api/skillCatalog", () => ({ getSkillCatalog: () => mockGetSkillCatalog(), @@ -17,10 +20,23 @@ vi.mock("@/lib/api/skillCatalog", () => ({ mockListSkillCatalogSceneEntries(catalog), })); +vi.mock("@/lib/api/serviceSkills", () => ({ + listServiceSkills: () => mockListServiceSkills(), +})); + +vi.mock("@/lib/api/project", () => ({ + getOrCreateDefaultProject: () => mockGetOrCreateDefaultProject(), +})); + vi.mock("@/lib/api/oemCloudRuntime", () => ({ resolveOemCloudRuntimeContext: () => mockResolveOemCloudRuntimeContext(), })); +vi.mock("@/lib/webview-api", () => ({ + siteGetAdapterLaunchReadiness: (...args: unknown[]) => + mockSiteGetAdapterLaunchReadiness(...args), +})); + function createCloudSceneSkill(): ServiceSkillHomeItem { return { id: "cloud-video-dubbing", @@ -46,20 +62,85 @@ function createCloudSceneSkill(): ServiceSkillHomeItem { }; } +function createXArticleExportSkill(): ServiceSkillHomeItem { + return { + id: "x-article-export", + skillKey: "x-article-export", + title: "X 文章转存", + summary: "复用 X 登录态把长文导出成 Markdown 和图片目录。", + category: "站点采集", + outputHint: "Markdown 正文 + 图片目录", + source: "local_custom", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + version: "seed-v1", + badge: "本地技能", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald", + runnerDescription: "直接复用浏览器登录态执行。", + actionLabel: "启动采集", + automationStatus: null, + slotSchema: [ + { + key: "article_url", + label: "X 文章链接", + type: "url", + required: true, + placeholder: "https://x.com/<账号>/article/<文章ID>", + }, + ], + readinessRequirements: { + requiresBrowser: true, + requiresProject: true, + }, + siteCapabilityBinding: { + adapterName: "x/article-export", + autoRun: true, + requireAttachedSession: true, + saveMode: "project_resource", + slotArgMap: { + article_url: "url", + }, + }, + sceneBinding: { + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + aliases: ["x文章转存", "x转存"], + }, + }; +} + describe("serviceSkillSceneLaunch", () => { beforeEach(() => { vi.clearAllMocks(); mockGetSkillCatalog.mockResolvedValue({ entries: [] }); mockListSkillCatalogSceneEntries.mockReturnValue([]); + mockListServiceSkills.mockResolvedValue([]); + mockGetOrCreateDefaultProject.mockResolvedValue({ + id: "project-default", + }); mockResolveOemCloudRuntimeContext.mockReturnValue(null); + mockSiteGetAdapterLaunchReadiness.mockResolvedValue(null); }); it("应解析 slash scene 命令", () => { - expect(parseRuntimeSceneCommand("/campaign-launch 帮我做一版新品活动方案")) - .toEqual({ - sceneKey: "campaign-launch", - userInput: "帮我做一版新品活动方案", - }); + expect( + parseRuntimeSceneCommand("/campaign-launch 帮我做一版新品活动方案"), + ).toEqual({ + sceneKey: "campaign-launch", + userInput: "帮我做一版新品活动方案", + }); + expect( + parseRuntimeSceneCommand("/x文章转存 https://x.com/a/article/1"), + ).toEqual({ + sceneKey: "x文章转存", + userInput: "https://x.com/a/article/1", + }); expect(parseRuntimeSceneCommand("campaign-launch")).toBeNull(); }); @@ -70,8 +151,12 @@ describe("serviceSkillSceneLaunch", () => { aliases: ["campaign", "launch"], }; - expect(matchesRuntimeSceneEntry(entry as never, "campaign-launch")).toBe(true); - expect(matchesRuntimeSceneEntry(entry as never, "/campaign-launch")).toBe(true); + expect(matchesRuntimeSceneEntry(entry as never, "campaign-launch")).toBe( + true, + ); + expect(matchesRuntimeSceneEntry(entry as never, "/campaign-launch")).toBe( + true, + ); expect(matchesRuntimeSceneEntry(entry as never, "campaign")).toBe(true); expect(matchesRuntimeSceneEntry(entry as never, "other")).toBe(false); }); @@ -137,4 +222,152 @@ describe("serviceSkillSceneLaunch", () => { }, }); }); + + it("site skill scene 应根据 skill 声明注入 service_skill_launch metadata", async () => { + mockGetSkillCatalog.mockResolvedValueOnce({ entries: [] }); + mockListSkillCatalogSceneEntries.mockReturnValueOnce([ + { + id: "scene:x-article-export", + kind: "scene", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + aliases: ["x文章转存", "x转存"], + linkedSkillId: "x-article-export", + executionKind: "site_adapter", + }, + ]); + mockSiteGetAdapterLaunchReadiness.mockResolvedValueOnce({ + status: "ready", + domain: "x.com", + profile_key: "existing-session-x", + target_id: "target-x-article", + message: "已复用当前附着的 X 会话", + report_hint: "将自动打开目标文章页。", + }); + + const request = await resolveRuntimeSceneLaunchRequest({ + rawText: + "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + serviceSkills: [createXArticleExportSkill()], + projectId: "project-1", + contentId: "content-1", + }); + + expect(request).not.toBeNull(); + const requestMetadata = buildServiceSceneLaunchRequestMetadata( + { + harness: { + chat_mode: "general", + }, + }, + request!.requestContext, + ); + + expect(requestMetadata).toMatchObject({ + harness: { + chat_mode: "general", + browser_requirement: "required", + browser_assist: { + enabled: true, + profile_key: "existing-session-x", + preferred_backend: "lime_extension_bridge", + auto_launch: false, + stream_mode: "both", + }, + service_skill_launch: { + kind: "site_adapter", + skill_id: "x-article-export", + adapter_name: "x/article-export", + save_mode: "project_resource", + project_id: "project-1", + content_id: undefined, + args: { + url: "https://x.com/GoogleCloudTech/article/2033953579824758855", + }, + launch_readiness: { + status: "ready", + profile_key: "existing-session-x", + target_id: "target-x-article", + domain: "x.com", + message: "已复用当前附着的 X 会话", + report_hint: "将自动打开目标文章页。", + }, + }, + }, + }); + }); + + it("site skill scene 缺少当前项目时应回退默认项目", async () => { + mockGetSkillCatalog.mockResolvedValueOnce({ entries: [] }); + mockListSkillCatalogSceneEntries.mockReturnValueOnce([ + { + id: "scene:x-article-export", + kind: "scene", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + aliases: ["x文章转存", "x转存"], + linkedSkillId: "x-article-export", + executionKind: "site_adapter", + }, + ]); + + const request = await resolveRuntimeSceneLaunchRequest({ + rawText: + "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + serviceSkills: [createXArticleExportSkill()], + projectId: null, + contentId: null, + }); + + expect(mockGetOrCreateDefaultProject).toHaveBeenCalledTimes(1); + expect(request).not.toBeNull(); + const requestMetadata = buildServiceSceneLaunchRequestMetadata( + undefined, + request!.requestContext, + ); + + expect(requestMetadata).toMatchObject({ + harness: { + service_skill_launch: { + project_id: "project-default", + adapter_name: "x/article-export", + }, + }, + }); + }); + + it("当前首页列表未暴露 scene 绑定 skill 时,应回退完整技能目录解析", async () => { + mockGetSkillCatalog.mockResolvedValueOnce({ entries: [] }); + mockListSkillCatalogSceneEntries.mockReturnValueOnce([ + { + id: "scene:x-article-export", + kind: "scene", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + linkedSkillId: "x-article-export", + executionKind: "site_adapter", + }, + ]); + mockListServiceSkills.mockResolvedValueOnce([createXArticleExportSkill()]); + + const request = await resolveRuntimeSceneLaunchRequest({ + rawText: + "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + serviceSkills: [], + projectId: "project-1", + }); + + expect(request?.skill.id).toBe("x-article-export"); + expect(request?.requestContext).toMatchObject({ + kind: "site_adapter", + adapterName: "x/article-export", + projectId: "project-1", + }); + }); }); diff --git a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts index 3e16de506..3a08cbecf 100644 --- a/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts +++ b/src/components/agent/chat/workspace/serviceSkillSceneLaunch.ts @@ -3,8 +3,20 @@ import { listSkillCatalogSceneEntries, type SkillCatalogSceneEntry, } from "@/lib/api/skillCatalog"; +import { + listServiceSkills, + type ServiceSkillItem, +} from "@/lib/api/serviceSkills"; +import { getOrCreateDefaultProject } from "@/lib/api/project"; import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; -import type { ServiceSkillHomeItem } from "../service-skills/types"; +import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api"; +import { + buildServiceSkillClawLaunchContext, + buildServiceSkillClawLaunchRequestMetadata, + isServiceSkillSiteCapabilityBound, + type ServiceSkillClawLaunchContext, +} from "../service-skills/siteCapabilityBinding"; +import type { ServiceSkillSlotValues } from "../service-skills/types"; function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -29,11 +41,18 @@ interface ParsedRuntimeSceneCommand { } interface ServiceSceneLaunchRequest { - skill: ServiceSkillHomeItem; + skill: ServiceSkillItem; sceneEntry: SkillCatalogSceneEntry; requestContext: Record; } +export class RuntimeSceneLaunchValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "RuntimeSceneLaunchValidationError"; + } +} + function normalizeCommandToken(value?: string | null): string { if (typeof value !== "string") { return ""; @@ -45,7 +64,7 @@ function normalizeCommandToken(value?: string | null): string { export function parseRuntimeSceneCommand( rawText: string, ): ParsedRuntimeSceneCommand | null { - const sceneMatch = rawText.trim().match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/); + const sceneMatch = rawText.trim().match(/^\/([^\s]+)\s*([\s\S]*)$/); if (!sceneMatch) { return null; } @@ -80,9 +99,9 @@ export function matchesRuntimeSceneEntry( } function resolveRuntimeSceneSkill( - serviceSkills: ServiceSkillHomeItem[], + serviceSkills: ServiceSkillItem[], entry: SkillCatalogSceneEntry, -): ServiceSkillHomeItem | null { +): ServiceSkillItem | null { const normalizedSceneKey = normalizeCommandToken(entry.sceneKey); if (!normalizedSceneKey) { return null; @@ -100,11 +119,115 @@ function resolveRuntimeSceneSkill( ); } +async function resolveRuntimeSceneSkillWithCatalogFallback( + serviceSkills: ServiceSkillItem[], + entry: SkillCatalogSceneEntry, +): Promise { + const matchedSkill = resolveRuntimeSceneSkill(serviceSkills, entry); + if (matchedSkill) { + return matchedSkill; + } + + try { + return resolveRuntimeSceneSkill(await listServiceSkills(), entry); + } catch { + return null; + } +} + +function extractFirstUrl(value: string): string | undefined { + const match = value.match(/https?:\/\/[^\s<>"'))]+/i); + return normalizeOptionalText(match?.[0]); +} + +function resolveSiteSceneSlotValues(params: { + sceneEntry: SkillCatalogSceneEntry; + skill: ServiceSkillItem; + userInput: string; +}): ServiceSkillSlotValues { + const { sceneEntry, skill, userInput } = params; + const normalizedUserInput = userInput.trim(); + + const resolvedSlotValues: ServiceSkillSlotValues = {}; + const singleUrlSlot = + skill.slotSchema.filter((slot) => slot.type === "url").length === 1 + ? skill.slotSchema.find((slot) => slot.type === "url") + : undefined; + if (singleUrlSlot) { + const matchedUrl = extractFirstUrl(normalizedUserInput); + if (matchedUrl) { + resolvedSlotValues[singleUrlSlot.key] = matchedUrl; + } + } + + const unresolvedRequiredSlots = skill.slotSchema.filter( + (slot) => slot.required && !resolvedSlotValues[slot.key], + ); + if (unresolvedRequiredSlots.length === 1 && normalizedUserInput) { + const [slot] = unresolvedRequiredSlots; + if (slot && slot.type !== "url") { + resolvedSlotValues[slot.key] = normalizedUserInput; + } + } + + const missingRequiredSlots = skill.slotSchema.filter( + (slot) => slot.required && !resolvedSlotValues[slot.key], + ); + if (missingRequiredSlots.length > 0) { + const slotLabels = missingRequiredSlots + .map((slot) => slot.label.trim()) + .filter(Boolean) + .join("、"); + if (slotLabels) { + throw new RuntimeSceneLaunchValidationError( + `请在 ${sceneEntry.commandPrefix} 后补${slotLabels}。`, + ); + } + throw new RuntimeSceneLaunchValidationError( + `请在 ${sceneEntry.commandPrefix} 后补完整参数。`, + ); + } + + return resolvedSlotValues; +} + +async function resolveSiteSceneProjectId( + skill: ServiceSkillItem, + projectId?: string | null, +): Promise { + const normalizedProjectId = normalizeOptionalText(projectId); + if (normalizedProjectId) { + return normalizedProjectId; + } + + const requiresProject = + skill.readinessRequirements?.requiresProject || + (skill.siteCapabilityBinding?.saveMode ?? "project_resource") === + "project_resource"; + if (!requiresProject) { + return undefined; + } + + try { + const defaultProject = await getOrCreateDefaultProject(); + const defaultProjectId = normalizeOptionalText(defaultProject?.id); + if (defaultProjectId) { + return defaultProjectId; + } + } catch { + // 统一在下方抛出稳定提示。 + } + + throw new RuntimeSceneLaunchValidationError( + "当前场景需要项目工作区,但默认项目准备失败,请先选择项目后再重试。", + ); +} + function buildServiceSceneLaunchRequestContext(params: { rawText: string; parsedCommand: ParsedRuntimeSceneCommand; sceneEntry: SkillCatalogSceneEntry; - skill: ServiceSkillHomeItem; + skill: ServiceSkillItem; projectId?: string | null; contentId?: string | null; }): Record { @@ -147,10 +270,82 @@ function buildServiceSceneLaunchRequestContext(params: { }; } +function buildSiteSceneLaunchRequestContext(params: { + parsedCommand: ParsedRuntimeSceneCommand; + sceneEntry: SkillCatalogSceneEntry; + skill: ServiceSkillItem; + projectId?: string; + contentId?: string | null; + launchReadiness?: Awaited< + ReturnType + > | null; +}): ServiceSkillClawLaunchContext { + const { + parsedCommand, + sceneEntry, + skill, + projectId, + contentId, + launchReadiness, + } = params; + + if (!isServiceSkillSiteCapabilityBound(skill)) { + throw new RuntimeSceneLaunchValidationError( + "当前场景没有绑定站点执行能力。", + ); + } + + const slotValues = resolveSiteSceneSlotValues({ + sceneEntry, + skill, + userInput: parsedCommand.userInput, + }); + const saveMode = skill.siteCapabilityBinding.saveMode ?? "project_resource"; + + return buildServiceSkillClawLaunchContext(skill, slotValues, { + projectId, + contentId: saveMode === "current_content" ? contentId : undefined, + launchReadiness, + }); +} + +async function resolveSiteSceneLaunchReadiness( + skill: ServiceSkillItem, +): Promise> | null> { + if (!isServiceSkillSiteCapabilityBound(skill)) { + return null; + } + + try { + return await siteGetAdapterLaunchReadiness({ + adapter_name: skill.siteCapabilityBinding.adapterName, + }); + } catch { + return null; + } +} + export function buildServiceSceneLaunchRequestMetadata( existingMetadata: Record | undefined, requestContext: Record, ): Record { + if (requestContext.kind === "site_adapter") { + const existingHarness = asRecord(existingMetadata?.harness); + const siteMetadata = buildServiceSkillClawLaunchRequestMetadata( + requestContext as ServiceSkillClawLaunchContext, + ); + const siteHarness = asRecord(siteMetadata.harness); + + return { + ...(existingMetadata || {}), + ...siteMetadata, + harness: { + ...(existingHarness || {}), + ...(siteHarness || {}), + }, + }; + } + const serviceSceneRun = asRecord(requestContext.service_scene_run); const existingHarness = asRecord(existingMetadata?.harness); @@ -177,7 +372,7 @@ export function buildServiceSceneLaunchRequestMetadata( export async function resolveRuntimeSceneLaunchRequest(params: { rawText: string; - serviceSkills: ServiceSkillHomeItem[]; + serviceSkills: ServiceSkillItem[]; projectId?: string | null; contentId?: string | null; }): Promise { @@ -194,21 +389,39 @@ export async function resolveRuntimeSceneLaunchRequest(params: { return null; } - const matchedSkill = resolveRuntimeSceneSkill(params.serviceSkills, sceneEntry); + const matchedSkill = await resolveRuntimeSceneSkillWithCatalogFallback( + params.serviceSkills, + sceneEntry, + ); if (!matchedSkill) { return null; } + const requestContext = + matchedSkill.defaultExecutorBinding === "browser_assist" + ? buildSiteSceneLaunchRequestContext({ + parsedCommand: parsedSceneCommand, + sceneEntry, + skill: matchedSkill, + projectId: await resolveSiteSceneProjectId( + matchedSkill, + params.projectId, + ), + contentId: params.contentId, + launchReadiness: await resolveSiteSceneLaunchReadiness(matchedSkill), + }) + : buildServiceSceneLaunchRequestContext({ + rawText: params.rawText, + parsedCommand: parsedSceneCommand, + sceneEntry, + skill: matchedSkill, + projectId: params.projectId, + contentId: params.contentId, + }); + return { skill: matchedSkill, sceneEntry, - requestContext: buildServiceSceneLaunchRequestContext({ - rawText: params.rawText, - parsedCommand: parsedSceneCommand, - sceneEntry, - skill: matchedSkill, - projectId: params.projectId, - contentId: params.contentId, - }), + requestContext, }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts index 6ccd4959f..4be295b7f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts @@ -11,7 +11,10 @@ import type { LayoutMode, ThemeType } from "@/lib/workspace/workbenchContract"; import { readFilePreview } from "@/lib/api/fileBrowser"; import type { SessionFile } from "@/lib/api/session-files"; import type { Artifact } from "@/lib/artifact/types"; -import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; +import { + areArtifactProtocolPathsEquivalent, + resolveArtifactProtocolFilePath, +} from "@/lib/artifact-protocol"; import type { TaskFile } from "../components/TaskFiles"; import type { HarnessFilePreviewResult } from "../components/HarnessStatusPanel"; import { useArtifactAutoPreviewSync } from "../hooks/useArtifactAutoPreviewSync"; @@ -280,7 +283,7 @@ export function useWorkspaceArtifactPreviewActions({ const matchingArtifact = artifacts.find((artifact) => { const artifactPath = resolveArtifactProtocolFilePath(artifact); return ( - artifactPath === fileName || + areArtifactProtocolPathsEquivalent(artifactPath, fileName) || artifact.title === extractFileNameFromPath(fileName) || (content.trim().length > 0 && artifact.content === content) ); diff --git a/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts index af9bf8ebb..ebdb1d43c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceBrowserAssistRuntime.ts @@ -570,11 +570,18 @@ export function useWorkspaceBrowserAssistRuntime({ return; } + const hasMarkdownBundleOutput = Boolean( + result.saved_content?.markdown_relative_path?.trim(), + ); const successMessage = result.saved_content ? result.saved_by === "explicit_content" || result.saved_by === "context_content" - ? `站点技能已完成,结果已写回当前主稿` - : `站点技能已完成,结果已保存到项目资源` + ? hasMarkdownBundleOutput + ? "站点技能已完成,Markdown 已写回当前主稿并同步保存图片资源" + : "站点技能已完成,结果已写回当前主稿" + : hasMarkdownBundleOutput + ? "站点技能已完成,Markdown 与图片已保存到项目资源" + : "站点技能已完成,结果已保存到项目资源" : `站点技能已完成`; setSiteSkillExecutionState({ diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx index e40bef65d..905aa0dfe 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx @@ -41,11 +41,13 @@ function renderHook(props?: Partial) { showTeamWorkspaceBoard: false, hasCurrentCanvasArtifact: false, currentCanvasArtifactType: null, + hasBrowserAssistArtifact: false, currentImageWorkbenchActive: false, onHasMessagesChange: vi.fn(), dismissActiveTeamWorkbenchAutoOpen: vi.fn(), suppressGeneralCanvasArtifactAutoOpen: vi.fn(), suppressBrowserAssistCanvasAutoOpen: vi.fn(), + clearBrowserAssistCanvasArtifact: vi.fn(), setShowSidebar: vi.fn(), setLayoutMode: vi.fn(), setGeneralCanvasState: vi.fn(), @@ -253,4 +255,53 @@ describe("useWorkspaceCanvasLayoutRuntime", () => { expect(setGeneralCanvasState).not.toHaveBeenCalled(); expect(setCanvasState).not.toHaveBeenCalled(); }); + + it("关闭通用画布时应一并移除残留的浏览器协助 artifact", async () => { + const dismissActiveTeamWorkbenchAutoOpen = vi.fn(); + const suppressGeneralCanvasArtifactAutoOpen = vi.fn(); + const suppressBrowserAssistCanvasAutoOpen = vi.fn(); + const clearBrowserAssistCanvasArtifact = vi.fn(); + const setLayoutMode = vi.fn(); + const setGeneralCanvasState = vi.fn(); + const { render, getValue } = renderHook({ + activeTheme: "general", + hasBrowserAssistArtifact: true, + dismissActiveTeamWorkbenchAutoOpen, + suppressGeneralCanvasArtifactAutoOpen, + suppressBrowserAssistCanvasAutoOpen, + clearBrowserAssistCanvasArtifact, + setLayoutMode, + setGeneralCanvasState, + }); + + await render(); + + act(() => { + getValue().handleCloseCanvas(); + }); + + expect(dismissActiveTeamWorkbenchAutoOpen).toHaveBeenCalledTimes(1); + expect(suppressGeneralCanvasArtifactAutoOpen).toHaveBeenCalledTimes(1); + expect(suppressBrowserAssistCanvasAutoOpen).toHaveBeenCalledTimes(1); + expect(clearBrowserAssistCanvasArtifact).toHaveBeenCalledTimes(1); + expect(setLayoutMode).toHaveBeenCalledWith("chat"); + + const updater = setGeneralCanvasState.mock.calls.at(-1)?.[0] as + | ((previous: GeneralCanvasState) => GeneralCanvasState) + | undefined; + expect(typeof updater).toBe("function"); + expect( + updater?.({ + isOpen: true, + contentType: "markdown", + content: "浏览器残留", + isEditing: false, + }), + ).toEqual({ + isOpen: false, + contentType: "markdown", + content: "浏览器残留", + isEditing: false, + }); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts index 54d4b5057..fe35fb1c0 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts @@ -40,11 +40,13 @@ interface UseWorkspaceCanvasLayoutRuntimeParams { showTeamWorkspaceBoard: boolean; hasCurrentCanvasArtifact: boolean; currentCanvasArtifactType?: string | null; + hasBrowserAssistArtifact: boolean; currentImageWorkbenchActive: boolean; onHasMessagesChange?: (hasMessages: boolean) => void; dismissActiveTeamWorkbenchAutoOpen: () => void; suppressGeneralCanvasArtifactAutoOpen: () => void; suppressBrowserAssistCanvasAutoOpen: () => void; + clearBrowserAssistCanvasArtifact: () => void; setShowSidebar: Dispatch>; setLayoutMode: Dispatch>; setGeneralCanvasState: Dispatch>; @@ -74,11 +76,13 @@ export function useWorkspaceCanvasLayoutRuntime({ showTeamWorkspaceBoard, hasCurrentCanvasArtifact, currentCanvasArtifactType, + hasBrowserAssistArtifact, currentImageWorkbenchActive, onHasMessagesChange, dismissActiveTeamWorkbenchAutoOpen, suppressGeneralCanvasArtifactAutoOpen, suppressBrowserAssistCanvasAutoOpen, + clearBrowserAssistCanvasArtifact, setShowSidebar, setLayoutMode, setGeneralCanvasState, @@ -404,8 +408,12 @@ export function useWorkspaceCanvasLayoutRuntime({ if (activeTheme === "general") { dismissActiveTeamWorkbenchAutoOpen(); suppressGeneralCanvasArtifactAutoOpen(); - if (currentCanvasArtifactType === "browser_assist") { + if ( + hasBrowserAssistArtifact || + currentCanvasArtifactType === "browser_assist" + ) { suppressBrowserAssistCanvasAutoOpen(); + clearBrowserAssistCanvasArtifact(); } } setLayoutMode("chat"); @@ -414,8 +422,10 @@ export function useWorkspaceCanvasLayoutRuntime({ } }, [ activeTheme, + clearBrowserAssistCanvasArtifact, currentCanvasArtifactType, dismissActiveTeamWorkbenchAutoOpen, + hasBrowserAssistArtifact, setGeneralCanvasState, setLayoutMode, suppressBrowserAssistCanvasAutoOpen, diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts index bdbc99f35..3d7eff241 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts @@ -192,4 +192,15 @@ describe("useWorkspaceConversationSceneRuntime", () => { presentationParams?.canvasWorkbenchLayout?.onLayoutModeChange, ).toBeUndefined(); }); + + it("任务中心场景应继续向页面层透传顶栏上下文变体", () => { + const params = createBaseParams({ + navbarContextVariant: "task-center", + }); + + useWorkspaceConversationSceneRuntime(params); + + const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; + expect(presentationParams?.scene.navbarContextVariant).toBe("task-center"); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index 8d5fa99d7..ba5467a12 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -28,6 +28,8 @@ type ConversationScenePresentationParams = Parameters< >[0]; interface UseWorkspaceConversationSceneRuntimeParams { + messageListEmptyStateVariant?: "default" | "task-center"; + navbarContextVariant?: "default" | "task-center"; navigationActions: NavigationActions; inputbarScene: InputbarScene; canvasScene: CanvasScene; @@ -154,6 +156,8 @@ interface UseWorkspaceConversationSceneRuntimeParams { } export function useWorkspaceConversationSceneRuntime({ + messageListEmptyStateVariant = "default", + navbarContextVariant = "default", navigationActions, inputbarScene, canvasScene, @@ -360,6 +364,7 @@ export function useWorkspaceConversationSceneRuntime({ navbarVisible: shellChromeRuntime.shouldRenderTopBar, isRunning: Boolean(isSending), navbarChrome: topBarChrome, + navbarContextVariant, onToggleHistory: handleToggleSidebar, showHistoryToggle: !hideHistoryToggle && showChatPanel, onBackToProjectManagement, @@ -408,6 +413,7 @@ export function useWorkspaceConversationSceneRuntime({ }, messageList: { messages: displayMessages, + emptyStateVariant: messageListEmptyStateVariant, turns, threadItems: effectiveThreadItems, currentTurnId, diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx index 8edc7e3b9..bf25ac647 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationShellSceneRuntime.tsx @@ -54,6 +54,7 @@ interface UseWorkspaceConversationShellSceneRuntimeParams currentImageWorkbenchState: { active: boolean }; project: { rootPath?: string | null } | null; projectId: string | null | undefined; + sidebarContextVariant?: "default" | "task-center"; generalCanvasState: { content: string }; projectMemory: { characters?: ConversationSceneRuntimeParams["projectCharacters"]; @@ -81,6 +82,7 @@ export function useWorkspaceConversationShellSceneRuntime({ currentImageWorkbenchState, project, projectId, + sidebarContextVariant = "default", projectMemory, handleSend, showSidebar, @@ -131,6 +133,7 @@ export function useWorkspaceConversationShellSceneRuntime({ isThemeWorkbench: conversationScene.isThemeWorkbench, showChatPanel: conversationScene.showChatPanel, showSidebar, + sidebarContextVariant, generalWorkbenchShellRuntime, conversationSceneRuntime, sessionId: conversationScene.sessionId, diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx index 733eb28be..453f79aad 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx @@ -118,7 +118,10 @@ function createArtifactOutput( function renderHook( props?: Partial, - options?: { initialMessages?: Message[] }, + options?: { + initialMessages?: Message[]; + initialImageWorkbenchState?: SessionImageWorkbenchState; + }, ) { const container = document.createElement("div"); document.body.appendChild(container); @@ -134,25 +137,25 @@ function renderHook( projectId: DEFAULT_PROJECT_ID, contentId: DEFAULT_CONTENT_ID, projectRootPath: DEFAULT_PROJECT_ROOT_PATH, + currentImageWorkbenchState: createInitialSessionImageWorkbenchState(), canvasState: null, }; function Probe(currentProps: RuntimeHarnessProps) { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState( + () => options?.initialMessages || [], + ); const [canvasState, setCanvasState] = useState(currentProps.canvasState); const [imageWorkbenchState, setImageWorkbenchState] = useState( - createInitialSessionImageWorkbenchState(), + options?.initialImageWorkbenchState || + createInitialSessionImageWorkbenchState(), ); useEffect(() => { setCanvasState(currentProps.canvasState); }, [currentProps.canvasState]); - useEffect(() => { - setMessages(options?.initialMessages || []); - }, []); - latestValue = { messages, canvasState, @@ -161,6 +164,8 @@ function renderHook( useWorkspaceImageTaskPreviewRuntime({ ...currentProps, + messages, + currentImageWorkbenchState: imageWorkbenchState, canvasState, setCanvasState, setChatMessages: setMessages, @@ -330,6 +335,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { status: "running", projectId: "project-image-1", contentId: "content-image-1", + statusMessage: "任务已提交,正在排队处理。", }), }), ]); @@ -403,7 +409,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { ]); await act(async () => { - await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(1500); await Promise.resolve(); }); @@ -539,6 +545,107 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { ]); }); + it("task file 仍是等待队列时,应立即优先采用媒体任务接口返回的完整结果", async () => { + let listener: CreationTaskListener | null = null; + vi.mocked(safeListen).mockImplementationOnce(async (_event, handler) => { + listener = handler; + return vi.fn(); + }); + + const taskId = "task-image-prefer-artifact-api-1"; + const taskPath = `/workspace/project-image-1/.lime/tasks/image_generate/${taskId}.json`; + vi.mocked(readFilePreview).mockResolvedValueOnce( + createFilePreviewResult( + taskPath, + withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "queued", + normalized_status: "queued", + created_at: "2026-04-04T10:30:00Z", + payload: { + prompt: "[img:珠江夜景主视觉]", + count: 1, + size: "1024x1024", + }, + progress: { + phase: "queued", + message: "任务已进入队列", + }, + }), + ), + ); + vi.mocked(getMediaTaskArtifact).mockResolvedValueOnce( + createArtifactOutput({ + task_id: taskId, + task_type: "image_generate", + current_attempt_id: "attempt-prefer-artifact-api-1", + record: withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "completed", + normalized_status: "succeeded", + created_at: "2026-04-04T10:30:01Z", + current_attempt_id: "attempt-prefer-artifact-api-1", + payload: { + prompt: "[img:珠江夜景主视觉]", + count: 1, + size: "1024x1024", + }, + result: { + images: [{ url: "https://example.com/prefer-artifact-api.png" }], + }, + attempts: [ + { + attempt_id: "attempt-prefer-artifact-api-1", + provider: "fal", + model: "flux-pro", + result_snapshot: { + images: [{ url: "https://example.com/prefer-artifact-api.png" }], + }, + }, + ], + }), + }), + ); + + const { render, getValue } = renderHook(); + await render(); + + await act(async () => { + listener?.({ + payload: withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "queued", + path: `.lime/tasks/image_generate/${taskId}.json`, + }), + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(readFilePreview).toHaveBeenCalledWith(taskPath, 256 * 1024); + expect(getMediaTaskArtifact).toHaveBeenCalledWith({ + projectRootPath: DEFAULT_PROJECT_ROOT_PATH, + taskRef: taskId, + }); + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: `image-workbench:${taskId}:assistant`, + imageWorkbenchPreview: expect.objectContaining({ + taskId, + status: "complete", + imageUrl: "https://example.com/prefer-artifact-api.png", + prompt: "珠江夜景主视觉", + }), + }), + ]); + }); + it("已存在同 taskId 的 skill 消息时,应把 task 预览合并回原消息而不是再插一条伪造消息", async () => { let listener: CreationTaskListener | null = null; vi.mocked(safeListen).mockImplementationOnce(async (event, handler) => { @@ -893,6 +1000,155 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { expect(readFilePreview).not.toHaveBeenCalled(); }); + it("历史消息已带 taskId 时,应直接按 taskId 走媒体任务接口恢复图片结果", async () => { + const taskId = "task-image-history-direct-1"; + vi.mocked(getMediaTaskArtifact).mockResolvedValueOnce( + createArtifactOutput({ + task_id: taskId, + task_type: "image_generate", + record: withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "completed", + normalized_status: "succeeded", + created_at: "2026-04-04T12:00:00Z", + payload: { + prompt: "[img:历史直恢广州春日主视觉]", + count: 1, + size: "1024x1024", + }, + result: { + images: [{ url: "https://example.com/history-direct-restore.png" }], + }, + }), + }), + ); + + const { render, getValue } = renderHook( + {}, + { + initialMessages: [ + { + id: `image-workbench:${taskId}:assistant`, + role: "assistant", + content: "图片任务已提交,正在同步任务状态。", + timestamp: new Date("2026-04-04T12:00:00Z"), + imageWorkbenchPreview: { + taskId, + prompt: "历史直恢广州春日主视觉", + status: "running", + phase: "queued", + }, + }, + ], + }, + ); + await render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getMediaTaskArtifact).toHaveBeenCalledWith({ + projectRootPath: DEFAULT_PROJECT_ROOT_PATH, + taskRef: taskId, + }); + expect(listMediaTaskArtifacts).not.toHaveBeenCalled(); + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: `image-workbench:${taskId}:assistant`, + imageWorkbenchPreview: expect.objectContaining({ + taskId, + status: "complete", + imageUrl: "https://example.com/history-direct-restore.png", + prompt: "历史直恢广州春日主视觉", + }), + }), + ]); + }); + + it("同一历史会话已缓存图片工作台结果时,应直接回填聊天卡而不是继续显示等待队列", async () => { + const taskId = "task-image-history-cached-1"; + const cachedState = { + ...createInitialSessionImageWorkbenchState(), + tasks: [ + { + sessionId: taskId, + id: taskId, + mode: "generate" as const, + status: "complete" as const, + prompt: "已缓存的广州主视觉", + rawText: "已缓存的广州主视觉", + expectedCount: 1, + outputIds: [`${taskId}:output:1`], + createdAt: Date.now(), + hookImageIds: [`${taskId}:hook:1`], + applyTarget: null, + }, + ], + outputs: [ + { + id: `${taskId}:output:1`, + taskId, + hookImageId: `${taskId}:hook:1`, + refId: `img-${taskId}`, + url: "https://example.com/history-cached.png", + prompt: "已缓存的广州主视觉", + createdAt: Date.now(), + size: "1024x1024", + parentOutputId: null, + resourceSaved: false, + applyTarget: null, + }, + ], + selectedOutputId: `${taskId}:output:1`, + }; + + const { render, getValue } = renderHook( + {}, + { + initialMessages: [ + { + id: `image-workbench:${taskId}:assistant`, + role: "assistant", + content: "图片任务已提交,正在同步任务状态。", + timestamp: new Date("2026-04-04T12:10:00Z"), + isThinking: true, + imageWorkbenchPreview: { + taskId, + prompt: "已缓存的广州主视觉", + status: "running", + phase: "queued", + }, + }, + ], + initialImageWorkbenchState: cachedState, + }, + ); + await render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getMediaTaskArtifact).not.toHaveBeenCalled(); + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: `image-workbench:${taskId}:assistant`, + isThinking: false, + imageWorkbenchPreview: expect.objectContaining({ + taskId, + status: "complete", + imageUrl: "https://example.com/history-cached.png", + size: "1024x1024", + }), + }), + ]); + }); + it("应在进入会话时从 task file 恢复最近的图片任务", async () => { vi.mocked(listDirectory) .mockResolvedValueOnce( diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts index ccae6eac4..c1afefa5a 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts @@ -24,7 +24,7 @@ import { const IMAGE_TASK_EVENT_NAME = "lime://creation_task_submitted"; const IMAGE_TASK_FILE_PREVIEW_MAX_SIZE = 256 * 1024; -const IMAGE_TASK_POLL_INTERVAL_MS = 3000; +const IMAGE_TASK_POLL_INTERVAL_MS = 1500; const IMAGE_TASK_RESTORE_LOOKBACK_MS = 24 * 60 * 60 * 1000; const IMAGE_TASK_RESTORE_LIMIT = 8; const IMAGE_TASKS_ROOT_RELATIVE_PATH = ".lime/tasks"; @@ -80,6 +80,8 @@ interface UseWorkspaceImageTaskPreviewRuntimeParams { contentId?: string | null; projectRootPath?: string | null; restoreFromWorkspace?: boolean; + messages?: Message[]; + currentImageWorkbenchState?: SessionImageWorkbenchState; canvasState: CanvasStateUnion | null; setCanvasState: Dispatch>; setChatMessages: Dispatch>; @@ -107,6 +109,84 @@ interface RestoredImageTaskSnapshot extends LoadedImageTaskSnapshot { taskFamily: string; } +function shouldPreferLoadedImageTaskSnapshot( + current: LoadedImageTaskSnapshot | null, + candidate: LoadedImageTaskSnapshot | null, +): boolean { + if (!candidate) { + return false; + } + if (!current) { + return true; + } + + const currentIsTerminal = current.snapshot.terminal; + const candidateIsTerminal = candidate.snapshot.terminal; + if (currentIsTerminal !== candidateIsTerminal) { + return candidateIsTerminal; + } + + const currentOutputCount = current.snapshot.outputs.length; + const candidateOutputCount = candidate.snapshot.outputs.length; + if (currentOutputCount !== candidateOutputCount) { + return candidateOutputCount > currentOutputCount; + } + + return candidate.snapshot.updatedAt > current.snapshot.updatedAt; +} + +function collectSeedImageTaskIds(messages?: Message[]): string[] { + if (!messages || messages.length === 0) { + return []; + } + + const taskIds: string[] = []; + const seen = new Set(); + messages.forEach((message) => { + if (message.role !== "assistant") { + return; + } + + const taskId = message.imageWorkbenchPreview?.taskId?.trim(); + if (!taskId || seen.has(taskId)) { + return; + } + + seen.add(taskId); + taskIds.push(taskId); + }); + return taskIds; +} + +function isImageWorkbenchTaskSatisfiedByCache(params: { + imageWorkbenchState?: SessionImageWorkbenchState; + taskId: string; +}): boolean { + const imageWorkbenchState = params.imageWorkbenchState; + if (!imageWorkbenchState) { + return false; + } + + const task = imageWorkbenchState.tasks.find((item) => item.id === params.taskId); + if (!task) { + return false; + } + + const outputs = imageWorkbenchState.outputs.filter( + (output) => output.taskId === params.taskId, + ); + if (outputs.length > 0) { + return true; + } + + return ( + task.status === "cancelled" || + task.status === "error" || + task.status === "partial" || + task.status === "complete" + ); +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -1083,7 +1163,7 @@ function buildParsedImageTaskSnapshot(params: { readString([progressRecord], ["message"]) || (prompt ? `正在处理:${prompt}` - : "任务已提交到异步队列,结果会自动回填。"), + : "任务已提交,生成完成后会自动展示结果。"), checkpoints: ["创建任务文件", "轮询任务状态", "回填图片结果"], } : previewStatus === "cancelled" @@ -1202,7 +1282,7 @@ function buildPendingImageTaskSnapshot(params: { phase: resolvePendingProgressPhase(params.status), message: params.progressMessage || - "任务已提交到异步队列,正在同步任务状态。", + "任务已提交,正在排队处理。", }, created_at: new Date().toISOString(), }, @@ -1237,7 +1317,7 @@ function buildPendingImageTaskSnapshot(params: { phase: resolvePendingProgressPhase(params.status), statusMessage: params.progressMessage || - "任务已提交到异步队列,正在同步任务状态。", + "任务已提交,正在排队处理。", }, }, task: { @@ -1394,23 +1474,131 @@ function mergeImageTaskSnapshot( }; } +function resolvePreviewStatusFromWorkbenchTask( + status: ImageWorkbenchTask["status"], +): MessageImageWorkbenchPreview["status"] { + switch (status) { + case "complete": + return "complete"; + case "partial": + return "partial"; + case "cancelled": + return "cancelled"; + case "error": + return "failed"; + case "queued": + case "routing": + case "running": + default: + return "running"; + } +} + +function patchMessagesWithImageWorkbenchState(params: { + messages: Message[]; + imageWorkbenchState?: SessionImageWorkbenchState; +}): Message[] { + const imageWorkbenchState = params.imageWorkbenchState; + if (!imageWorkbenchState || params.messages.length === 0) { + return params.messages; + } + + const outputsByTaskId = new Map(); + imageWorkbenchState.outputs.forEach((output) => { + const current = outputsByTaskId.get(output.taskId) || []; + current.push(output); + outputsByTaskId.set(output.taskId, current); + }); + + let changed = false; + const nextMessages = params.messages.map((message) => { + const preview = message.imageWorkbenchPreview; + if (!preview?.taskId) { + return message; + } + + const task = imageWorkbenchState.tasks.find((item) => item.id === preview.taskId); + if (!task) { + return message; + } + + const outputs = outputsByTaskId.get(task.id) || []; + const preferredOutput = + task.outputIds + .map((outputId) => outputs.find((output) => output.id === outputId)) + .find((output): output is ImageWorkbenchOutput => Boolean(output)) || + outputs[0]; + const nextPreview: MessageImageWorkbenchPreview = { + ...preview, + prompt: preview.prompt || task.prompt, + mode: task.mode, + status: resolvePreviewStatusFromWorkbenchTask(task.status), + imageUrl: preferredOutput?.url || preview.imageUrl || null, + imageCount: outputs.length > 0 ? outputs.length : preview.imageCount, + sourceImageUrl: task.sourceImageUrl ?? preview.sourceImageUrl ?? null, + sourceImagePrompt: + task.sourceImagePrompt ?? preview.sourceImagePrompt ?? null, + sourceImageRef: task.sourceImageRef ?? preview.sourceImageRef ?? null, + sourceImageCount: task.sourceImageCount ?? preview.sourceImageCount, + size: preferredOutput?.size || preview.size, + retryable: + typeof preview.retryable === "boolean" + ? preview.retryable + : task.status === "error" + ? false + : preview.retryable, + }; + + const hasChanged = + nextPreview.status !== preview.status || + nextPreview.imageUrl !== preview.imageUrl || + nextPreview.imageCount !== preview.imageCount || + nextPreview.size !== preview.size || + nextPreview.sourceImageUrl !== preview.sourceImageUrl || + nextPreview.sourceImagePrompt !== preview.sourceImagePrompt || + nextPreview.sourceImageRef !== preview.sourceImageRef || + nextPreview.sourceImageCount !== preview.sourceImageCount || + nextPreview.mode !== preview.mode; + + if (!hasChanged) { + return message; + } + + changed = true; + return { + ...message, + isThinking: + nextPreview.status === "running" ? message.isThinking : false, + imageWorkbenchPreview: nextPreview, + }; + }); + + return changed ? nextMessages : params.messages; +} + export function useWorkspaceImageTaskPreviewRuntime({ sessionId, projectId, contentId, projectRootPath, restoreFromWorkspace = true, + messages, + currentImageWorkbenchState, canvasState, setCanvasState, setChatMessages, updateCurrentImageWorkbenchState, }: UseWorkspaceImageTaskPreviewRuntimeParams) { const trackedTasksRef = useRef>(new Map()); + const restoreSeedMessagesRef = useRef< + ((seedMessages?: Message[]) => void) | null + >(null); const runtimeContextRef = useRef({ sessionId, projectId, contentId, projectRootPath, + messages, canvasState, }); @@ -1419,9 +1607,24 @@ export function useWorkspaceImageTaskPreviewRuntime({ projectId, contentId, projectRootPath, + messages, + currentImageWorkbenchState, canvasState, }; + useEffect(() => { + setChatMessages((previous) => + patchMessagesWithImageWorkbenchState({ + messages: previous, + imageWorkbenchState: currentImageWorkbenchState, + }), + ); + }, [currentImageWorkbenchState, setChatMessages]); + + useEffect(() => { + restoreSeedMessagesRef.current?.(messages); + }, [messages, projectRootPath, sessionId]); + useEffect(() => { const trackedTasks = trackedTasksRef.current; trackedTasks.forEach((trackedTask) => { @@ -1433,6 +1636,7 @@ export function useWorkspaceImageTaskPreviewRuntime({ let cancelled = false; let unlisten: (() => void) | null = null; + const restoredSeedTaskIds = new Set(); const loadTaskSnapshotFromArtifactApi = async ( taskId: string, @@ -1491,6 +1695,27 @@ export function useWorkspaceImageTaskPreviewRuntime({ }); }; + const trackTaskForPolling = (params: { + taskId: string; + taskType?: string; + absolutePath?: string; + artifactPath?: string; + }) => { + const existing = trackedTasks.get(params.taskId); + if (existing?.timerId !== null) { + window.clearTimeout(existing.timerId); + } + trackedTasks.set(params.taskId, { + taskId: params.taskId, + taskType: params.taskType || existing?.taskType || "image_generate", + taskFamily: "image", + artifactPath: params.artifactPath || existing?.artifactPath || "", + absolutePath: params.absolutePath || existing?.absolutePath || "", + timerId: null, + polling: false, + }); + }; + const scheduleNextPoll = (taskId: string) => { const trackedTask = trackedTasks.get(taskId); if (!trackedTask || cancelled) { @@ -1549,8 +1774,21 @@ export function useWorkspaceImageTaskPreviewRuntime({ loadedSnapshot = null; } - if (!loadedSnapshot) { - loadedSnapshot = await loadTaskSnapshotFromArtifactApi(taskId); + const shouldProbeArtifactApi = + !loadedSnapshot || + !loadedSnapshot.snapshot.terminal || + loadedSnapshot.snapshot.outputs.length === 0; + + if (shouldProbeArtifactApi) { + const artifactSnapshot = await loadTaskSnapshotFromArtifactApi(taskId); + if ( + shouldPreferLoadedImageTaskSnapshot( + loadedSnapshot, + artifactSnapshot, + ) + ) { + loadedSnapshot = artifactSnapshot; + } } if (!loadedSnapshot) { scheduleNextPoll(taskId); @@ -1770,6 +2008,73 @@ export function useWorkspaceImageTaskPreviewRuntime({ } }; + const restoreTrackedTasksFromMessages = async ( + seedMessages?: Message[], + ): Promise => { + const taskIds = collectSeedImageTaskIds( + seedMessages || runtimeContextRef.current.messages, + ).filter((taskId) => !restoredSeedTaskIds.has(taskId)); + if (taskIds.length === 0 || cancelled) { + return false; + } + + taskIds.forEach((taskId) => { + restoredSeedTaskIds.add(taskId); + }); + + const unresolvedTaskIds = taskIds.filter( + (taskId) => + !isImageWorkbenchTaskSatisfiedByCache({ + imageWorkbenchState: runtimeContextRef.current.currentImageWorkbenchState, + taskId, + }), + ); + if (unresolvedTaskIds.length === 0) { + return true; + } + + const loadedSnapshots = await Promise.all( + unresolvedTaskIds.map(async (taskId) => ({ + taskId, + loaded: await loadTaskSnapshotFromArtifactApi(taskId), + })), + ); + if (cancelled) { + return false; + } + + const resolvedSnapshots = loadedSnapshots.filter( + ( + item, + ): item is { + taskId: string; + loaded: LoadedImageTaskSnapshot; + } => Boolean(item.loaded), + ); + if (resolvedSnapshots.length === 0) { + return false; + } + + resolvedSnapshots.forEach((item) => { + applyLoadedTaskSnapshot(item.loaded); + if (!item.loaded.snapshot.terminal) { + trackTaskForPolling({ + taskId: item.taskId, + }); + scheduleNextPoll(item.taskId); + } + }); + + return resolvedSnapshots.length === taskIds.length; + }; + + restoreSeedMessagesRef.current = (seedMessages) => { + if (!restoreFromWorkspace || cancelled) { + return; + } + void restoreTrackedTasksFromMessages(seedMessages); + }; + safeListen(IMAGE_TASK_EVENT_NAME, (event) => { if (cancelled) { return; @@ -1793,6 +2098,18 @@ export function useWorkspaceImageTaskPreviewRuntime({ } const artifactPath = payload.path?.trim() || payload.absolute_path?.trim() || ""; + const normalizedEventStatus = normalizeTaskStatus(payload.status); + const progressMessage = payload.reused_existing + ? "已复用现有图片任务,正在同步最新状态。" + : normalizedEventStatus === "succeeded" + ? "图片已生成完成,可在右侧查看结果。" + : normalizedEventStatus === "partial" + ? "图片已返回部分结果,可在右侧继续查看。" + : normalizedEventStatus === "failed" + ? "图片任务执行失败,可查看详情后重试。" + : normalizedEventStatus === "cancelled" + ? "图片任务已取消。" + : "任务已提交,正在排队处理。"; const pendingSnapshot = taskId && taskType && taskFamily === "image" ? buildPendingImageTaskSnapshot({ @@ -1816,9 +2133,7 @@ export function useWorkspaceImageTaskPreviewRuntime({ anchor_section_title: payload.anchor_section_title, anchor_text: payload.anchor_text, }, - progressMessage: payload.reused_existing - ? "已复用现有图片任务,正在同步最新状态。" - : "任务已提交到异步队列,正在同步任务状态。", + progressMessage, projectId: payload.project_id || runtimeContextRef.current.projectId, contentId: @@ -1905,11 +2220,16 @@ export function useWorkspaceImageTaskPreviewRuntime({ }); if (restoreFromWorkspace) { - void restoreTrackedTasksFromWorkspace(); + void restoreTrackedTasksFromMessages().then((restoredFromMessages) => { + if (!restoredFromMessages) { + void restoreTrackedTasksFromWorkspace(); + } + }); } return () => { cancelled = true; + restoreSeedMessagesRef.current = null; trackedTasks.forEach((trackedTask) => { if (trackedTask.timerId !== null) { window.clearTimeout(trackedTask.timerId); diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx index 72932c3d6..99c17e7ec 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx @@ -52,7 +52,6 @@ function renderHook(props?: Partial) { status: "cancelled", }), contentId: null, - createFreshSession: vi.fn().mockResolvedValue("session-1"), createImageGenerationTask: vi.fn().mockResolvedValue({ task_id: "task-image-1", task_type: "image_generate", @@ -116,7 +115,6 @@ function renderHook(props?: Partial) { setCanvasState: vi.fn(), setInput: vi.fn(), setLayoutMode: vi.fn(), - updateImageWorkbenchStateForSession: vi.fn(), updateCurrentImageWorkbenchState: vi.fn(), }; @@ -215,17 +213,13 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { expect(toast.error).not.toHaveBeenCalled(); }); - it("本地图片工作台 key 首次提交时应先创建真实会话,并把 Agent 请求绑定到该会话", async () => { - const createFreshSession = vi.fn().mockResolvedValue("session-image-1"); + it("本地图片工作台 key 首次提交时应保留统一发送主线,延后由发送边界绑定真实会话", async () => { const submitImageWorkbenchAgentCommand = vi.fn().mockResolvedValue(true); - const updateImageWorkbenchStateForSession = vi.fn(); const localImageWorkbenchSessionKey = "__local_image_workbench__:draft:image"; const { render, getValue } = renderHook({ - createFreshSession, submitImageWorkbenchAgentCommand, imageWorkbenchSessionKey: localImageWorkbenchSessionKey, - updateImageWorkbenchStateForSession, }); await render(); @@ -238,27 +232,15 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { }); }); - expect(createFreshSession).toHaveBeenCalledWith("配图:城市夜景主视觉"); expect(submitImageWorkbenchAgentCommand).toHaveBeenCalledWith( expect.objectContaining({ requestContext: expect.objectContaining({ image_task: expect.objectContaining({ - session_id: "session-image-1", + session_id: localImageWorkbenchSessionKey, }), }), }), ); - expect(updateImageWorkbenchStateForSession).toHaveBeenCalledWith( - "session-image-1", - expect.any(Function), - expect.objectContaining({ - fallbackState: expect.objectContaining({ - tasks: [], - outputs: [], - }), - removeSessionKeys: [localImageWorkbenchSessionKey], - }), - ); }); it("应把编辑命令解析为统一的 skillRequest 上下文", async () => { diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts index f32650dab..4273ca290 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts @@ -18,8 +18,6 @@ import { onImageWorkbenchTaskAction } from "@/lib/imageWorkbenchEvents"; import type { MessageImage } from "../types"; import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import { - buildImageWorkbenchSessionTitle, - isLocalImageWorkbenchSessionKey, resolveImageWorkbenchSkillRequest, } from "./imageSkillLaunch"; import { @@ -44,7 +42,6 @@ export interface SubmitImageWorkbenchAgentCommandParams { interface UseWorkspaceImageWorkbenchActionRuntimeParams { contentId?: string | null; - createFreshSession: (sessionName?: string) => Promise; createImageGenerationTask: ( request: CreateImageGenerationTaskArtifactRequest, ) => Promise; @@ -69,16 +66,6 @@ interface UseWorkspaceImageWorkbenchActionRuntimeParams { setCanvasState: Dispatch>; setInput: Dispatch>; setLayoutMode: Dispatch>; - updateImageWorkbenchStateForSession: ( - sessionKey: string, - updater: ( - current: SessionImageWorkbenchState, - ) => SessionImageWorkbenchState, - options?: { - fallbackState?: SessionImageWorkbenchState; - removeSessionKeys?: string[]; - }, - ) => void; updateCurrentImageWorkbenchState: ( updater: ( current: SessionImageWorkbenchState, @@ -271,7 +258,6 @@ function matchesTaskActionContext(params: { export function useWorkspaceImageWorkbenchActionRuntime({ cancelImageTask, contentId, - createFreshSession, createImageGenerationTask, getImageTask, currentImageWorkbenchState, @@ -286,43 +272,19 @@ export function useWorkspaceImageWorkbenchActionRuntime({ setCanvasState, setInput, setLayoutMode, - updateImageWorkbenchStateForSession, updateCurrentImageWorkbenchState, }: UseWorkspaceImageWorkbenchActionRuntimeParams) { const resolveImageWorkbenchSessionKey = useCallback( - async (params: { - preferredSessionKey?: string | null; - mode: NonNullable>["mode"]; - prompt: string; - }) => { + async (params: { preferredSessionKey?: string | null }) => { const normalizedPreferredSessionKey = params.preferredSessionKey?.trim() || null; - if ( - normalizedPreferredSessionKey && - !isLocalImageWorkbenchSessionKey(normalizedPreferredSessionKey) - ) { + if (normalizedPreferredSessionKey) { return normalizedPreferredSessionKey; } - const normalizedCurrentSessionKey = imageWorkbenchSessionKey.trim(); - if ( - normalizedCurrentSessionKey && - !isLocalImageWorkbenchSessionKey(normalizedCurrentSessionKey) - ) { - return normalizedCurrentSessionKey; - } - - const createdSessionId = await createFreshSession( - buildImageWorkbenchSessionTitle(params.mode, params.prompt), - ); - const normalizedCreatedSessionId = createdSessionId?.trim(); - if (!normalizedCreatedSessionId) { - throw new Error("图片会话创建失败,请稍后重试"); - } - - return normalizedCreatedSessionId; + return imageWorkbenchSessionKey.trim() || null; }, - [createFreshSession, imageWorkbenchSessionKey], + [imageWorkbenchSessionKey], ); const handleImageWorkbenchViewportChange = useCallback( @@ -724,10 +686,7 @@ export function useWorkspaceImageWorkbenchActionRuntime({ return false; } - const resolvedSessionKey = await resolveImageWorkbenchSessionKey({ - mode: params.parsedCommand.mode, - prompt: effectivePrompt, - }); + const resolvedSessionKey = await resolveImageWorkbenchSessionKey({}); const skillRequest = resolveImageWorkbenchSkillRequest({ rawText: params.rawText, @@ -751,17 +710,6 @@ export function useWorkspaceImageWorkbenchActionRuntime({ return false; } - if (resolvedSessionKey !== imageWorkbenchSessionKey) { - updateImageWorkbenchStateForSession( - resolvedSessionKey, - (current) => current, - { - fallbackState: currentImageWorkbenchState, - removeSessionKeys: [imageWorkbenchSessionKey], - }, - ); - } - return submitImageWorkbenchAgentCommand({ rawText: params.rawText, displayContent: params.rawText, @@ -780,7 +728,6 @@ export function useWorkspaceImageWorkbenchActionRuntime({ projectRootPath, resolveImageWorkbenchSessionKey, submitImageWorkbenchAgentCommand, - updateImageWorkbenchStateForSession, ], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index 31a16a39a..73defd433 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -3,28 +3,50 @@ import { createRoot } from "react-dom/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamMemorySnapshot } from "@/lib/teamMemorySync"; import type { ServiceSkillHomeItem } from "../service-skills/types"; +import { listMentionEntryUsage } from "../skill-selection/mentionEntryUsage"; +import { listServiceSkillUsage } from "../service-skills/storage"; import { useWorkspaceSendActions } from "./useWorkspaceSendActions"; import type { TeamWorkspaceRuntimeFormationState } from "../teamWorkspaceRuntime"; +import { listSlashEntryUsage } from "../skill-selection/slashEntryUsage"; const mockPreheatBrowserAssistInBackground = vi.hoisted(() => vi.fn()); const mockGetSkillCatalog = vi.hoisted(() => vi.fn()); const mockListSkillCatalogSceneEntries = vi.hoisted(() => vi.fn()); +const mockGetOrCreateDefaultProject = vi.hoisted(() => vi.fn()); const mockResolveOemCloudRuntimeContext = vi.hoisted(() => vi.fn()); vi.mock("../utils/browserAssistPreheat", () => ({ preheatBrowserAssistInBackground: mockPreheatBrowserAssistInBackground, })); -vi.mock("@/lib/api/skillCatalog", () => ({ - getSkillCatalog: () => mockGetSkillCatalog(), - listSkillCatalogSceneEntries: (catalog: unknown) => - mockListSkillCatalogSceneEntries(catalog), -})); +vi.mock("@/lib/api/skillCatalog", async () => { + const actual = await vi.importActual( + "@/lib/api/skillCatalog", + ); + + return { + ...actual, + getSkillCatalog: () => mockGetSkillCatalog(), + listSkillCatalogSceneEntries: (catalog: unknown) => + mockListSkillCatalogSceneEntries(catalog), + }; +}); vi.mock("@/lib/api/oemCloudRuntime", () => ({ resolveOemCloudRuntimeContext: () => mockResolveOemCloudRuntimeContext(), })); +vi.mock("@/lib/api/project", async () => { + const actual = await vi.importActual( + "@/lib/api/project", + ); + + return { + ...actual, + getOrCreateDefaultProject: () => mockGetOrCreateDefaultProject(), + }; +}); + type HookProps = Parameters[0]; interface HookHarness { @@ -138,6 +160,86 @@ function createCloudSceneSkill(): ServiceSkillHomeItem { }; } +function createXArticleExportSkill(): ServiceSkillHomeItem { + return { + id: "x-article-export", + skillKey: "x-article-export", + title: "X 文章转存", + summary: "复用 X 登录态把长文导出成 Markdown 和图片目录。", + category: "站点采集", + outputHint: "Markdown 正文 + 图片目录", + source: "local_custom", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + version: "seed-v1", + badge: "本地技能", + recentUsedAt: null, + isRecent: false, + runnerLabel: "浏览器站点执行", + runnerTone: "emerald", + runnerDescription: "直接复用浏览器登录态执行。", + actionLabel: "启动采集", + automationStatus: null, + slotSchema: [ + { + key: "article_url", + label: "X 文章链接", + type: "url", + required: true, + placeholder: "https://x.com/<账号>/article/<文章ID>", + }, + ], + readinessRequirements: { + requiresBrowser: true, + requiresProject: true, + }, + siteCapabilityBinding: { + adapterName: "x/article-export", + autoRun: true, + requireAttachedSession: true, + saveMode: "project_resource", + slotArgMap: { + article_url: "url", + }, + }, + sceneBinding: { + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + aliases: ["x文章转存", "x转存"], + }, + }; +} + +function createGeneralServiceSkill( + overrides: Partial = {}, +): ServiceSkillHomeItem { + return { + id: "research", + title: "搜索", + summary: "针对当前主题执行联网检索与轻量调研。", + category: "通用技能", + outputHint: "结论 + 来源", + source: "cloud_catalog", + runnerType: "instant", + defaultExecutorBinding: "agent_turn", + executionLocation: "client_default", + version: "seed-v1", + badge: "云目录", + recentUsedAt: null, + isRecent: false, + runnerLabel: "Agent 调度", + runnerTone: "slate", + runnerDescription: "通过统一 Agent 发送链执行。", + actionLabel: "立即启动", + automationStatus: null, + slotSchema: [], + ...overrides, + }; +} + function createPreparedRuntimeTeamState(): TeamWorkspaceRuntimeFormationState { return { requestId: "runtime-team-preview-1", @@ -288,10 +390,14 @@ describe("useWorkspaceSendActions", () => { ).IS_REACT_ACT_ENVIRONMENT = true; vi.clearAllMocks(); + window.localStorage.clear(); mockResolveImageWorkbenchSkillRequest.mockReturnValue(null); mockEnsureSessionForCommandMetadata.mockResolvedValue(null); mockGetSkillCatalog.mockResolvedValue({ entries: [] }); mockListSkillCatalogSceneEntries.mockReturnValue([]); + mockGetOrCreateDefaultProject.mockResolvedValue({ + id: "project-default", + }); mockResolveOemCloudRuntimeContext.mockReturnValue(null); }); @@ -981,7 +1087,8 @@ describe("useWorkspaceSendActions", () => { } }); - it("预绑定 image_skill_launch metadata 时不应重新解析图片命令", async () => { + it("预绑定 image_skill_launch metadata 时不应重新解析图片命令,并且仍会绑定真实 session_id", async () => { + mockEnsureSessionForCommandMetadata.mockResolvedValue("session-image-bound"); const harness = mountHook({ input: "@配图 生成 一张春日咖啡馆插画", }); @@ -1004,6 +1111,7 @@ describe("useWorkspaceSendActions", () => { kind: "image_task", image_task: { prompt: "一张春日咖啡馆插画", + session_id: "__local_image_workbench__:draft", }, }, }, @@ -1015,11 +1123,15 @@ describe("useWorkspaceSendActions", () => { expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled(); expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ requestMetadata: { harness: { image_skill_launch: { skill_name: "image_generate", + image_task: { + session_id: "session-image-bound", + }, }, }, }, @@ -1282,6 +1394,79 @@ describe("useWorkspaceSendActions", () => { } }); + it("@搜索 直接输入成功后应回写对应 service skill 最近使用", async () => { + const harness = mountHook({ + input: + "@搜索 关键词:AI Agent 融资 站点:36Kr 时间:近30天 深度:深度 重点:融资额与产品发布 输出:要点", + serviceSkills: [ + createGeneralServiceSkill({ + id: "research", + title: "搜索", + }), + ], + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(listServiceSkillUsage()).toEqual([ + expect.objectContaining({ + skillId: "research", + runnerType: "instant", + }), + ]); + expect(listMentionEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "builtin_command", + entryId: "research", + replayText: + "关键词:AI Agent 融资 站点:36Kr 时间:近30天 深度:深度 重点:融资额与产品发布 输出:要点", + }), + ]); + } finally { + harness.unmount(); + } + }); + + it("通过 active builtin command 发送时,也应回写最近一次正文草稿", async () => { + const harness = mountHook({ + input: "GitHub 最近一周 openai agents sdk issue 讨论", + serviceSkills: [ + createGeneralServiceSkill({ + id: "research", + title: "搜索", + }), + ], + }); + + try { + await act(async () => { + const started = await harness + .getValue() + .handleSend( + undefined, + undefined, + undefined, + "@搜索 GitHub 最近一周 openai agents sdk issue 讨论", + ); + expect(started).toBe(true); + }); + + expect(listMentionEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "builtin_command", + entryId: "research", + replayText: "GitHub 最近一周 openai agents sdk issue 讨论", + }), + ]); + } finally { + harness.unmount(); + } + }); + it("@深搜 应保留原始消息,并通过 deep_search_skill_launch metadata 交给 Agent 调度 research 技能", async () => { const harness = mountHook({ input: @@ -1649,6 +1834,79 @@ describe("useWorkspaceSendActions", () => { } }); + it("@修图 直接输入成功后应归并回写 image_generate 的最近使用", async () => { + mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({ + images: [], + requestContext: { + kind: "image_task", + image_task: { + mode: "edit", + prompt: "去掉角标,保留主体", + target_output_ref_id: "img-2", + }, + }, + }); + const harness = mountHook({ + input: "@修图 #img-2 去掉角标,保留主体", + serviceSkills: [ + createGeneralServiceSkill({ + id: "image_generate", + title: "配图", + defaultExecutorBinding: "native_skill", + }), + ], + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(listServiceSkillUsage()).toEqual([ + expect.objectContaining({ + skillId: "image_generate", + runnerType: "instant", + }), + ]); + expect(listMentionEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "builtin_command", + entryId: "image_edit", + replayText: "#img-2 去掉角标,保留主体", + }), + ]); + } finally { + harness.unmount(); + } + }); + + it("@搜索 发送失败时不应误记最近使用", async () => { + mockSendMessage.mockRejectedValueOnce(new Error("network down")); + const harness = mountHook({ + input: + "@搜索 关键词:AI Agent 融资 站点:36Kr 时间:近30天 深度:深度 重点:融资额与产品发布 输出:要点", + serviceSkills: [ + createGeneralServiceSkill({ + id: "research", + title: "搜索", + }), + ], + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(false); + }); + + expect(listServiceSkillUsage()).toEqual([]); + expect(listMentionEntryUsage()).toEqual([]); + } finally { + harness.unmount(); + } + }); + it("@链接解析 应保留原始消息,并通过 url_parse_skill_launch metadata 交给 Agent 调度技能", async () => { const harness = mountHook({ input: @@ -1784,6 +2042,223 @@ describe("useWorkspaceSendActions", () => { } }); + it("@网页 应保留原始消息,并通过 webpage_skill_launch metadata 交给 Agent 调度技能", async () => { + const harness = mountHook({ + input: + "@网页 类型:落地页 风格:未来感 技术:原生 HTML 帮我做一个 AI 代码助手官网", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled(); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[0]).toBe( + "@网页 类型:落地页 风格:未来感 技术:原生 HTML 帮我做一个 AI 代码助手官网", + ); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + harness: { + allow_model_skills: true, + webpage_skill_launch: { + skill_name: "webpage_generate", + kind: "webpage_request", + webpage_request: { + prompt: "帮我做一个 AI 代码助手官网", + content: + "类型:落地页 风格:未来感 技术:原生 HTML 帮我做一个 AI 代码助手官网", + page_type: "landing_page", + style: "未来感", + tech_stack: "原生 HTML", + entry_source: "at_webpage_command", + }, + }, + }, + }, + }); + } finally { + harness.unmount(); + } + }); + + it("@PPT 应保留原始消息,并通过 presentation_skill_launch metadata 交给 Agent 调度技能", async () => { + const harness = mountHook({ + input: + "@PPT 类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[0]).toBe( + "@PPT 类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿", + ); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + harness: { + allow_model_skills: true, + presentation_skill_launch: { + skill_name: "presentation_generate", + kind: "presentation_request", + presentation_request: { + prompt: "帮我做一个 AI 助手创业项目融资演示稿", + content: + "类型:路演PPT 风格:极简科技 受众:投资人 页数:10 帮我做一个 AI 助手创业项目融资演示稿", + deck_type: "pitch_deck", + style: "极简科技", + audience: "投资人", + slide_count: 10, + entry_source: "at_presentation_command", + }, + }, + }, + }, + }); + } finally { + harness.unmount(); + } + }); + + it("@表单 应保留原始消息,并通过 form_skill_launch metadata 交给 Agent 调度技能", async () => { + const harness = mountHook({ + input: + "@表单 类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[0]).toBe( + "@表单 类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表", + ); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + harness: { + allow_model_skills: true, + form_skill_launch: { + skill_name: "form_generate", + kind: "form_request", + form_request: { + prompt: "帮我做一个 AI Workshop 报名表", + content: + "类型:报名表单 风格:简洁专业 受众:活动嘉宾 字段数:8 帮我做一个 AI Workshop 报名表", + form_type: "registration_form", + style: "简洁专业", + audience: "活动嘉宾", + field_count: 8, + entry_source: "at_form_command", + }, + }, + }, + }, + }); + } finally { + harness.unmount(); + } + }); + + it("@代码 应保留原始消息,并切到 code_orchestrated + 代码团队主链", async () => { + const harness = mountHook({ + input: "@代码 修复消息历史切换后图片卡片丢失的问题,并补一个回归测试", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[0]).toBe( + "@代码 修复消息历史切换后图片卡片丢失的问题,并补一个回归测试", + ); + expect(mockSendMessage.mock.calls[0]?.[5]).toBe("code_orchestrated"); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + harness: { + preferred_team_preset_id: "code-triage-team", + preferences: { + task: true, + subagent: true, + }, + code_command: { + kind: "bug_fix", + prompt: "修复消息历史切换后图片卡片丢失的问题,并补一个回归测试", + content: + "修复消息历史切换后图片卡片丢失的问题,并补一个回归测试", + entry_source: "at_code_command", + }, + }, + }, + }); + expect(listMentionEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "builtin_command", + entryId: "code_runtime", + replayText: "修复消息历史切换后图片卡片丢失的问题,并补一个回归测试", + }), + ]); + expect(listServiceSkillUsage()).toEqual([]); + } finally { + harness.unmount(); + } + }); + + it("@发布 应保留原始消息,并将 dispatch 接到现有发布工作流", async () => { + const harness = mountHook({ + input: "@发布 平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[0]).toBe( + "/content_post_with_cover 平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本", + ); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + displayContent: + "@发布 平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本", + requestMetadata: { + harness: { + browser_requirement: "required_with_user_step", + browser_launch_url: "https://mp.weixin.qq.com/", + publish_command: { + prompt: "帮我把这篇文章整理成可直接发布的版本", + content: "平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本", + platform_type: "wechat_official_account", + platform_label: "微信公众号后台", + entry_source: "at_publish_command", + }, + }, + }, + }); + expect(listMentionEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "builtin_command", + entryId: "publish_runtime", + replayText: "平台:微信公众号后台 帮我把这篇文章整理成可直接发布的版本", + }), + ]); + } finally { + harness.unmount(); + } + }); + it("/scene-key 命中运行时场景时应注入统一 scene launch metadata 后继续走 Agent 发送主线", async () => { mockGetSkillCatalog.mockResolvedValueOnce({ entries: [ @@ -1852,12 +2327,133 @@ describe("useWorkspaceSendActions", () => { }, }, }); + expect(listSlashEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "scene", + entryId: "campaign-launch", + }), + ]); expect(mockHandleAutoLaunchMatchedSiteSkill).not.toHaveBeenCalled(); } finally { harness.unmount(); } }); + it("site scene 应从 skill 声明推导 service_skill_launch metadata 并继续发送", async () => { + mockGetSkillCatalog.mockResolvedValueOnce({ + entries: [ + { + id: "scene:x-article-export", + }, + ], + }); + mockListSkillCatalogSceneEntries.mockReturnValueOnce([ + { + id: "scene:x-article-export", + kind: "scene", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + aliases: ["x文章转存", "x转存"], + linkedSkillId: "x-article-export", + executionKind: "site_adapter", + }, + ]); + const harness = mountHook({ + input: + "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + serviceSkills: [createXArticleExportSkill()], + contentId: "content-1", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + harness: { + browser_requirement: "required", + service_skill_launch: { + kind: "site_adapter", + skill_id: "x-article-export", + adapter_name: "x/article-export", + save_mode: "project_resource", + project_id: "project-1", + args: { + url: "https://x.com/GoogleCloudTech/article/2033953579824758855", + }, + }, + }, + }, + }); + expect(listSlashEntryUsage()).toEqual([ + expect.objectContaining({ + kind: "scene", + entryId: "x-article-export", + }), + ]); + } finally { + harness.unmount(); + } + }); + + it("site scene 缺少当前项目时应回退默认项目继续发送", async () => { + mockGetSkillCatalog.mockResolvedValueOnce({ + entries: [ + { + id: "scene:x-article-export", + }, + ], + }); + mockListSkillCatalogSceneEntries.mockReturnValueOnce([ + { + id: "scene:x-article-export", + kind: "scene", + title: "X文章转存", + summary: "把 X 长文导出成 Markdown。", + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + aliases: ["x文章转存", "x转存"], + linkedSkillId: "x-article-export", + executionKind: "site_adapter", + }, + ]); + const harness = mountHook({ + input: + "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", + serviceSkills: [createXArticleExportSkill()], + projectId: null, + contentId: null, + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockGetOrCreateDefaultProject).toHaveBeenCalledTimes(1); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ + requestMetadata: { + harness: { + service_skill_launch: { + project_id: "project-default", + adapter_name: "x/article-export", + }, + }, + }, + }); + } finally { + harness.unmount(); + } + }); + it("已携带 service_skill_launch metadata 时不应再被前端二次命中站点技能或浏览器预热", async () => { const harness = mountHook({ input: "请帮我使用 GitHub 查一下 AI Agent 项目", diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index db8d5fb14..ed65a1c16 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -2,12 +2,20 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import type { Dispatch, SetStateAction } from "react"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import { + getSeededSkillCatalog, + listSkillCatalogCommandEntries, +} from "@/lib/api/skillCatalog"; import { parseAnalysisWorkbenchCommand } from "../utils/analysisWorkbenchCommand"; import { parseBroadcastWorkbenchCommand } from "../utils/broadcastWorkbenchCommand"; +import { parseCodeWorkbenchCommand } from "../utils/codeWorkbenchCommand"; import { parseCoverWorkbenchCommand } from "../utils/coverWorkbenchCommand"; import { parseDeepSearchWorkbenchCommand } from "../utils/deepSearchWorkbenchCommand"; +import { parseFormWorkbenchCommand } from "../utils/formWorkbenchCommand"; import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import { parsePdfWorkbenchCommand } from "../utils/pdfWorkbenchCommand"; +import { parsePresentationWorkbenchCommand } from "../utils/presentationWorkbenchCommand"; +import { parsePublishWorkbenchCommand } from "../utils/publishWorkbenchCommand"; import { parseReportWorkbenchCommand } from "../utils/reportWorkbenchCommand"; import { parseResourceSearchWorkbenchCommand } from "../utils/resourceSearchWorkbenchCommand"; import { parseSearchWorkbenchCommand } from "../utils/searchWorkbenchCommand"; @@ -18,6 +26,8 @@ import { parseTranscriptionWorkbenchCommand } from "../utils/transcriptionWorkbe import { parseTypesettingWorkbenchCommand } from "../utils/typesettingWorkbenchCommand"; import { parseUrlParseWorkbenchCommand } from "../utils/urlParseWorkbenchCommand"; import { parseVideoWorkbenchCommand } from "../utils/videoWorkbenchCommand"; +import { parseWebpageWorkbenchCommand } from "../utils/webpageWorkbenchCommand"; +import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement"; import { isTeamRuntimeRecommendation } from "../utils/contextualRecommendations"; import { matchAutoLaunchSiteSkillFromText, @@ -59,8 +69,13 @@ import { } from "./imageSkillLaunch"; import { buildServiceSceneLaunchRequestMetadata, + RuntimeSceneLaunchValidationError, resolveRuntimeSceneLaunchRequest, } from "./serviceSkillSceneLaunch"; +import { recordMentionEntryUsage } from "../skill-selection/mentionEntryUsage"; +import { recordServiceSkillUsage } from "../service-skills/storage"; +import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; +import { CONTENT_POST_SKILL_KEY } from "../utils/contentPostSkill"; type ExecutionStrategy = "react" | "code_orchestrated" | "auto"; type SetStringState = (value: string) => void; @@ -85,6 +100,12 @@ type ParsedReportWorkbenchCommand = NonNullable< type ParsedPdfWorkbenchCommand = NonNullable< ReturnType >; +type ParsedPresentationWorkbenchCommand = NonNullable< + ReturnType +>; +type ParsedFormWorkbenchCommand = NonNullable< + ReturnType +>; type ParsedSearchWorkbenchCommand = NonNullable< ReturnType >; @@ -112,6 +133,28 @@ type ParsedUrlParseWorkbenchCommand = NonNullable< type ParsedTypesettingWorkbenchCommand = NonNullable< ReturnType >; +type ParsedWebpageWorkbenchCommand = NonNullable< + ReturnType +>; +type CompletedMentionUsage = { + skillId: string; + runnerType: ServiceSkillHomeItem["runnerType"]; +}; +type CompletedMentionCommandUsage = { + entryId: string; + replayText?: string; +}; + +const MENTION_COMMAND_SKILL_ID_MAP = new Map( + listSkillCatalogCommandEntries(getSeededSkillCatalog()) + .filter((entry) => entry.triggers.some((trigger) => trigger.mode === "mention")) + .flatMap((entry) => { + const commandKey = entry.commandKey.trim(); + const skillId = entry.binding?.skillId?.trim(); + + return commandKey && skillId ? [[commandKey, skillId] as const] : []; + }), +); function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -120,6 +163,73 @@ function asRecord(value: unknown): Record | undefined { return value as Record; } +function resolveImageMentionCommandKey( + trigger: ParsedImageWorkbenchCommand["trigger"], +): string | null { + if (trigger === "@修图") { + return "image_edit"; + } + if (trigger === "@重绘") { + return "image_variation"; + } + if (trigger === "@配图" || trigger === "@image") { + return "image_generate"; + } + return null; +} + +const MAX_MENTION_COMMAND_REPLAY_TEXT_LENGTH = 400; + +function normalizeMentionCommandReplayText( + value: string | null | undefined, +): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.slice(0, MAX_MENTION_COMMAND_REPLAY_TEXT_LENGTH).trim(); +} + +function resolveMentionCommandReplayText(parsedCommand: { + body: string; +}): string | undefined { + return normalizeMentionCommandReplayText(parsedCommand.body); +} + +function resolveMentionCommandUsage(params: { + commandKey: string; + serviceSkills: ServiceSkillHomeItem[]; +}): CompletedMentionUsage | null { + const normalizedCommandKey = params.commandKey.trim(); + if (!normalizedCommandKey) { + return null; + } + + const boundSkillId = MENTION_COMMAND_SKILL_ID_MAP.get(normalizedCommandKey); + if (!boundSkillId) { + return null; + } + + const matchedSkill = params.serviceSkills.find((skill) => { + const normalizedSkillId = skill.id.trim(); + const normalizedSkillKey = skill.skillKey?.trim(); + return ( + normalizedSkillId === boundSkillId || + normalizedSkillKey === boundSkillId + ); + }); + + if (!matchedSkill) { + return null; + } + + return { + skillId: matchedSkill.id, + runnerType: matchedSkill.runnerType, + }; +} + type SessionBoundRequestContextKey = | "image_task" | "cover_task" @@ -130,6 +240,17 @@ type SessionBoundRequestContextKey = | "url_parse_task" | "typesetting_task"; +type PendingCommandSessionBinding = + | { + kind: "request_context"; + requestContext: Record; + requestContextKey: SessionBoundRequestContextKey; + } + | { + kind: "scoped_request_context"; + scopedRequestContext: Record; + }; + function attachSessionIdToRequestContext( requestContext: Record, requestContextKey: SessionBoundRequestContextKey, @@ -149,6 +270,95 @@ function attachSessionIdToRequestContext( delete scopedRequestContext.session_id; } +function attachSessionIdToScopedRequestContext( + scopedRequestContext: Record, + sessionId: string | null | undefined, +): void { + const normalizedSessionId = sessionId?.trim(); + if (normalizedSessionId) { + scopedRequestContext.session_id = normalizedSessionId; + return; + } + + delete scopedRequestContext.session_id; +} + +const SESSION_BOUND_MODEL_SKILL_LAUNCHES: ReadonlyArray<{ + launchKey: + | "image_skill_launch" + | "cover_skill_launch" + | "video_skill_launch" + | "broadcast_skill_launch" + | "resource_search_skill_launch" + | "transcription_skill_launch" + | "url_parse_skill_launch" + | "typesetting_skill_launch"; + requestContextKey: SessionBoundRequestContextKey; +}> = [ + { + launchKey: "image_skill_launch", + requestContextKey: "image_task", + }, + { + launchKey: "cover_skill_launch", + requestContextKey: "cover_task", + }, + { + launchKey: "video_skill_launch", + requestContextKey: "video_task", + }, + { + launchKey: "broadcast_skill_launch", + requestContextKey: "broadcast_task", + }, + { + launchKey: "resource_search_skill_launch", + requestContextKey: "resource_search_task", + }, + { + launchKey: "transcription_skill_launch", + requestContextKey: "transcription_task", + }, + { + launchKey: "url_parse_skill_launch", + requestContextKey: "url_parse_task", + }, + { + launchKey: "typesetting_skill_launch", + requestContextKey: "typesetting_task", + }, +]; + +function extractBoundSessionRequestContext( + requestMetadata: Record | undefined, +): PendingCommandSessionBinding | null { + const harness = asRecord(requestMetadata?.harness); + if (!harness) { + return null; + } + + for (const launch of SESSION_BOUND_MODEL_SKILL_LAUNCHES) { + const launchMetadata = asRecord(harness[launch.launchKey]); + if (!launchMetadata) { + continue; + } + + const scopedRequestContext = + asRecord(launchMetadata[launch.requestContextKey]) || + asRecord(asRecord(launchMetadata.request_context)?.[launch.requestContextKey]); + if (!scopedRequestContext) { + continue; + } + + return { + kind: "scoped_request_context", + scopedRequestContext, + }; + } + + return null; +} + function buildModelSkillLaunchRequestMetadata(params: { existingMetadata: Record | undefined; requestContext: Record; @@ -168,7 +378,10 @@ function buildModelSkillLaunchRequestMetadata(params: { | "analysis_skill_launch" | "transcription_skill_launch" | "url_parse_skill_launch" - | "typesetting_skill_launch"; + | "typesetting_skill_launch" + | "presentation_skill_launch" + | "form_skill_launch" + | "webpage_skill_launch"; requestContextKey: | "image_task" | "cover_task" @@ -185,7 +398,10 @@ function buildModelSkillLaunchRequestMetadata(params: { | "analysis_request" | "transcription_task" | "url_parse_task" - | "typesetting_task"; + | "typesetting_task" + | "presentation_request" + | "form_request" + | "webpage_request"; defaultKind: | "image_task" | "cover_task" @@ -202,7 +418,10 @@ function buildModelSkillLaunchRequestMetadata(params: { | "analysis_request" | "transcription_task" | "url_parse_task" - | "typesetting_task"; + | "typesetting_task" + | "presentation_request" + | "form_request" + | "webpage_request"; skillName: | "image_generate" | "cover_generate" @@ -218,7 +437,10 @@ function buildModelSkillLaunchRequestMetadata(params: { | "analysis" | "transcription_generate" | "url_parse" - | "typesetting"; + | "typesetting" + | "presentation_generate" + | "form_generate" + | "webpage_generate"; }): Record { const scopedRequestContext = asRecord( params.requestContext[params.requestContextKey], @@ -456,6 +678,48 @@ function buildTypesettingSkillLaunchRequestMetadata( }); } +function buildPresentationSkillLaunchRequestMetadata( + existingMetadata: Record | undefined, + requestContext: Record, +): Record { + return buildModelSkillLaunchRequestMetadata({ + existingMetadata, + requestContext, + launchKey: "presentation_skill_launch", + requestContextKey: "presentation_request", + defaultKind: "presentation_request", + skillName: "presentation_generate", + }); +} + +function buildWebpageSkillLaunchRequestMetadata( + existingMetadata: Record | undefined, + requestContext: Record, +): Record { + return buildModelSkillLaunchRequestMetadata({ + existingMetadata, + requestContext, + launchKey: "webpage_skill_launch", + requestContextKey: "webpage_request", + defaultKind: "webpage_request", + skillName: "webpage_generate", + }); +} + +function buildFormSkillLaunchRequestMetadata( + existingMetadata: Record | undefined, + requestContext: Record, +): Record { + return buildModelSkillLaunchRequestMetadata({ + existingMetadata, + requestContext, + launchKey: "form_skill_launch", + requestContextKey: "form_request", + defaultKind: "form_request", + skillName: "form_generate", + }); +} + function buildVideoSkillLaunchRequestContext(params: { rawText: string; parsedCommand: ParsedVideoWorkbenchCommand; @@ -880,6 +1144,83 @@ function buildTypesettingSkillLaunchRequestContext(params: { }; } +function buildWebpageSkillLaunchRequestContext(params: { + rawText: string; + parsedCommand: ParsedWebpageWorkbenchCommand; + projectId?: string | null; + contentId?: string | null; +}): Record { + const prompt = + params.parsedCommand.prompt.trim() || "请生成一个可直接预览的网页"; + + return { + kind: "webpage_request", + webpage_request: { + raw_text: params.rawText, + prompt, + content: params.parsedCommand.body || undefined, + page_type: params.parsedCommand.pageType, + style: params.parsedCommand.style, + tech_stack: params.parsedCommand.techStack, + project_id: params.projectId || undefined, + content_id: params.contentId || undefined, + entry_source: "at_webpage_command", + }, + }; +} + +function buildPresentationSkillLaunchRequestContext(params: { + rawText: string; + parsedCommand: ParsedPresentationWorkbenchCommand; + projectId?: string | null; + contentId?: string | null; +}): Record { + const prompt = + params.parsedCommand.prompt.trim() || "请生成一份可直接讲述的演示文稿草稿"; + + return { + kind: "presentation_request", + presentation_request: { + raw_text: params.rawText, + prompt, + content: params.parsedCommand.body || undefined, + deck_type: params.parsedCommand.deckType, + style: params.parsedCommand.style, + audience: params.parsedCommand.audience, + slide_count: params.parsedCommand.slideCount, + project_id: params.projectId || undefined, + content_id: params.contentId || undefined, + entry_source: "at_presentation_command", + }, + }; +} + +function buildFormSkillLaunchRequestContext(params: { + rawText: string; + parsedCommand: ParsedFormWorkbenchCommand; + projectId?: string | null; + contentId?: string | null; +}): Record { + const prompt = + params.parsedCommand.prompt.trim() || "请生成一个可直接在聊天区渲染的 A2UI 表单"; + + return { + kind: "form_request", + form_request: { + raw_text: params.rawText, + prompt, + content: params.parsedCommand.body || undefined, + form_type: params.parsedCommand.formType, + style: params.parsedCommand.style, + audience: params.parsedCommand.audience, + field_count: params.parsedCommand.fieldCount, + project_id: params.projectId || undefined, + content_id: params.contentId || undefined, + entry_source: "at_form_command", + }, + }; +} + interface UseWorkspaceSendActionsParams { input: string; setInput: SetStringState; @@ -955,6 +1296,12 @@ interface WorkspaceSendPlan extends WorkspaceResolvedSendState { sendExecutionStrategy?: ExecutionStrategy; autoContinuePayload?: AutoContinueRequestPayload; sendOptions?: HandleSendOptions; + completedMentionCommandUsage: CompletedMentionCommandUsage | null; + completedMentionUsage: CompletedMentionUsage | null; + completedSlashUsage?: { + kind: "scene"; + entryId: string; + } | null; } type WorkspaceSendResolution = @@ -1043,7 +1390,7 @@ export function useWorkspaceSendActions({ sourceText = sendBoundary.sourceText; let dispatchText = sourceText; - const effectiveToolPreferences = + let effectiveToolPreferences = sendOptions?.toolPreferencesOverride ?? chatToolPreferences; const { browserRequirementMatch } = sendBoundary; const mergedLaunchRequestMetadata = { @@ -1062,12 +1409,6 @@ export function useWorkspaceSendActions({ : requestedWebSearch; const effectiveThinking = thinking ?? effectiveToolPreferences.thinking; - if (!projectId) { - sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); - toast.error("请先选择项目后再开始对话"); - return { kind: "done", result: false }; - } - const preparedActiveContextPrompt = contextWorkspace.enabled && !contextWorkspace.activeContextPrompt.trim() @@ -1085,12 +1426,14 @@ export function useWorkspaceSendActions({ let commandSessionId: string | null | undefined; let commandSessionPromise: Promise | null = null; - let pendingCommandSessionBinding: - | { - requestContext: Record; - requestContextKey: SessionBoundRequestContextKey; - } + let pendingCommandSessionBinding: PendingCommandSessionBinding | null = + extractBoundSessionRequestContext(mergedLaunchRequestMetadata); + let completedMentionCommandUsage: + | WorkspaceSendPlan["completedMentionCommandUsage"] | null = null; + let completedMentionUsage: WorkspaceSendPlan["completedMentionUsage"] = + null; + let completedSlashUsage: WorkspaceSendPlan["completedSlashUsage"] = null; let submissionPreviewKey: string | null = null; const ensureSubmissionPreview = (previewImages = effectiveImages) => { if (submissionPreviewKey) { @@ -1135,6 +1478,19 @@ export function useWorkspaceSendActions({ const ensureCommandSessionId = async () => { return primeCommandSessionId(); }; + const markCompletedMentionCommand = ( + commandKey: string, + replayText?: string, + ) => { + completedMentionCommandUsage = { + entryId: commandKey, + replayText: normalizeMentionCommandReplayText(replayText), + }; + completedMentionUsage = resolveMentionCommandUsage({ + commandKey, + serviceSkills, + }); + }; const parsedImageWorkbenchCommand = !sendOptions?.purpose && !hasBoundSkillLaunch && sourceText.trim() @@ -1156,6 +1512,7 @@ export function useWorkspaceSendActions({ ? skillRequest.images : effectiveImages; pendingCommandSessionBinding = { + kind: "request_context", requestContext: skillRequest.requestContext, requestContextKey: "image_task", }; @@ -1168,6 +1525,15 @@ export function useWorkspaceSendActions({ skillRequest.requestContext, ), }; + const mentionCommandKey = resolveImageMentionCommandKey( + parsedImageWorkbenchCommand.trigger, + ); + if (mentionCommandKey) { + markCompletedMentionCommand( + mentionCommandKey, + resolveMentionCommandReplayText(parsedImageWorkbenchCommand), + ); + } hasBoundSkillLaunch = true; } @@ -1189,6 +1555,7 @@ export function useWorkspaceSendActions({ return { kind: "done", result: false }; } pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "cover_task", }; @@ -1201,6 +1568,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "cover_generate", + resolveMentionCommandReplayText(parsedCoverWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1223,6 +1594,7 @@ export function useWorkspaceSendActions({ return { kind: "done", result: false }; } pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "video_task", }; @@ -1235,6 +1607,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "video_generate", + resolveMentionCommandReplayText(parsedVideoWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1254,6 +1630,7 @@ export function useWorkspaceSendActions({ contentId, }); pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "broadcast_task", }; @@ -1266,6 +1643,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "broadcast_generate", + resolveMentionCommandReplayText(parsedBroadcastWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1286,6 +1667,7 @@ export function useWorkspaceSendActions({ contentId, }); pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "resource_search_task", }; @@ -1298,6 +1680,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "modal_resource_search", + resolveMentionCommandReplayText(parsedResourceSearchWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1319,6 +1705,7 @@ export function useWorkspaceSendActions({ contentId, }); pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "transcription_task", }; @@ -1331,6 +1718,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "transcription_generate", + resolveMentionCommandReplayText(parsedTranscriptionWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1362,6 +1753,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "research", + resolveMentionCommandReplayText(parsedSearchWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1394,6 +1789,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "research_report", + resolveMentionCommandReplayText(parsedReportWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1427,6 +1826,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "deep_search", + resolveMentionCommandReplayText(parsedDeepSearchWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1461,6 +1864,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "site_search", + resolveMentionCommandReplayText(parsedSiteSearchWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1496,6 +1903,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "read_pdf", + resolveMentionCommandReplayText(parsedPdfWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1529,6 +1940,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "summary", + resolveMentionCommandReplayText(parsedSummaryWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1563,6 +1978,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "translation", + resolveMentionCommandReplayText(parsedTranslationWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1598,6 +2017,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "analysis", + resolveMentionCommandReplayText(parsedAnalysisWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1627,6 +2050,7 @@ export function useWorkspaceSendActions({ contentId, }); pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "url_parse_task", }; @@ -1639,6 +2063,10 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "url_parse", + resolveMentionCommandReplayText(parsedUrlParseWorkbenchCommand), + ); hasBoundSkillLaunch = true; } @@ -1669,6 +2097,7 @@ export function useWorkspaceSendActions({ contentId, }); pendingCommandSessionBinding = { + kind: "request_context", requestContext, requestContextKey: "typesetting_task", }; @@ -1681,17 +2110,292 @@ export function useWorkspaceSendActions({ requestContext, ), }; + markCompletedMentionCommand( + "typesetting", + resolveMentionCommandReplayText(parsedTypesettingWorkbenchCommand), + ); hasBoundSkillLaunch = true; } + const parsedPresentationWorkbenchCommand = + !sendOptions?.purpose && + sourceText.trim() && + !parsedImageWorkbenchCommand && + !parsedCoverWorkbenchCommand && + !parsedVideoWorkbenchCommand && + !parsedBroadcastWorkbenchCommand && + !parsedResourceSearchWorkbenchCommand && + !parsedTranscriptionWorkbenchCommand && + !parsedSearchWorkbenchCommand && + !parsedReportWorkbenchCommand && + !parsedDeepSearchWorkbenchCommand && + !parsedSiteSearchWorkbenchCommand && + !parsedPdfWorkbenchCommand && + !parsedSummaryWorkbenchCommand && + !parsedTranslationWorkbenchCommand && + !parsedUrlParseWorkbenchCommand && + !parsedTypesettingWorkbenchCommand + ? parsePresentationWorkbenchCommand(sourceText) + : null; + if (parsedPresentationWorkbenchCommand) { + const requestContext = buildPresentationSkillLaunchRequestContext({ + rawText: sourceText, + parsedCommand: parsedPresentationWorkbenchCommand, + projectId, + contentId, + }); + ensureSubmissionPreview(); + sendOptions = { + ...(sendOptions || {}), + requestMetadata: buildPresentationSkillLaunchRequestMetadata( + sendOptions?.requestMetadata, + requestContext, + ), + }; + markCompletedMentionCommand( + "presentation_generate", + resolveMentionCommandReplayText(parsedPresentationWorkbenchCommand), + ); + hasBoundSkillLaunch = true; + } + + const parsedFormWorkbenchCommand = + !sendOptions?.purpose && + sourceText.trim() && + !parsedImageWorkbenchCommand && + !parsedCoverWorkbenchCommand && + !parsedVideoWorkbenchCommand && + !parsedBroadcastWorkbenchCommand && + !parsedResourceSearchWorkbenchCommand && + !parsedTranscriptionWorkbenchCommand && + !parsedSearchWorkbenchCommand && + !parsedReportWorkbenchCommand && + !parsedDeepSearchWorkbenchCommand && + !parsedSiteSearchWorkbenchCommand && + !parsedPdfWorkbenchCommand && + !parsedSummaryWorkbenchCommand && + !parsedTranslationWorkbenchCommand && + !parsedUrlParseWorkbenchCommand && + !parsedTypesettingWorkbenchCommand && + !parsedPresentationWorkbenchCommand + ? parseFormWorkbenchCommand(sourceText) + : null; + if (parsedFormWorkbenchCommand) { + const requestContext = buildFormSkillLaunchRequestContext({ + rawText: sourceText, + parsedCommand: parsedFormWorkbenchCommand, + projectId, + contentId, + }); + ensureSubmissionPreview(); + sendOptions = { + ...(sendOptions || {}), + requestMetadata: buildFormSkillLaunchRequestMetadata( + sendOptions?.requestMetadata, + requestContext, + ), + }; + markCompletedMentionCommand( + "form_generate", + resolveMentionCommandReplayText(parsedFormWorkbenchCommand), + ); + hasBoundSkillLaunch = true; + } + + const parsedWebpageWorkbenchCommand = + !sendOptions?.purpose && + sourceText.trim() && + !parsedImageWorkbenchCommand && + !parsedCoverWorkbenchCommand && + !parsedVideoWorkbenchCommand && + !parsedBroadcastWorkbenchCommand && + !parsedResourceSearchWorkbenchCommand && + !parsedTranscriptionWorkbenchCommand && + !parsedSearchWorkbenchCommand && + !parsedReportWorkbenchCommand && + !parsedDeepSearchWorkbenchCommand && + !parsedSiteSearchWorkbenchCommand && + !parsedPdfWorkbenchCommand && + !parsedSummaryWorkbenchCommand && + !parsedTranslationWorkbenchCommand && + !parsedUrlParseWorkbenchCommand && + !parsedTypesettingWorkbenchCommand && + !parsedPresentationWorkbenchCommand && + !parsedFormWorkbenchCommand + ? parseWebpageWorkbenchCommand(sourceText) + : null; + if (parsedWebpageWorkbenchCommand) { + const requestContext = buildWebpageSkillLaunchRequestContext({ + rawText: sourceText, + parsedCommand: parsedWebpageWorkbenchCommand, + projectId, + contentId, + }); + ensureSubmissionPreview(); + sendOptions = { + ...(sendOptions || {}), + requestMetadata: buildWebpageSkillLaunchRequestMetadata( + sendOptions?.requestMetadata, + requestContext, + ), + }; + markCompletedMentionCommand( + "webpage_generate", + resolveMentionCommandReplayText(parsedWebpageWorkbenchCommand), + ); + hasBoundSkillLaunch = true; + } + + const parsedCodeWorkbenchCommand = + !sendOptions?.purpose && + sourceText.trim() && + !parsedImageWorkbenchCommand && + !parsedCoverWorkbenchCommand && + !parsedVideoWorkbenchCommand && + !parsedBroadcastWorkbenchCommand && + !parsedResourceSearchWorkbenchCommand && + !parsedTranscriptionWorkbenchCommand && + !parsedSearchWorkbenchCommand && + !parsedReportWorkbenchCommand && + !parsedDeepSearchWorkbenchCommand && + !parsedSiteSearchWorkbenchCommand && + !parsedPdfWorkbenchCommand && + !parsedSummaryWorkbenchCommand && + !parsedTranslationWorkbenchCommand && + !parsedUrlParseWorkbenchCommand && + !parsedTypesettingWorkbenchCommand && + !parsedPresentationWorkbenchCommand && + !parsedFormWorkbenchCommand && + !parsedWebpageWorkbenchCommand + ? parseCodeWorkbenchCommand(sourceText) + : null; + if (parsedCodeWorkbenchCommand) { + const existingHarnessMetadata = + asRecord(sendOptions?.requestMetadata?.harness) || {}; + effectiveToolPreferences = { + ...effectiveToolPreferences, + task: true, + subagent: true, + }; + sendExecutionStrategy = "code_orchestrated"; + ensureSubmissionPreview(); + sendOptions = { + ...(sendOptions || {}), + toolPreferencesOverride: effectiveToolPreferences, + requestMetadata: { + ...(sendOptions?.requestMetadata || {}), + harness: { + ...existingHarnessMetadata, + preferred_team_preset_id: "code-triage-team", + code_command: { + kind: + parsedCodeWorkbenchCommand.taskType || "implementation", + prompt: + parsedCodeWorkbenchCommand.prompt || + parsedCodeWorkbenchCommand.body, + content: parsedCodeWorkbenchCommand.body, + entry_source: "at_code_command", + }, + }, + }, + }; + markCompletedMentionCommand( + "code_runtime", + resolveMentionCommandReplayText(parsedCodeWorkbenchCommand), + ); + } + + const parsedPublishWorkbenchCommand = + !sendOptions?.purpose && + sourceText.trim() && + !parsedImageWorkbenchCommand && + !parsedCoverWorkbenchCommand && + !parsedVideoWorkbenchCommand && + !parsedBroadcastWorkbenchCommand && + !parsedResourceSearchWorkbenchCommand && + !parsedTranscriptionWorkbenchCommand && + !parsedSearchWorkbenchCommand && + !parsedReportWorkbenchCommand && + !parsedDeepSearchWorkbenchCommand && + !parsedSiteSearchWorkbenchCommand && + !parsedPdfWorkbenchCommand && + !parsedSummaryWorkbenchCommand && + !parsedTranslationWorkbenchCommand && + !parsedUrlParseWorkbenchCommand && + !parsedTypesettingWorkbenchCommand && + !parsedPresentationWorkbenchCommand && + !parsedFormWorkbenchCommand && + !parsedWebpageWorkbenchCommand && + !parsedCodeWorkbenchCommand + ? parsePublishWorkbenchCommand(sourceText) + : null; + if (parsedPublishWorkbenchCommand) { + const existingHarnessMetadata = + asRecord(sendOptions?.requestMetadata?.harness) || {}; + const publishBrowserRequirementMatch = detectBrowserTaskRequirement( + parsedPublishWorkbenchCommand.body || + parsedPublishWorkbenchCommand.prompt || + sourceText, + ); + const nextBody = + parsedPublishWorkbenchCommand.body || + parsedPublishWorkbenchCommand.prompt; + dispatchText = `/${CONTENT_POST_SKILL_KEY}${nextBody ? ` ${nextBody}` : ""}`; + ensureSubmissionPreview(); + sendOptions = { + ...(sendOptions || {}), + requestMetadata: { + ...(sendOptions?.requestMetadata || {}), + harness: { + ...existingHarnessMetadata, + ...(publishBrowserRequirementMatch + ? { + browser_requirement: + publishBrowserRequirementMatch.requirement, + browser_requirement_reason: + publishBrowserRequirementMatch.reason, + browser_launch_url: + publishBrowserRequirementMatch.launchUrl, + } + : {}), + publish_command: { + prompt: + parsedPublishWorkbenchCommand.prompt || + parsedPublishWorkbenchCommand.body, + content: parsedPublishWorkbenchCommand.body, + platform_type: + parsedPublishWorkbenchCommand.platformType || undefined, + platform_label: + parsedPublishWorkbenchCommand.platformLabel || undefined, + entry_source: "at_publish_command", + }, + }, + }, + }; + markCompletedMentionCommand( + "publish_runtime", + resolveMentionCommandReplayText(parsedPublishWorkbenchCommand), + ); + } + if (!sendOptions?.purpose && sourceText.trim().startsWith("/")) { ensureSubmissionPreview(); - const sceneLaunchRequest = await resolveRuntimeSceneLaunchRequest({ - rawText: sourceText, - serviceSkills, - projectId, - contentId, - }); + let sceneLaunchRequest = null; + try { + sceneLaunchRequest = await resolveRuntimeSceneLaunchRequest({ + rawText: sourceText, + serviceSkills, + projectId, + contentId, + }); + } catch (error) { + if (error instanceof RuntimeSceneLaunchValidationError) { + toast.error(error.message); + clearSubmissionPreview(); + return { kind: "done", result: false }; + } + throw error; + } if (sceneLaunchRequest) { sendOptions = { ...(sendOptions || {}), @@ -1701,6 +2405,10 @@ export function useWorkspaceSendActions({ ), }; hasBoundSkillLaunch = true; + completedSlashUsage = { + kind: "scene", + entryId: sceneLaunchRequest.sceneEntry.sceneKey, + }; } } @@ -1725,6 +2433,20 @@ export function useWorkspaceSendActions({ } } + const mergedRequestMetadataAfterLaunch = { + ...(workspaceRequestMetadataBase || {}), + ...(sendOptions?.requestMetadata || {}), + }; + if ( + !projectId && + !hasServiceSkillLaunchRequestMetadata(mergedRequestMetadataAfterLaunch) + ) { + sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); + toast.error("请先选择项目后再开始对话"); + clearSubmissionPreview(); + return { kind: "done", result: false }; + } + const shouldPrimeSessionForInitialConversationSend = !sendOptions?.purpose && !hasBoundSkillLaunch && @@ -1754,11 +2476,19 @@ export function useWorkspaceSendActions({ preparedActiveContextPrompt, }); if (pendingCommandSessionBinding) { - attachSessionIdToRequestContext( - pendingCommandSessionBinding.requestContext, - pendingCommandSessionBinding.requestContextKey, - await ensureCommandSessionId(), - ); + const resolvedSessionId = await ensureCommandSessionId(); + if (pendingCommandSessionBinding.kind === "request_context") { + attachSessionIdToRequestContext( + pendingCommandSessionBinding.requestContext, + pendingCommandSessionBinding.requestContextKey, + resolvedSessionId, + ); + } else { + attachSessionIdToScopedRequestContext( + pendingCommandSessionBinding.scopedRequestContext, + resolvedSessionId, + ); + } } submissionPreviewKey = resolvedSubmissionPreviewKey; } catch (error) { @@ -1781,6 +2511,9 @@ export function useWorkspaceSendActions({ sendExecutionStrategy, autoContinuePayload, sendOptions, + completedMentionCommandUsage, + completedMentionUsage, + completedSlashUsage, }, }; }, @@ -1819,6 +2552,8 @@ export function useWorkspaceSendActions({ sendExecutionStrategy, autoContinuePayload, sendOptions, + completedMentionCommandUsage, + completedMentionUsage, } = plan; setRuntimeTeamDispatchPreview(null); @@ -1886,6 +2621,22 @@ export function useWorkspaceSendActions({ nextSendOptions, ); + if (completedMentionCommandUsage) { + recordMentionEntryUsage({ + kind: "builtin_command", + entryId: completedMentionCommandUsage.entryId, + replayText: completedMentionCommandUsage.replayText, + }); + } + + if (completedMentionUsage) { + recordServiceSkillUsage(completedMentionUsage); + } + + if (plan.completedSlashUsage) { + recordSlashEntryUsage(plan.completedSlashUsage); + } + finalizeAfterSendSuccess(sendBoundary); return true; } catch (error) { diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx index 35cc15659..09d2f9adf 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx @@ -13,6 +13,7 @@ const mockGetServiceSkillRun = vi.fn(); const mockIsTerminalServiceSkillRunStatus = vi.fn(); const mockCreateContent = vi.fn(); const mockListProjects = vi.fn(); +const mockGetOrCreateDefaultProject = vi.fn(); const mockRecordServiceSkillAutomationLink = vi.fn(); const mockSiteGetAdapterLaunchReadiness = vi.fn(); const mockToastSuccess = vi.fn(); @@ -42,6 +43,7 @@ vi.mock("@/lib/api/serviceSkillRuns", () => ({ vi.mock("@/lib/api/project", () => ({ createContent: (request: unknown) => mockCreateContent(request), + getOrCreateDefaultProject: () => mockGetOrCreateDefaultProject(), listProjects: () => mockListProjects(), getDefaultContentTypeForProject: (projectType: string) => { switch (projectType) { @@ -322,6 +324,7 @@ beforeEach(() => { id: "content-created-by-service-skill", }); mockListProjects.mockResolvedValue([createProject()]); + mockGetOrCreateDefaultProject.mockResolvedValue(createProject("project-default")); mockRecordServiceSkillAutomationLink.mockReset(); mockToastSuccess.mockReset(); mockToastError.mockReset(); @@ -473,6 +476,45 @@ describe("useWorkspaceServiceSkillEntryActions", () => { ); }); + it("站点型技能缺少当前项目时应回退默认项目后再进入工作区", async () => { + const onNavigate = vi.fn(); + const { render, getValue } = renderHook({ + onNavigate, + projectId: null, + contentId: null, + recordServiceSkillUsage: vi.fn(), + }); + await render(); + + await act(async () => { + await getValue().handleServiceSkillLaunch(createBrowserServiceSkill(), { + repository_query: "browser assist mcp", + }); + }); + + expect(mockGetOrCreateDefaultProject).toHaveBeenCalledTimes(1); + expect(mockCreateContent).toHaveBeenCalledWith( + expect.objectContaining({ + project_id: "project-default", + }), + ); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + projectId: "project-default", + contentId: "content-created-by-service-skill", + initialAutoSendRequestMetadata: { + harness: expect.objectContaining({ + service_skill_launch: expect.objectContaining({ + project_id: "project-default", + content_id: "content-created-by-service-skill", + }), + }), + }, + }), + ); + }); + it("站点型技能次级动作才应跳到浏览器工作台", async () => { const onNavigate = vi.fn(); const recordServiceSkillUsage = vi.fn(); diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts index 78fc83dba..5f5ad7e9f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts @@ -10,6 +10,7 @@ import { } from "@/lib/api/serviceSkillRuns"; import { createContent, + getOrCreateDefaultProject, listProjects, type Project, } from "@/lib/api/project"; @@ -89,6 +90,18 @@ function normalizeOptionalText(value?: string | null): string | undefined { return normalized ? normalized : undefined; } +function siteSkillRequiresProject(skill: ServiceSkillHomeItem): boolean { + if (!isServiceSkillExecutableAsSiteAdapter(skill)) { + return false; + } + + return ( + skill.readinessRequirements?.requiresProject || + (skill.siteCapabilityBinding.saveMode ?? "project_resource") === + "project_resource" + ); +} + interface ServiceSkillLaunchOptions { launchUserInput?: string | null; fallbackToWorkspaceOnCloudSubmitFailure?: boolean; @@ -277,7 +290,7 @@ export function useWorkspaceServiceSkillEntryActions({ ), }; const resolved = resolveWorkspaceEntry({ - projectId: currentProjectId, + projectId: payload.projectId ?? currentProjectId, activeTheme, creationMode, defaultToolPreferences: chatToolPreferences, @@ -352,6 +365,27 @@ export function useWorkspaceServiceSkillEntryActions({ [activeTheme, currentProjectId], ); + const resolveSiteSkillProjectId = useCallback( + async (skill: ServiceSkillHomeItem): Promise => { + if (!siteSkillRequiresProject(skill)) { + return undefined; + } + + if (currentProjectId) { + return currentProjectId; + } + + const defaultProject = await getOrCreateDefaultProject(); + const defaultProjectId = normalizeProjectId(defaultProject?.id); + if (!defaultProjectId) { + throw new Error("当前技能需要项目工作区,但默认项目准备失败。"); + } + + return defaultProjectId; + }, + [currentProjectId], + ); + const prepareServiceSkillWorkspacePayload = useCallback( async ( skill: ServiceSkillHomeItem, @@ -432,13 +466,7 @@ export function useWorkspaceServiceSkillEntryActions({ throw new Error(buildSiteLaunchBlockedMessage(launchReadiness)); } - if ( - skill.readinessRequirements?.requiresProject && - !currentProjectId - ) { - throw new Error("缺少项目工作区,请先选择项目后再启动站点技能。"); - } - + const resolvedProjectId = await resolveSiteSkillProjectId(skill); const binding = skill.siteCapabilityBinding; const saveMode = binding.saveMode ?? "project_resource"; const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle( @@ -450,11 +478,11 @@ export function useWorkspaceServiceSkillEntryActions({ if ( saveMode === "current_content" && !nextContentId && - currentProjectId + resolvedProjectId ) { const created = await createServiceSkillSeededContent( skill, - currentProjectId, + resolvedProjectId, ); nextContentId = created?.id ?? undefined; } @@ -462,7 +490,7 @@ export function useWorkspaceServiceSkillEntryActions({ const clawLaunchContext = { ...buildServiceSkillClawLaunchContext(skill, slotValues, { contentId: nextContentId, - projectId: currentProjectId, + projectId: resolvedProjectId, launchReadiness, }), saveTitle: nextContentId ? undefined : initialSaveTitle, @@ -476,6 +504,7 @@ export function useWorkspaceServiceSkillEntryActions({ return { prompt, + projectId: resolvedProjectId, contentId: nextContentId, themeOverride: "general", initialAutoSendRequestMetadata: @@ -483,7 +512,12 @@ export function useWorkspaceServiceSkillEntryActions({ autoRunInitialPromptOnMount: true, }; }, - [createServiceSkillSeededContent, currentContentId, currentProjectId, input], + [ + createServiceSkillSeededContent, + currentContentId, + input, + resolveSiteSkillProjectId, + ], ); const prepareServiceSkillCloudResultWorkspacePayload = useCallback( @@ -548,11 +582,11 @@ export function useWorkspaceServiceSkillEntryActions({ return; } - if ( - skill.readinessRequirements?.requiresProject && - !currentProjectId - ) { - toast.error("缺少项目工作区,请先选择项目后再启动浏览器采集。"); + let resolvedProjectId: string | undefined; + try { + resolvedProjectId = await resolveSiteSkillProjectId(skill); + } catch (error) { + toast.error(getErrorMessage(error)); return; } @@ -581,12 +615,12 @@ export function useWorkspaceServiceSkillEntryActions({ if ( saveMode === "current_content" && !nextContentId && - currentProjectId + resolvedProjectId ) { try { const created = await createServiceSkillSeededContent( skill, - currentProjectId, + resolvedProjectId, ); nextContentId = created?.id ?? undefined; } catch (error) { @@ -596,7 +630,7 @@ export function useWorkspaceServiceSkillEntryActions({ } const navigationParams: BrowserRuntimePageParams = { - projectId: currentProjectId ?? undefined, + projectId: resolvedProjectId, contentId: nextContentId, initialProfileKey: launchReadiness?.status === "ready" @@ -624,9 +658,9 @@ export function useWorkspaceServiceSkillEntryActions({ [ createServiceSkillSeededContent, currentContentId, - currentProjectId, onNavigate, recordServiceSkillUsage, + resolveSiteSkillProjectId, ], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx index 30fb3518b..f91dba7ac 100644 --- a/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceShellSceneRuntime.tsx @@ -16,6 +16,7 @@ interface UseWorkspaceShellSceneRuntimeParams { isThemeWorkbench: boolean; showChatPanel: boolean; showSidebar: boolean; + sidebarContextVariant?: WorkspaceShellSceneProps["sidebarContextVariant"]; generalWorkbenchShellRuntime: GeneralWorkbenchShellRuntime; conversationSceneRuntime: ConversationSceneRuntime; sessionId?: WorkspaceShellSceneProps["currentTopicId"]; @@ -40,6 +41,7 @@ export function useWorkspaceShellSceneRuntime({ isThemeWorkbench, showChatPanel, showSidebar, + sidebarContextVariant = "default", generalWorkbenchShellRuntime, conversationSceneRuntime, sessionId, @@ -68,6 +70,7 @@ export function useWorkspaceShellSceneRuntime({ } showChatPanel={showChatPanel} showSidebar={showSidebar} + sidebarContextVariant={sidebarContextVariant} showGeneralWorkbenchLeftExpandButton={ generalWorkbenchShellRuntime.showGeneralWorkbenchLeftExpandButton } diff --git a/src/components/agent/chat/workspace/useWorkspaceVideoTaskActionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceVideoTaskActionRuntime.test.tsx new file mode 100644 index 000000000..5d4f7d89a --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceVideoTaskActionRuntime.test.tsx @@ -0,0 +1,250 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { emitVideoWorkbenchTaskAction } from "@/lib/videoWorkbenchEvents"; +import type { Message } from "../types"; +import { useWorkspaceVideoTaskActionRuntime } from "./useWorkspaceVideoTaskActionRuntime"; + +const { mockCancelTask, mockCreateTask, mockGetTask, toast } = vi.hoisted( + () => ({ + mockCancelTask: vi.fn(), + mockCreateTask: vi.fn(), + mockGetTask: vi.fn(), + toast: { + error: vi.fn(), + success: vi.fn(), + }, + }), +); + +vi.mock("sonner", () => ({ + toast, +})); + +vi.mock("@/lib/api/videoGeneration", () => ({ + videoGenerationApi: { + getTask: mockGetTask, + createTask: mockCreateTask, + cancelTask: mockCancelTask, + }, +})); + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function buildFailedVideoMessage(): Message { + return { + id: "msg-video-failed-1", + role: "assistant", + content: "视频任务失败。", + timestamp: new Date(), + taskPreview: { + kind: "video_generate", + taskId: "task-video-old", + taskType: "video_generate", + prompt: "新品发布会短视频", + status: "failed", + durationSeconds: 15, + aspectRatio: "16:9", + resolution: "720p", + projectId: "project-video-1", + contentId: "content-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + }, + }; +} + +function buildRunningVideoMessage(): Message { + return { + ...buildFailedVideoMessage(), + id: "msg-video-running-1", + content: "视频任务进行中。", + taskPreview: { + ...buildFailedVideoMessage().taskPreview!, + taskId: "task-video-running", + status: "running", + progress: 32, + statusMessage: "视频任务正在生成中,工作区会继续同步最新状态。", + }, + }; +} + +function renderHook(props: HookProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + function Probe(currentProps: HookProps) { + useWorkspaceVideoTaskActionRuntime(currentProps); + return null; + } + + const render = async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ container, root }); + return { render }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockCancelTask.mockReset(); + mockCreateTask.mockReset(); + mockGetTask.mockReset(); + toast.error.mockReset(); + toast.success.mockReset(); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("useWorkspaceVideoTaskActionRuntime", () => { + it("应响应聊天区视频任务卡的重试动作,并把消息卡切回新的运行任务", async () => { + let messages: Message[] = [buildFailedVideoMessage()]; + const setChatMessages: HookProps["setChatMessages"] = (value) => { + messages = typeof value === "function" ? value(messages) : value; + }; + + mockGetTask.mockResolvedValue({ + id: "task-video-old", + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "新品发布会短视频", + requestPayload: JSON.stringify({ + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "新品发布会短视频", + aspectRatio: "16:9", + resolution: "720p", + duration: 15, + imageUrl: "material://start", + endImageUrl: "material://end", + }), + status: "error", + createdAt: Date.now(), + updatedAt: Date.now(), + }); + mockCreateTask.mockResolvedValue({ + id: "task-video-new", + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "新品发布会短视频", + status: "pending", + progress: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const harness = renderHook({ + projectId: "project-video-1", + contentId: "content-video-1", + setChatMessages, + }); + + await harness.render(); + await act(async () => { + emitVideoWorkbenchTaskAction({ + action: "retry", + taskId: "task-video-old", + projectId: "project-video-1", + contentId: "content-video-1", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockGetTask).toHaveBeenCalledWith("task-video-old", { + refreshStatus: false, + }); + expect(mockCreateTask).toHaveBeenCalledWith({ + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "新品发布会短视频", + aspectRatio: "16:9", + resolution: "720p", + duration: 15, + imageUrl: "material://start", + endImageUrl: "material://end", + }); + expect(messages[0]?.taskPreview).toMatchObject({ + kind: "video_generate", + taskId: "task-video-new", + status: "running", + phase: "queued", + videoUrl: null, + statusMessage: "视频任务已重新提交,工作区会继续同步最新状态。", + }); + expect(toast.success).toHaveBeenCalledWith("已重新提交视频任务"); + }); + + it("应响应聊天区视频任务卡的取消动作,并立即回写已取消状态", async () => { + let messages: Message[] = [buildRunningVideoMessage()]; + const setChatMessages: HookProps["setChatMessages"] = (value) => { + messages = typeof value === "function" ? value(messages) : value; + }; + + mockCancelTask.mockResolvedValue({ + id: "task-video-running", + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "新品发布会短视频", + status: "cancelled", + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const harness = renderHook({ + projectId: "project-video-1", + contentId: "content-video-1", + setChatMessages, + }); + + await harness.render(); + await act(async () => { + emitVideoWorkbenchTaskAction({ + action: "cancel", + taskId: "task-video-running", + projectId: "project-video-1", + contentId: "content-video-1", + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockCancelTask).toHaveBeenCalledWith("task-video-running"); + expect(messages[0]?.taskPreview).toMatchObject({ + kind: "video_generate", + taskId: "task-video-running", + status: "cancelled", + phase: null, + statusMessage: "视频任务已取消,当前不会继续生成新的结果。", + }); + expect(toast.success).toHaveBeenCalledWith("已提交取消请求"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceVideoTaskActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceVideoTaskActionRuntime.ts new file mode 100644 index 000000000..6f1aa1eba --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceVideoTaskActionRuntime.ts @@ -0,0 +1,254 @@ +import { + useCallback, + useEffect, + type Dispatch, + type SetStateAction, +} from "react"; +import { toast } from "sonner"; +import { + type CreateVideoGenerationRequest, + videoGenerationApi, +} from "@/lib/api/videoGeneration"; +import { onVideoWorkbenchTaskAction } from "@/lib/videoWorkbenchEvents"; +import type { Message, MessageVideoTaskPreview } from "../types"; +import { + buildVideoPreviewFromTask, + clampVideoTaskProgress, + resolveVideoPreviewPhase, + resolveVideoPreviewStatus, +} from "./videoTaskPreviewState"; + +interface UseWorkspaceVideoTaskActionRuntimeParams { + projectId?: string | null; + contentId?: string | null; + setChatMessages: Dispatch>; +} + +function parseTaskRequestPayload( + requestPayload?: string | null, +): Partial | null { + const normalizedPayload = requestPayload?.trim(); + if (!normalizedPayload) { + return null; + } + + try { + const parsed = JSON.parse(normalizedPayload) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Partial; + } catch { + return null; + } +} + +function buildRetryRequest( + task: Awaited>, + preview?: MessageVideoTaskPreview, +): CreateVideoGenerationRequest | null { + if (!task) { + return null; + } + + const payload = parseTaskRequestPayload(task.requestPayload); + const projectId = payload?.projectId || task.projectId; + const providerId = payload?.providerId || task.providerId; + const model = payload?.model || task.model; + const prompt = payload?.prompt || task.prompt; + + if (!projectId || !providerId || !model || !prompt?.trim()) { + return null; + } + + return { + projectId, + providerId, + model, + prompt: prompt.trim(), + aspectRatio: payload?.aspectRatio || preview?.aspectRatio, + resolution: payload?.resolution || preview?.resolution, + duration: payload?.duration || preview?.durationSeconds, + imageUrl: payload?.imageUrl, + endImageUrl: payload?.endImageUrl, + seed: payload?.seed, + generateAudio: payload?.generateAudio, + cameraFixed: payload?.cameraFixed, + }; +} + +function matchesTaskActionContext(params: { + detailProjectId?: string | null; + detailContentId?: string | null; + projectId?: string | null; + contentId?: string | null; +}): boolean { + if ( + params.detailProjectId && + params.projectId && + params.detailProjectId !== params.projectId + ) { + return false; + } + + if ( + params.detailContentId && + params.contentId && + params.detailContentId !== params.contentId + ) { + return false; + } + + return true; +} + +function updateVideoPreviewByTaskId( + previous: Message[], + taskId: string, + updater: (preview: MessageVideoTaskPreview) => MessageVideoTaskPreview, +): Message[] { + let changed = false; + const nextMessages = previous.map((message) => { + const preview = message.taskPreview; + if (preview?.kind !== "video_generate" || preview.taskId !== taskId) { + return message; + } + + changed = true; + return { + ...message, + taskPreview: updater(preview), + }; + }); + + return changed ? nextMessages : previous; +} + +export function useWorkspaceVideoTaskActionRuntime({ + projectId, + contentId, + setChatMessages, +}: UseWorkspaceVideoTaskActionRuntimeParams) { + const handleRetryVideoTask = useCallback( + async (taskId: string) => { + const normalizedTaskId = taskId.trim(); + if (!normalizedTaskId) { + toast.error("缺少视频任务 ID,暂时无法重新生成"); + return false; + } + + const originalTask = await videoGenerationApi.getTask(normalizedTaskId, { + refreshStatus: false, + }); + if (!originalTask) { + toast.error("未找到原视频任务,暂时无法重新生成"); + return false; + } + + let sourcePreview: MessageVideoTaskPreview | null = null; + setChatMessages((previous) => { + previous.some((message) => { + if ( + message.taskPreview?.kind === "video_generate" && + message.taskPreview.taskId === normalizedTaskId + ) { + sourcePreview = message.taskPreview; + return true; + } + return false; + }); + return previous; + }); + + const retryRequest = buildRetryRequest( + originalTask, + sourcePreview || undefined, + ); + if (!retryRequest) { + toast.error("原视频任务缺少必要参数,暂时无法重新生成"); + return false; + } + + try { + const created = await videoGenerationApi.createTask(retryRequest); + setChatMessages((previous) => + updateVideoPreviewByTaskId(previous, normalizedTaskId, (preview) => ({ + ...preview, + taskId: created.id, + prompt: created.prompt?.trim() || preview.prompt, + status: resolveVideoPreviewStatus(created.status), + projectId: created.projectId || preview.projectId || null, + videoUrl: null, + thumbnailUrl: null, + providerId: created.providerId || preview.providerId || null, + model: created.model || preview.model || null, + progress: clampVideoTaskProgress(created.progress), + phase: resolveVideoPreviewPhase(created.status), + statusMessage: "视频任务已重新提交,工作区会继续同步最新状态。", + })), + ); + toast.success("已重新提交视频任务"); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "重新提交视频任务失败", + ); + return false; + } + }, + [setChatMessages], + ); + + const handleCancelVideoTask = useCallback( + async (taskId: string) => { + const normalizedTaskId = taskId.trim(); + if (!normalizedTaskId) { + toast.error("缺少视频任务 ID,暂时无法取消"); + return false; + } + + try { + const cancelledTask = await videoGenerationApi.cancelTask( + normalizedTaskId, + ); + if (cancelledTask) { + setChatMessages((previous) => + updateVideoPreviewByTaskId(previous, normalizedTaskId, (preview) => + buildVideoPreviewFromTask(cancelledTask, preview), + ), + ); + } + toast.success("已提交取消请求"); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "取消视频任务失败", + ); + return false; + } + }, + [setChatMessages], + ); + + useEffect(() => { + return onVideoWorkbenchTaskAction((detail) => { + if ( + !matchesTaskActionContext({ + detailProjectId: detail.projectId, + detailContentId: detail.contentId, + projectId, + contentId, + }) + ) { + return; + } + + if (detail.action === "retry") { + void handleRetryVideoTask(detail.taskId); + return; + } + + void handleCancelVideoTask(detail.taskId); + }); + }, [contentId, handleCancelVideoTask, handleRetryVideoTask, projectId]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.test.tsx index 5cbdc7be3..d2efb2fcd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.test.tsx @@ -140,6 +140,7 @@ describe("useWorkspaceVideoTaskPreviewRuntime", () => { taskPreview: { ...buildVideoMessage().taskPreview!, status: "complete", + videoUrl: "https://example.com/already-synced.mp4", }, }, ], @@ -155,4 +156,54 @@ describe("useWorkspaceVideoTaskPreviewRuntime", () => { expect(mockGetTask).not.toHaveBeenCalled(); }); + + it("历史里的视频任务已完成但尚未带回 videoUrl 时,也应继续回填最终结果", async () => { + let messages: Message[] = [ + { + ...buildVideoMessage(), + taskPreview: { + ...buildVideoMessage().taskPreview!, + status: "complete", + statusMessage: "视频已经生成完成,正在同步最终结果。", + }, + }, + ]; + const setChatMessages: HookProps["setChatMessages"] = (value) => { + messages = typeof value === "function" ? value(messages) : value; + }; + + mockGetTask.mockResolvedValue({ + id: "task-video-1", + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "新品发布会短视频", + status: "success", + progress: 100, + resultUrl: "https://example.com/history-video.mp4", + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const harness = renderHook({ + messages, + setChatMessages, + }); + + await harness.render(); + await act(async () => { + await Promise.resolve(); + }); + + expect(mockGetTask).toHaveBeenCalledWith("task-video-1", { + refreshStatus: true, + }); + expect(messages[0]?.taskPreview).toMatchObject({ + kind: "video_generate", + taskId: "task-video-1", + status: "complete", + videoUrl: "https://example.com/history-video.mp4", + statusMessage: "视频结果已同步,打开查看即可继续预览。", + }); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.ts index 8c3b32ea7..db172cd7b 100644 --- a/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceVideoTaskPreviewRuntime.ts @@ -1,9 +1,10 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"; -import { - videoGenerationApi, - type VideoGenerationTask, -} from "@/lib/api/videoGeneration"; +import { videoGenerationApi } from "@/lib/api/videoGeneration"; import type { Message, MessageVideoTaskPreview } from "../types"; +import { + areVideoPreviewsEqual, + buildVideoPreviewFromTask, +} from "./videoTaskPreviewState"; const VIDEO_TASK_POLL_INTERVAL_MS = 3000; @@ -12,115 +13,28 @@ interface UseWorkspaceVideoTaskPreviewRuntimeParams { setChatMessages: Dispatch>; } -function clampProgress(value?: number | null): number | null { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; +function shouldTrackVideoPreview(preview?: MessageVideoTaskPreview): boolean { + if (!preview) { + return false; } - return Math.max(0, Math.min(100, Math.round(value))); -} -function resolvePreviewStatus( - status: VideoGenerationTask["status"], -): MessageVideoTaskPreview["status"] { - switch (status) { - case "success": - return "complete"; - case "error": - return "failed"; - case "cancelled": - return "cancelled"; - case "pending": - case "processing": - default: - return "running"; + if (preview.status === "running") { + return true; } -} -function resolvePreviewPhase( - status: VideoGenerationTask["status"], -): MessageVideoTaskPreview["phase"] { - if (status === "pending") { - return "queued"; - } - if (status === "processing") { - return "running"; - } - return null; -} - -function resolveStatusMessage( - task: VideoGenerationTask, - previewStatus: MessageVideoTaskPreview["status"], - progress: number | null, -): string { - if (previewStatus === "complete") { - return task.resultUrl - ? "视频结果已同步,打开查看即可继续预览。" - : "视频已经生成完成,工作区正在同步最终结果。"; - } - if (previewStatus === "failed") { - return task.errorMessage?.trim() || "视频生成失败,请稍后重试。"; - } - if (previewStatus === "cancelled") { - return "视频任务已取消,当前不会继续生成新的结果。"; - } - if (typeof progress === "number" && progress > 0) { - return `视频正在生成中,当前进度约 ${progress}%。`; - } - if (task.status === "pending") { - return "视频任务已进入排队队列,稍后会自动开始生成。"; - } - return "视频任务正在生成中,工作区会继续同步最新状态。"; -} - -function buildPreviewFromTask( - task: VideoGenerationTask, - currentPreview: MessageVideoTaskPreview, -): MessageVideoTaskPreview { - const nextStatus = resolvePreviewStatus(task.status); - const progress = clampProgress(task.progress); - - return { - ...currentPreview, - kind: "video_generate", - taskId: task.id, - taskType: "video_generate", - prompt: task.prompt?.trim() || currentPreview.prompt, - status: nextStatus, - projectId: task.projectId || currentPreview.projectId || null, - videoUrl: task.resultUrl || currentPreview.videoUrl || null, - providerId: task.providerId || currentPreview.providerId || null, - model: task.model || currentPreview.model || null, - progress, - phase: resolvePreviewPhase(task.status), - statusMessage: resolveStatusMessage(task, nextStatus, progress), - }; -} - -function areVideoPreviewsEqual( - previous: MessageVideoTaskPreview, - next: MessageVideoTaskPreview, -): boolean { return ( - previous.prompt === next.prompt && - previous.status === next.status && - previous.projectId === next.projectId && - previous.videoUrl === next.videoUrl && - previous.providerId === next.providerId && - previous.model === next.model && - previous.progress === next.progress && - previous.phase === next.phase && - previous.statusMessage === next.statusMessage + (preview.status === "complete" || preview.status === "partial") && + !preview.videoUrl?.trim() ); } function collectTrackedTaskIds(messages: Message[]): string[] { const taskIds = new Set(); messages.forEach((message) => { - if ( - message.taskPreview?.kind !== "video_generate" || - message.taskPreview.status !== "running" - ) { + if (message.taskPreview?.kind !== "video_generate") { + return; + } + if (!shouldTrackVideoPreview(message.taskPreview)) { return; } const taskId = message.taskPreview.taskId.trim(); @@ -182,7 +96,7 @@ export function useWorkspaceVideoTaskPreviewRuntime({ return message; } - const nextPreview = buildPreviewFromTask(task, currentPreview); + const nextPreview = buildVideoPreviewFromTask(task, currentPreview); if (areVideoPreviewsEqual(currentPreview, nextPreview)) { return message; } diff --git a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.test.tsx b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.test.tsx index 2a287d585..90b654cdb 100644 --- a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.test.tsx @@ -151,6 +151,58 @@ describe("useWorkspaceWriteFileAction", () => { expect(setLayoutMode).not.toHaveBeenCalled(); }); + it("tool_result 来源的通用产物不应自动选中或展开工作台", async () => { + const setSelectedArtifactId = vi.fn(); + const setArtifactViewMode = vi.fn(); + const setLayoutMode = vi.fn(); + const { render, getValue } = renderHook({ + setSelectedArtifactId, + setArtifactViewMode, + setLayoutMode, + suppressCanvasAutoOpen: false, + }); + + await render(); + + act(() => { + getValue()("## 结果摘要", "summary.md", { + source: "tool_result", + status: "complete", + metadata: { + writePhase: "completed", + }, + }); + }); + + expect(setSelectedArtifactId).not.toHaveBeenCalled(); + expect(setArtifactViewMode).not.toHaveBeenCalled(); + expect(setLayoutMode).not.toHaveBeenCalled(); + }); + + it("空内容的图片 tool_result 不应进入通用 artifact 工作台", async () => { + const upsertGeneralArtifact = vi.fn(); + const setSelectedArtifactId = vi.fn(); + const { render, getValue } = renderHook({ + upsertGeneralArtifact, + setSelectedArtifactId, + }); + + await render(); + + act(() => { + getValue()("", "output_image.jpg", { + source: "tool_result", + status: "complete", + metadata: { + writePhase: "completed", + }, + }); + }); + + expect(upsertGeneralArtifact).not.toHaveBeenCalled(); + expect(setSelectedArtifactId).not.toHaveBeenCalled(); + }); + it("进行中的流式写入仍应自动展开画布,便于实时查看输出", async () => { const setArtifactViewMode = vi.fn(); const setLayoutMode = vi.fn(); diff --git a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts index cbc2f652e..fd6b64ade 100644 --- a/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts +++ b/src/components/agent/chat/workspace/useWorkspaceWriteFileAction.ts @@ -8,7 +8,11 @@ import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas"; import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; import { activityLogger } from "@/lib/workspace/workbenchRuntime"; import type { ThemeType, LayoutMode } from "@/lib/workspace/workbenchContract"; -import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol"; +import { + areArtifactProtocolPathsEquivalent, + isArtifactProtocolImagePath, + resolveArtifactProtocolFilePath, +} from "@/lib/artifact-protocol"; import type { TaskFile } from "../components/TaskFiles"; import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; import type { WriteArtifactContext } from "../types"; @@ -40,6 +44,25 @@ function shouldAutoOpenCanvasForActiveWrite( ); } +function shouldBackgroundGeneralArtifactWrite( + context?: WriteArtifactContext, +): boolean { + return context?.source === "tool_result"; +} + +function shouldSkipGeneralArtifactWrite(params: { + fileName: string; + content: string; + context?: WriteArtifactContext; +}): boolean { + return ( + params.content.length === 0 && + isArtifactProtocolImagePath(params.fileName) && + (params.context?.source === "tool_result" || + params.context?.source === "artifact_snapshot") + ); +} + function isContentPostDocumentFile(fileName: string, fileType: string): boolean { return fileType === "document" && /^content-posts\/.+\.md$/i.test(fileName); } @@ -178,7 +201,10 @@ export function useWorkspaceWriteFileAction({ return true; } - return resolveArtifactProtocolFilePath(artifact) === fileName; + return areArtifactProtocolPathsEquivalent( + resolveArtifactProtocolFilePath(artifact), + fileName, + ); }); const nextContent = content.length > 0 @@ -212,6 +238,17 @@ export function useWorkspaceWriteFileAction({ (nextContent.length > 0 ? "complete" : "pending"), }, }); + const shouldKeepInBackground = shouldBackgroundGeneralArtifactWrite(context); + + if ( + shouldSkipGeneralArtifactWrite({ + fileName, + content: nextContent, + context, + }) + ) { + return; + } const syncResource = () => { if (nextArtifact.status !== "complete") { @@ -238,21 +275,23 @@ export function useWorkspaceWriteFileAction({ } upsertGeneralArtifact(nextArtifact); - setSelectedArtifactId(nextArtifact.id); - setArtifactViewMode( - resolveDefaultArtifactViewMode(nextArtifact, { - preferSourceWhenStreaming: true, - }), - { artifactId: nextArtifact.id }, - ); - if ( - !suppressCanvasAutoOpen && - shouldAutoOpenCanvasForActiveWrite( - nextArtifact.status, - nextArtifact.meta.writePhase, - ) - ) { - setLayoutMode("chat-canvas"); + if (!shouldKeepInBackground) { + setSelectedArtifactId(nextArtifact.id); + setArtifactViewMode( + resolveDefaultArtifactViewMode(nextArtifact, { + preferSourceWhenStreaming: true, + }), + { artifactId: nextArtifact.id }, + ); + if ( + !suppressCanvasAutoOpen && + shouldAutoOpenCanvasForActiveWrite( + nextArtifact.status, + nextArtifact.meta.writePhase, + ) + ) { + setLayoutMode("chat-canvas"); + } } return; } diff --git a/src/components/agent/chat/workspace/videoTaskPreviewState.ts b/src/components/agent/chat/workspace/videoTaskPreviewState.ts new file mode 100644 index 000000000..1dc0fa7a8 --- /dev/null +++ b/src/components/agent/chat/workspace/videoTaskPreviewState.ts @@ -0,0 +1,105 @@ +import type { VideoGenerationTask } from "@/lib/api/videoGeneration"; +import type { MessageVideoTaskPreview } from "../types"; + +export function clampVideoTaskProgress(value?: number | null): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function resolveVideoPreviewStatus( + status: VideoGenerationTask["status"], +): MessageVideoTaskPreview["status"] { + switch (status) { + case "success": + return "complete"; + case "error": + return "failed"; + case "cancelled": + return "cancelled"; + case "pending": + case "processing": + default: + return "running"; + } +} + +export function resolveVideoPreviewPhase( + status: VideoGenerationTask["status"], +): MessageVideoTaskPreview["phase"] { + if (status === "pending") { + return "queued"; + } + if (status === "processing") { + return "running"; + } + return null; +} + +export function resolveVideoStatusMessage( + task: VideoGenerationTask, + previewStatus: MessageVideoTaskPreview["status"], + progress: number | null, +): string { + if (previewStatus === "complete") { + return task.resultUrl + ? "视频结果已同步,打开查看即可继续预览。" + : "视频已经生成完成,工作区正在同步最终结果。"; + } + if (previewStatus === "failed") { + return task.errorMessage?.trim() || "视频生成失败,请稍后重试。"; + } + if (previewStatus === "cancelled") { + return "视频任务已取消,当前不会继续生成新的结果。"; + } + if (typeof progress === "number" && progress > 0) { + return `视频正在生成中,当前进度约 ${progress}%。`; + } + if (task.status === "pending") { + return "视频任务已进入排队队列,稍后会自动开始生成。"; + } + return "视频任务正在生成中,工作区会继续同步最新状态。"; +} + +export function buildVideoPreviewFromTask( + task: VideoGenerationTask, + currentPreview: MessageVideoTaskPreview, +): MessageVideoTaskPreview { + const nextStatus = resolveVideoPreviewStatus(task.status); + const progress = clampVideoTaskProgress(task.progress); + + return { + ...currentPreview, + kind: "video_generate", + taskId: task.id, + taskType: "video_generate", + prompt: task.prompt?.trim() || currentPreview.prompt, + status: nextStatus, + projectId: task.projectId || currentPreview.projectId || null, + videoUrl: task.resultUrl || currentPreview.videoUrl || null, + providerId: task.providerId || currentPreview.providerId || null, + model: task.model || currentPreview.model || null, + progress, + phase: resolveVideoPreviewPhase(task.status), + statusMessage: resolveVideoStatusMessage(task, nextStatus, progress), + }; +} + +export function areVideoPreviewsEqual( + previous: MessageVideoTaskPreview, + next: MessageVideoTaskPreview, +): boolean { + return ( + previous.prompt === next.prompt && + previous.status === next.status && + previous.projectId === next.projectId && + previous.videoUrl === next.videoUrl && + previous.thumbnailUrl === next.thumbnailUrl && + previous.providerId === next.providerId && + previous.model === next.model && + previous.progress === next.progress && + previous.phase === next.phase && + previous.statusMessage === next.statusMessage + ); +} diff --git a/src/components/agent/chat/workspace/workspaceSendHelpers.ts b/src/components/agent/chat/workspace/workspaceSendHelpers.ts index 7e43ce459..36ce90e6a 100644 --- a/src/components/agent/chat/workspace/workspaceSendHelpers.ts +++ b/src/components/agent/chat/workspace/workspaceSendHelpers.ts @@ -5,7 +5,7 @@ import { } from "../utils/harnessRequestMetadata"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { MessageImage } from "../types"; +import type { BrowserTaskRequirement, MessageImage } from "../types"; import type { Character } from "@/lib/api/memory"; import { buildTeamMemoryShadowRequestMetadata, @@ -64,6 +64,20 @@ function readMetadataText( : undefined; } +function readExistingBrowserRequirement( + metadata: Record | undefined, +): BrowserTaskRequirement | undefined { + const requirement = readMetadataText(metadata, "browser_requirement"); + if ( + requirement === "optional" || + requirement === "required" || + requirement === "required_with_user_step" + ) { + return requirement; + } + return undefined; +} + function readExistingTeamSource( metadata: Record | undefined, ): TeamDefinitionSource | undefined { @@ -231,7 +245,7 @@ function applyActiveContextPrompt( return text; } - const slashCommandMatch = text.match(/^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/); + const slashCommandMatch = text.match(/^\/([a-zA-Z0-9_-]+)(?:\s+([\s\S]*))?$/); if (slashCommandMatch) { const [, skillName, skillArgs] = slashCommandMatch; const mergedArgs = [activeContextPrompt, skillArgs.trim()] @@ -362,6 +376,12 @@ export function hasModelSkillLaunchRequestMetadata( "urlParseSkillLaunch", "typesetting_skill_launch", "typesettingSkillLaunch", + "presentation_skill_launch", + "presentationSkillLaunch", + "form_skill_launch", + "formSkillLaunch", + "webpage_skill_launch", + "webpageSkillLaunch", "service_scene_launch", "serviceSceneLaunch", ] as const; @@ -467,6 +487,15 @@ export function buildWorkspaceRequestMetadata( const resolvedTeamMemoryShadow = buildTeamMemoryShadowRequestMetadata(teamMemoryShadowSnapshot) || readExistingTeamMemoryShadow(existingHarnessMetadata); + const resolvedBrowserRequirement = + browserRequirementMatch?.requirement || + readExistingBrowserRequirement(existingHarnessMetadata); + const resolvedBrowserRequirementReason = + browserRequirementMatch?.reason || + readMetadataText(existingHarnessMetadata, "browser_requirement_reason"); + const resolvedBrowserLaunchUrl = + browserRequirementMatch?.launchUrl || + readMetadataText(existingHarnessMetadata, "browser_launch_url"); return { ...(workspaceRequestMetadataBase || {}), @@ -486,9 +515,9 @@ export function buildWorkspaceRequestMetadata( gateKey: isThemeWorkbench ? currentGateKey : undefined, runTitle: themeWorkbenchActiveQueueTitle?.trim() || undefined, contentId: contentId || undefined, - browserRequirement: browserRequirementMatch?.requirement, - browserRequirementReason: browserRequirementMatch?.reason, - browserLaunchUrl: browserRequirementMatch?.launchUrl, + browserRequirement: resolvedBrowserRequirement, + browserRequirementReason: resolvedBrowserRequirementReason, + browserLaunchUrl: resolvedBrowserLaunchUrl, browserAssistProfileKey: mappedTheme === "general" ? browserAssistProfileKey || GENERAL_BROWSER_ASSIST_PROFILE_KEY diff --git a/src/components/agent/chat/workspaceEntry.ts b/src/components/agent/chat/workspaceEntry.ts index 35847db76..921105cf2 100644 --- a/src/components/agent/chat/workspaceEntry.ts +++ b/src/components/agent/chat/workspaceEntry.ts @@ -26,6 +26,7 @@ export interface AgentChatWorkspaceBootstrap { export interface WorkspaceEntryPayload { prompt?: string; images?: MessageImage[]; + projectId?: string; contentId?: string; initialRequestMetadata?: Record; initialAutoSendRequestMetadata?: Record; @@ -107,6 +108,7 @@ export function resolveWorkspaceEntry( const hasPrompt = Boolean(payload.prompt?.trim()); const hasImages = Boolean(payload.images?.length); + const resolvedProjectId = payload.projectId?.trim() || projectId || undefined; const hasContentId = Boolean(payload.contentId?.trim()); const hasSiteSkillLaunch = Boolean( payload.initialSiteSkillLaunch?.adapterName?.trim(), @@ -122,7 +124,7 @@ export function resolveWorkspaceEntry( const openBrowserAssistOnMount = payload.openBrowserAssistOnMount; const autoRunInitialPromptOnMount = payload.autoRunInitialPromptOnMount; - if (!openBrowserAssistOnMount && !hasSiteSkillLaunch && !projectId) { + if (!openBrowserAssistOnMount && !hasSiteSkillLaunch && !resolvedProjectId) { return { ok: false, reason: "missing_project", @@ -144,7 +146,7 @@ export function resolveWorkspaceEntry( const nextNewChatAt = now(); const shared = { - projectId: projectId ?? undefined, + projectId: resolvedProjectId, contentId: payload.contentId, theme: targetTheme, lockTheme, @@ -175,7 +177,7 @@ export function resolveWorkspaceEntry( nextNewChatAt, navigationParams: buildClawAgentParams(shared), workspaceBootstrap: { - projectId: projectId ?? undefined, + projectId: resolvedProjectId, contentId: payload.contentId, initialUserPrompt: payload.prompt, initialUserImages: payload.images, diff --git a/src/components/channels/ImConfigPage.test.tsx b/src/components/channels/ImConfigPage.test.tsx index 91b282f13..98615a457 100644 --- a/src/components/channels/ImConfigPage.test.tsx +++ b/src/components/channels/ImConfigPage.test.tsx @@ -272,7 +272,7 @@ describe("ImConfigPage", () => { await flushEffects(); const text = document.body.textContent ?? ""; - expect(text).toContain("IM 配置"); + expect(text).toContain("消息渠道"); expect(text).toContain("Telegram"); expect(text).toContain("飞书"); expect(text).toContain("微信"); @@ -292,7 +292,7 @@ describe("ImConfigPage", () => { ); expect(getBodyText()).not.toContain("网关、日志和运行状态都收在这里。"); - const introTip = await hoverTip("IM 配置说明"); + const introTip = await hoverTip("消息渠道说明"); expect(getBodyText()).toContain( "首页只放重点入口。Telegram、飞书、微信在这里直达;联调检查放进各自配置弹窗,网关和日志统一收到下方高级区。", ); diff --git a/src/components/channels/ImConfigPage.tsx b/src/components/channels/ImConfigPage.tsx index 928f01b66..c54fb58a0 100644 --- a/src/components/channels/ImConfigPage.tsx +++ b/src/components/channels/ImConfigPage.tsx @@ -2370,7 +2370,7 @@ export function ImConfigPage() {

- 能力 / IM 配置 + 能力 / 消息渠道
@@ -2378,11 +2378,14 @@ export function ImConfigPage() { 把常用 IM 接到 Lime
+

+ 让 Telegram、飞书、微信等外部客户端也能连到 Lime。 +

diff --git a/src/components/image-gen/useImageGen.ts b/src/components/image-gen/useImageGen.ts index 76596ef38..021f7a803 100644 --- a/src/components/image-gen/useImageGen.ts +++ b/src/components/image-gen/useImageGen.ts @@ -14,6 +14,7 @@ import { import { getImageModelsForProvider } from "@/lib/imageGeneration"; import { isDebugFlagEnabled } from "@/lib/perfDebug"; import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection"; +import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import type { GeneratedImage, ImageGenRequest, @@ -60,6 +61,8 @@ interface UseImageGenOptions { preferredProviderId?: string; preferredModelId?: string; allowFallback?: boolean; + providerLoadMode?: "immediate" | "deferred"; + providerDeferredDelayMs?: number; } const IMAGE_REQUEST_TIMEOUT_MS = 180_000; @@ -70,6 +73,7 @@ const FAL_QUEUE_TIMEOUT_MS = 180_000; const IMAGE_GEN_MATERIAL_TAG = "image-gen"; const IMAGE_MATERIAL_NAME_MAX_LENGTH = 48; export const IMAGE_GENERATION_CANCELED_MESSAGE = "已停止当前图片任务"; +const IMAGE_GEN_PROVIDER_IDLE_TIMEOUT_MS = 1_500; function imageGenDebugLog(...args: unknown[]): void { if (!isDebugFlagEnabled(PROVIDER_DEBUG_KEY)) { @@ -2030,7 +2034,15 @@ function isFalProviderLike(provider: { } export function useImageGen(options: UseImageGenOptions = {}) { - const { providers, loading: providersLoading } = useApiKeyProvider(); + const providerLoadMode = options.providerLoadMode ?? "immediate"; + const providerDeferredDelayMs = + options.providerDeferredDelayMs ?? IMAGE_GEN_PROVIDER_IDLE_TIMEOUT_MS; + const [providerLoadReady, setProviderLoadReady] = useState( + providerLoadMode !== "deferred", + ); + const { providers, loading: providersLoading } = useApiKeyProvider({ + autoLoad: providerLoadReady, + }); const preferredProviderId = options.preferredProviderId?.trim() || ""; const preferredModelId = options.preferredModelId?.trim() || ""; const allowFallback = options.allowFallback ?? true; @@ -2049,6 +2061,27 @@ export function useImageGen(options: UseImageGenOptions = {}) { const syncedPreferredModelIdRef = useRef(""); const hasManualProviderSelectionRef = useRef(false); + useEffect(() => { + if (providerLoadMode !== "deferred") { + setProviderLoadReady(true); + return; + } + + if (providerLoadReady) { + return; + } + + return scheduleMinimumDelayIdleTask( + () => { + setProviderLoadReady(true); + }, + { + minimumDelayMs: providerDeferredDelayMs, + idleTimeoutMs: IMAGE_GEN_PROVIDER_IDLE_TIMEOUT_MS, + }, + ); + }, [providerDeferredDelayMs, providerLoadMode, providerLoadReady]); + // 过滤出支持图片生成、启用且有 API Key 的 Provider const availableProviders = useMemo(() => { imageGenDebugLog( diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index 63b97946c..8f7e6d8cf 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -168,19 +168,19 @@ describe("MemoryPage", () => { cleanupMountedRoots(mountedRoots); }); - it("应把导航说明和首页说明收进 tips", async () => { + it("应把灵感库导航说明和总览说明收进 tips", async () => { renderPage(); await flushPageEffects(); expect(getBodyText()).not.toContain("按 / 搜索,按 1-6 切换视图。"); - expect(getBodyText()).not.toContain("查看全部记忆并触发分析任务"); + expect(getBodyText()).not.toContain("查看已沉淀的风格、参考、成果与偏好"); - const navTip = await hoverTip("记忆导航说明"); + const navTip = await hoverTip("灵感库导航说明"); expect(getBodyText()).toContain("按 / 搜索,按 1-6 切换视图。"); await leaveTip(navTip); - const heroTip = await hoverTip("记忆首页说明"); - expect(getBodyText()).toContain("查看全部记忆并触发分析任务"); + const heroTip = await hoverTip("灵感总览说明"); + expect(getBodyText()).toContain("查看已沉淀的风格、参考、成果与偏好"); await leaveTip(heroTip); }); }); diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 0e132723a..0c3c59f05 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -120,28 +120,28 @@ const CATEGORY_META: Record< { label: string; description: string; icon: LucideIcon } > = { identity: { - label: "身份", - description: "关于你是谁的稳定信息", + label: "风格", + description: "常用语气、风格方向与稳定审美", icon: Signature, }, context: { - label: "情境", - description: "对话背景与当前约束", + label: "参考", + description: "值得反复参考的背景、约束与素材线索", icon: MessagesSquare, }, preference: { label: "偏好", - description: "你的习惯、口味与偏爱", + description: "常用习惯、口味与输出偏好", icon: HeartPulse, }, experience: { - label: "经验", - description: "过往经历与可复用知识", + label: "成果", + description: "已验证可复用的结果与经验", icon: Lightbulb, }, activity: { - label: "活动", - description: "近期计划与进行中的事项", + label: "收藏", + description: "先留住准备回头再用的内容", icon: CalendarClock, }, }; @@ -162,19 +162,19 @@ const MEMORY_NAV_ITEMS: Array<{ }> = [ { key: "home", - label: "首页", + label: "总览", icon: Home, - description: "全部记忆", + description: "全部灵感", }, { key: "identity", - label: "身份", + label: CATEGORY_META.identity.label, icon: CATEGORY_META.identity.icon, description: CATEGORY_META.identity.description, }, { key: "context", - label: "情境", + label: CATEGORY_META.context.label, icon: CATEGORY_META.context.icon, description: CATEGORY_META.context.description, }, @@ -186,13 +186,13 @@ const MEMORY_NAV_ITEMS: Array<{ }, { key: "experience", - label: "经验", + label: CATEGORY_META.experience.label, icon: CATEGORY_META.experience.icon, description: CATEGORY_META.experience.description, }, { key: "activity", - label: "活动", + label: CATEGORY_META.activity.label, icon: CATEGORY_META.activity.icon, description: CATEGORY_META.activity.description, }, @@ -481,9 +481,9 @@ function EmptyMemoryState({
-

暂无记忆

+

暂无灵感沉淀

- 记忆提取是渐进式能力。积累更多真实对话后,系统会抽取并沉淀更稳定的可用信息。 + 沉淀是渐进式能力。积累更多真实对话、参考和成果后,系统会逐步整理出更稳定、可复用的内容。

@@ -521,7 +521,7 @@ function MemoryEntryCollection({ if (entries.length === 0) { return (
- 当前筛选条件下暂无记忆条目 + 当前筛选条件下暂无沉淀条目
); } @@ -661,7 +661,7 @@ function MemoryDetailPanel({ if (!entry) { return (
- 请选择一条记忆查看详情 + 请选择一条沉淀查看详情
); } @@ -684,7 +684,7 @@ function MemoryDetailPanel({
-
记忆标题
+
条目标题
{entry.title}
@@ -760,7 +760,7 @@ function MemoryDetailPanel({ ) : ( <> - 删除记忆(不可恢复) + 删除条目(不可恢复) )} @@ -974,7 +974,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { await Promise.all([loadConfig(), loadOverview()]); } catch (error) { console.error("加载记忆数据失败:", error); - showMessage("error", "加载记忆失败,请稍后重试"); + showMessage("error", "加载灵感库失败,请稍后重试"); } finally { setLoading(false); } @@ -1062,7 +1062,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { const hasOutline = (projectMemory?.outline.length ?? 0) > 0; if (hasCharacters && hasWorldBuilding && hasOutline) { - showMessage("success", "第三层项目记忆已完善"); + showMessage("success", "第三层项目沉淀已完善"); return; } @@ -1104,10 +1104,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { } await loadOverview(); - showMessage("success", "已初始化第三层项目记忆,请按需继续完善"); + showMessage("success", "已初始化第三层项目沉淀,请按需继续完善"); } catch (error) { console.error("初始化项目记忆失败:", error); - showMessage("error", "初始化项目记忆失败,请稍后重试"); + showMessage("error", "初始化项目沉淀失败,请稍后重试"); } finally { setInitializingProjectMemory(false); } @@ -1132,7 +1132,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { } if (!memoryConfig.enabled) { - showMessage("error", "记忆功能已关闭,请先开启"); + showMessage("error", "沉淀功能已关闭,请先开启"); return; } @@ -1148,14 +1148,14 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { if (result.generated_entries > 0) { showMessage( "success", - `分析完成:新增 ${result.generated_entries} 条记忆(去重 ${result.deduplicated_entries} 条)`, + `整理完成:新增 ${result.generated_entries} 条沉淀(去重 ${result.deduplicated_entries} 条)`, ); } else { - showMessage("success", "分析完成:暂无新的可提取记忆"); + showMessage("success", "整理完成:暂无新的可提取沉淀"); } } catch (error) { console.error("记忆分析失败:", error); - showMessage("error", "记忆分析失败,请稍后重试"); + showMessage("error", "灵感整理失败,请稍后重试"); } finally { setAnalyzing(false); } @@ -1170,7 +1170,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { const handleDeleteEntry = useCallback( async (entry: MemoryEntryPreview) => { const confirmed = window.confirm( - `确定永久删除这条记忆吗?\n\n标题:${entry.title}\n\n该操作不可恢复。`, + `确定永久删除这条沉淀吗?\n\n标题:${entry.title}\n\n该操作不可恢复。`, ); if (!confirmed) { return; @@ -1180,12 +1180,12 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { try { const deleted = await deleteUnifiedMemory(entry.id); if (!deleted) { - showMessage("error", "删除失败,记忆可能不存在"); + showMessage("error", "删除失败,条目可能不存在"); return; } await loadOverview(); - showMessage("success", "记忆已删除"); + showMessage("success", "条目已删除"); } catch (error) { console.error("删除记忆失败:", error); showMessage("error", "删除失败,请稍后重试"); @@ -1218,10 +1218,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { await saveConfig(nextConfig); setConfig(nextConfig); setMemoryConfig(nextMemoryConfig); - showMessage("success", "记忆设置已保存"); + showMessage("success", "灵感设置已保存"); } catch (error) { console.error("保存记忆设置失败:", error); - showMessage("error", "记忆设置保存失败"); + showMessage("error", "灵感设置保存失败"); } finally { setSaving(false); } @@ -1231,12 +1231,12 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { const sectionTitle = activeSection === "home" - ? "记忆首页" - : `${CATEGORY_META[activeSection].label}记忆`; + ? "灵感总览" + : CATEGORY_META[activeSection].label; const sectionDescription = activeSection === "home" - ? "查看全部记忆并触发分析任务" + ? "查看已沉淀的风格、参考、成果与偏好" : CATEGORY_META[activeSection].description; const activeSectionCount = @@ -1253,7 +1253,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { return (
- +
@@ -1261,9 +1261,9 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
- 记忆 + 灵感库 @@ -1317,10 +1317,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
- 统一记忆数据库 + 当前沉淀数据库
- MEMORY HUB + 灵感沉淀
@@ -1373,8 +1373,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ariaLabel={`${sectionTitle}说明`} content={`${sectionDescription}${ activeSection === "home" - ? " 统一记忆、上下文记忆和项目记忆会在这里汇总展示。" - : " 这里会聚焦当前分类下的真实记忆条目与分析结果。" + ? " 风格、收藏、参考、成果与偏好会在这里汇总展示。" + : " 这里会聚焦当前分类下可反复参考的沉淀条目与整理结果。" }`} tone="mint" /> @@ -1385,7 +1385,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { - {memoryConfig.enabled ? "记忆已启用" : "记忆已关闭"} + {memoryConfig.enabled ? "沉淀已启用" : "沉淀已暂停"} 当前范围 {analysisScopeLabel} @@ -1401,24 +1401,24 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
@@ -1440,8 +1440,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
- 记忆设置 + 灵感设置 @@ -1498,8 +1498,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
@@ -1648,8 +1648,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { tone={memoryConfig.enabled ? "success" : "warning"} > {memoryConfig.enabled - ? "当前允许分析" - : "请先开启记忆"} + ? "当前允许整理" + : "请先开启沉淀"} 分析范围 {analysisScopeLabel} @@ -1671,7 +1671,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ) : ( <> - 立即分析 + 立即整理 )} @@ -1693,7 +1693,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { {analysisResult ? (
- 最近一次分析结果 + 最近一次整理结果
@@ -1709,7 +1709,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {

-

新增记忆

+

新增沉淀

{analysisResult.generated_entries}

@@ -1724,7 +1724,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
) : (
- 还没有最近一次分析结果。未选择日期时,会分析全部可用历史对话。 + 还没有最近一次整理结果。未选择日期时,会整理全部可用历史对话。
)}
@@ -1776,8 +1776,8 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ))}
@@ -471,13 +474,16 @@ export function ImageGenSettings() { )} >
{size.label}
-
- -
+

+ {size.desc} +

))}
@@ -512,15 +518,16 @@ export function ImageGenSettings() { )} >
{quality.label}
-
- -
+

+ {quality.desc} +

))}
@@ -555,13 +562,16 @@ export function ImageGenSettings() { )} >
{style.label}
-
- -
+

+ {style.desc} +

))}
diff --git a/src/components/settings-v2/agent/providers/index.test.tsx b/src/components/settings-v2/agent/providers/index.test.tsx index 09e0b082a..a2e805c5d 100644 --- a/src/components/settings-v2/agent/providers/index.test.tsx +++ b/src/components/settings-v2/agent/providers/index.test.tsx @@ -684,10 +684,16 @@ describe("CloudProviderSettings", () => { container.querySelector('[data-testid="companion-provider-card"]'), ).not.toBeNull(); expect(text).toContain("Lime Pet Companion"); - expect(text).toContain( + + const introTip = await hoverTip("桌宠 Companion 说明"); + expect(getBodyText()).toContain( "桌宠通过本地 Companion 通道复用 Lime 的 AI 服务商状态", ); - expect(text).toContain("不会直接读取 API Key、OAuth 凭证或本地凭证文件"); + expect(getBodyText()).toContain( + "不会直接读取 API Key、OAuth 凭证或本地凭证文件", + ); + await leaveTip(introTip); + expect(text).toContain("桌宠已连接"); expect(text).toContain("Provider 概览"); expect(text).toContain("桌宠视角预览"); @@ -878,6 +884,9 @@ describe("CloudProviderSettings", () => { expect(syncButton?.disabled).toBe(true); expect(text).toContain("能力未声明"); expect(text).toContain("当前桌宠已连接,但尚未声明 Provider 概览能力"); - expect(text).toContain("平台:Windows"); + + const identityTip = await hoverTip("桌宠身份说明"); + expect(getBodyText()).toContain("平台:Windows"); + await leaveTip(identityTip); }); }); diff --git a/src/components/settings-v2/agent/providers/index.tsx b/src/components/settings-v2/agent/providers/index.tsx index cbd068b66..d04f7ca37 100644 --- a/src/components/settings-v2/agent/providers/index.tsx +++ b/src/components/settings-v2/agent/providers/index.tsx @@ -563,7 +563,7 @@ function CompanionProviderBridgeCard() { detail: supportsProviderOverview ? "桌宠已声明可接收 provider-overview 摘要。" : connected - ? "当前桌宠已连接,但尚未声明 provider-overview 能力。" + ? "当前桌宠已连接,但尚未声明 Provider 概览能力。" : "桌宠连接建立后,Lime 会等待能力声明。", }, { @@ -590,7 +590,7 @@ function CompanionProviderBridgeCard() { return "点击“开启桌宠”,或检查桌宠是否已连接到本地 Companion 地址。"; } if (!supportsProviderOverview) { - return "先让桌宠在 ready 事件里声明 provider-overview 能力,再尝试同步摘要。"; + return "先让桌宠在 ready 事件里声明 Provider 概览能力,再尝试同步摘要。"; } if (previewLoading) { return "等待 Lime 整理完当前 Provider 脱敏摘要。"; diff --git a/src/components/settings-v2/general/appearance/index.test.tsx b/src/components/settings-v2/general/appearance/index.test.tsx index 08b380bf3..fed02026e 100644 --- a/src/components/settings-v2/general/appearance/index.test.tsx +++ b/src/components/settings-v2/general/appearance/index.test.tsx @@ -168,11 +168,11 @@ describe("AppearanceSettings", () => { expect(text).toContain("左侧边栏导航"); expect(text).toContain("主导航入口"); expect(text).toContain("系统入口"); - expect(text).toContain("核心入口固定显示:新建任务、任务中心、技能、自动化、IM 配置"); + expect(text).toContain("核心入口固定显示:新建任务、任务中心、技能、消息渠道、资料库、灵感库"); expect(text).toContain("推荐行为"); expect(text).toContain("OpenClaw"); expect(text).toContain("资料库"); - expect(text).toContain("记忆"); + expect(text).toContain("灵感库"); expect(text).toContain("推荐自动附带选中内容"); expect(text).toContain("重新运行引导"); expect(text).not.toContain("已合并旧入口"); @@ -182,7 +182,7 @@ describe("AppearanceSettings", () => { it("切换底部入口时应保留 workspace_preferences 的其他配置", async () => { const { container } = await renderPage(); const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("资料库"), + (item) => item.textContent?.includes("工具箱"), ); await act(async () => { @@ -192,11 +192,7 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.navigation.enabled_items).toEqual([ - "video", - "image-gen", - "terminal", - ]); + expect(savedConfig.navigation.enabled_items).toEqual(["terminal", "tools"]); expect( savedConfig.workspace_preferences.media_defaults.voice.preferredProviderId, ).toBe("openai"); @@ -205,7 +201,7 @@ describe("AppearanceSettings", () => { it("切换底部入口时应保存完整的侧栏导航配置", async () => { const { container } = await renderPage(); const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("资料库"), + (item) => item.textContent?.includes("工具箱"), ); await act(async () => { @@ -215,11 +211,7 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.navigation.enabled_items).toEqual([ - "video", - "image-gen", - "terminal", - ]); + expect(savedConfig.navigation.enabled_items).toEqual(["terminal", "tools"]); }); it("缺少导航配置时应回退到底部默认入口", async () => { @@ -242,13 +234,7 @@ describe("AppearanceSettings", () => { const savedConfig = mockSaveConfig.mock.calls.at(-1)?.[0] as any; - expect(savedConfig.navigation.enabled_items).toEqual([ - "video", - "image-gen", - "resources", - "terminal", - "tools", - ]); + expect(savedConfig.navigation.enabled_items).toEqual(["terminal", "tools"]); }); it("应把首屏和基础外观说明收进 tips", async () => { diff --git a/src/components/skills/SkillsWorkspacePage.test.tsx b/src/components/skills/SkillsWorkspacePage.test.tsx index a08d3327a..4cb452719 100644 --- a/src/components/skills/SkillsWorkspacePage.test.tsx +++ b/src/components/skills/SkillsWorkspacePage.test.tsx @@ -264,7 +264,7 @@ describe("SkillsWorkspacePage", () => { "技能中心现在先展示技能组,再进入具体技能项。", ); expect(getBodyText()).not.toContain( - "先定位技能组,再进入具体技能;本地和项目级补充能力仍收纳在高级管理中。", + "先从能直接开工的技能组找起;本地导入、仓库维护和远程安装统一收进导入与维护。", ); const entryTip = await hoverTip("技能主入口说明"); @@ -275,7 +275,7 @@ describe("SkillsWorkspacePage", () => { const searchTip = await hoverTip("技能搜索说明"); expect(getBodyText()).toContain( - "先定位技能组,再进入具体技能;本地和项目级补充能力仍收纳在高级管理中。", + "先从能直接开工的技能组找起;本地导入、仓库维护和远程安装统一收进导入与维护。", ); await leaveTip(searchTip); }); @@ -326,7 +326,7 @@ describe("SkillsWorkspacePage", () => { expect(mockRefreshLocalSkills).toHaveBeenCalledTimes(1); }); - it("切到我的技能后应展示本地已安装技能,并可打开高级管理", () => { + it("切到我的技能后应展示本地已安装技能,并可打开导入与维护", () => { const { container } = renderPage(); const installedTab = Array.from(container.querySelectorAll("button")).find( @@ -341,7 +341,7 @@ describe("SkillsWorkspacePage", () => { expect(container.textContent).toContain("写作助手"); const manageButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("高级技能管理"), + (button) => button.textContent?.includes("导入与维护"), ); expect(manageButton).toBeTruthy(); diff --git a/src/components/skills/SkillsWorkspacePage.tsx b/src/components/skills/SkillsWorkspacePage.tsx index 783565ecb..63dc6e6e4 100644 --- a/src/components/skills/SkillsWorkspacePage.tsx +++ b/src/components/skills/SkillsWorkspacePage.tsx @@ -499,7 +499,7 @@ export function SkillsWorkspacePage({
- CLAW SKILLS · 主入口 + 技能 · 直接开工
@@ -512,24 +512,21 @@ export function SkillsWorkspacePage({ tone="mint" />
+

+ 先找能直接开工的做法,再进入具体技能。 +

{catalogMeta ? ( <> - {catalogMeta.sourceLabel} + 技能 {serviceSkills.length} {catalogMeta.groupCount ? ( 分组 {catalogMeta.groupCount} ) : null} - - Tenant · {catalogMeta.tenantId} - - - Version · {catalogMeta.version} - {syncedAtLabel ? ( 同步于 {syncedAtLabel} @@ -549,7 +546,7 @@ export function SkillsWorkspacePage({ 查找技能组 / 技能项
@@ -581,7 +578,7 @@ export function SkillsWorkspacePage({ onClick={() => setAdvancedManagerOpen(true)} > - 高级技能管理 + 导入与维护
@@ -592,12 +589,12 @@ export function SkillsWorkspacePage({
- Claw 下的技能主入口 + 从现成做法开始

- 先选技能组,再进入真正可执行的技能 + 先找能直接开工的做法

onNavigate("agent", { agentEntry: "claw" })} > - 打开 Claw 对话 + 回到任务中心
@@ -650,7 +647,7 @@ export function SkillsWorkspacePage({ 本地已安装
@@ -683,7 +680,7 @@ export function SkillsWorkspacePage({ }, { icon: Boxes, - title: "本地补充", + title: "我的补充", description: "本地和项目级技能仍然可用,但不再作为默认主入口承载。", }, @@ -747,7 +744,7 @@ export function SkillsWorkspacePage({ ? "正在同步当前目录..." : selectedGroup ? `当前正在浏览 ${selectedGroup.title} 技能组。` - : "技能广场面向最终用户,本地仓库与导入能力收纳在高级管理中。"} + : "首页先展示能直接启动的技能;本地仓库与导入能力收纳在导入与维护。"}
@@ -1004,7 +1001,7 @@ export function SkillsWorkspacePage({
) : (
- 当前没有本地已安装技能。你仍然可以在高级管理里导入本地技能或查看远程仓库目录。 + 当前没有本地已安装技能。你仍然可以在导入与维护里导入本地技能或查看远程仓库目录。
)}
@@ -1030,10 +1027,10 @@ export function SkillsWorkspacePage({
- 高级技能管理 + 导入与维护
diff --git a/src/components/workspace/video/VideoWorkspace.tsx b/src/components/workspace/video/VideoWorkspace.tsx index 3f35d1257..f00f0f551 100644 --- a/src/components/workspace/video/VideoWorkspace.tsx +++ b/src/components/workspace/video/VideoWorkspace.tsx @@ -453,6 +453,102 @@ const StageFooterTips = styled.div` flex-wrap: wrap; `; +const FocusedTaskPanel = styled.div` + position: relative; + z-index: 1; + margin-top: 14px; + border-radius: 24px; + border: 1px solid hsl(var(--border) / 0.84); + background: hsl(var(--background) / 0.94); + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; +`; + +const FocusedTaskHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +`; + +const FocusedTaskCopy = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; + +const FocusedTaskTitle = styled.h4` + margin: 0; + font-size: 15px; + line-height: 1.4; + font-weight: 700; + color: hsl(var(--foreground)); +`; + +const FocusedTaskDescription = styled.p` + margin: 0; + font-size: 12px; + line-height: 1.6; + color: hsl(var(--muted-foreground)); +`; + +const FocusedTaskPrompt = styled.div` + border-radius: 18px; + border: 1px solid hsl(var(--border) / 0.82); + background: hsl(var(--muted) / 0.12); + padding: 14px 16px; + font-size: 14px; + line-height: 1.7; + color: hsl(var(--foreground)); + white-space: pre-wrap; + word-break: break-word; +`; + +const FocusedTaskMetaGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + + @media (max-width: 720px) { + grid-template-columns: 1fr; + } +`; + +const FocusedTaskMetaCard = styled.div` + border-radius: 18px; + border: 1px solid hsl(var(--border) / 0.84); + background: hsl(var(--background)); + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; +`; + +const FocusedTaskMetaLabel = styled.span` + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + color: hsl(var(--muted-foreground)); +`; + +const FocusedTaskMetaValue = styled.span` + font-size: 14px; + line-height: 1.6; + font-weight: 700; + color: hsl(var(--foreground)); + word-break: break-word; +`; + +const FocusedTaskMetaHint = styled.span` + font-size: 12px; + line-height: 1.6; + color: hsl(var(--muted-foreground)); + word-break: break-word; +`; + const TaskCounter = styled.span` display: inline-flex; align-items: center; @@ -659,6 +755,22 @@ function mergeTaskList( return merged; } +function resolveFocusedTask( + taskList: WorkspaceTask[], + selectedTaskId?: string, +): WorkspaceTask | null { + const normalizedSelectedTaskId = selectedTaskId?.trim(); + if (normalizedSelectedTaskId) { + const matchedTask = taskList.find( + (task) => task.id === normalizedSelectedTaskId, + ); + if (matchedTask) { + return matchedTask; + } + } + return taskList[0] ?? null; +} + function getStatusTone( status: string, ): "neutral" | "processing" | "success" | "error" | "cancelled" { @@ -704,6 +816,126 @@ function clampProgress(value?: number | null): number | null { return Math.max(0, Math.min(100, Math.round(value))); } +function parseTaskRequestPayload( + task: WorkspaceTask, +): Record | null { + const normalizedPayload = task.requestPayload?.trim(); + if (!normalizedPayload) { + return null; + } + + try { + const parsed = JSON.parse(normalizedPayload) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +function readTaskPayloadString( + payload: Record | null, + keys: string[], +): string | null { + if (!payload) { + return null; + } + + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return null; +} + +function readTaskPayloadPositiveNumber( + payload: Record | null, + keys: string[], +): number | null { + if (!payload) { + return null; + } + + for (const key of keys) { + const value = payload[key]; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + } + + return null; +} + +function resolveTaskSyncCopy(task: WorkspaceTask): { + label: string; + hint: string; +} { + const progress = clampProgress(task.progress); + + if (task.status === "success") { + if (task.resourceMaterialId) { + return { + label: "已同步到项目素材", + hint: "当前结果已经沉淀到项目素材库,可继续复用或组合后续命令。", + }; + } + if (task.resourceSaveError) { + return { + label: "素材同步失败", + hint: task.resourceSaveError, + }; + } + return { + label: "结果已生成", + hint: "视频已经可预览,素材入库会继续在后台自动完成。", + }; + } + + if (task.status === "error") { + return { + label: "生成失败", + hint: task.errorMessage?.trim() || "请检查提示词、模型配置或参考图。", + }; + } + + if (task.status === "cancelled") { + return { + label: "任务已取消", + hint: "当前任务不会继续生成新的结果,可直接重新发起下一轮。", + }; + } + + if (typeof progress === "number") { + return { + label: `当前进度 ${progress}%`, + hint: "工作台会继续刷新状态,完成后自动切换为可预览结果。", + }; + } + + if (task.status === "pending") { + return { + label: "排队中", + hint: "任务已提交到生成队列,等待服务端返回正式进度。", + }; + } + + return { + label: "生成中", + hint: "视频任务正在执行,当前会优先展示你选中的任务状态。", + }; +} + export const VideoWorkspace: React.FC = memo( ({ state, projectId, onStateChange }) => { const [tasks, setTasks] = useState([]); @@ -711,6 +943,19 @@ export const VideoWorkspace: React.FC = memo( const pollingGuard = useRef(false); const savingTaskIdsRef = useRef>(new Set()); const materialRefCache = useRef>(new Map()); + const stateRef = useRef(state); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + const pushState = useCallback( + (nextState: VideoCanvasState) => { + stateRef.current = nextState; + onStateChange(nextState); + }, + [onStateChange], + ); useEffect(() => { skillsApi @@ -725,49 +970,81 @@ export const VideoWorkspace: React.FC = memo( const syncPrimaryState = useCallback( (taskList: WorkspaceTask[]) => { - if (taskList.length === 0) { + const currentState = stateRef.current; + const focusedTask = resolveFocusedTask( + taskList, + currentState.selectedTaskId, + ); + if (!focusedTask) { return; } - const latestTask = taskList[0]; - if (latestTask.status === "success" && latestTask.resultUrl) { + const baseState = + currentState.selectedTaskId === focusedTask.id + ? currentState + : { + ...currentState, + selectedTaskId: focusedTask.id, + }; + + if (focusedTask.status === "success" && focusedTask.resultUrl) { if ( - state.status !== "success" || - state.videoUrl !== latestTask.resultUrl + baseState.status !== "success" || + baseState.videoUrl !== focusedTask.resultUrl || + baseState.errorMessage !== undefined ) { - onStateChange({ - ...state, + pushState({ + ...baseState, status: "success", - videoUrl: latestTask.resultUrl, + videoUrl: focusedTask.resultUrl, errorMessage: undefined, }); - } - return; - } - if (latestTask.status === "error") { - const message = latestTask.errorMessage ?? "视频生成失败"; - if (state.status !== "error" || state.errorMessage !== message) { - onStateChange({ - ...state, - status: "error", - errorMessage: message, - }); + } else if (baseState !== currentState) { + pushState(baseState); } return; } if ( - latestTask.status === "pending" || - latestTask.status === "processing" + focusedTask.status === "error" || + focusedTask.status === "cancelled" ) { - if (state.status !== "generating") { - onStateChange({ - ...state, + const message = + focusedTask.errorMessage ?? + (focusedTask.status === "cancelled" + ? "视频任务已取消" + : "视频生成失败"); + if ( + baseState.status !== "error" || + baseState.errorMessage !== message + ) { + pushState({ + ...baseState, + status: "error", + errorMessage: message, + }); + } else if (baseState !== currentState) { + pushState(baseState); + } + return; + } + if ( + focusedTask.status === "pending" || + focusedTask.status === "processing" + ) { + if ( + baseState.status !== "generating" || + baseState.errorMessage !== undefined + ) { + pushState({ + ...baseState, status: "generating", errorMessage: undefined, }); + } else if (baseState !== currentState) { + pushState(baseState); } } }, - [onStateChange, state], + [pushState], ); const saveVideoToResource = useCallback( @@ -835,7 +1112,10 @@ export const VideoWorkspace: React.FC = memo( if (!active) { return; } - const mapped = list.map((task) => ({ ...task })); + const mapped = mergeTaskList( + [], + list.map((task) => ({ ...task })), + ); setTasks(mapped); syncPrimaryState(mapped); } catch (error) { @@ -994,8 +1274,8 @@ export const VideoWorkspace: React.FC = memo( return; } - onStateChange({ - ...state, + pushState({ + ...stateRef.current, status: "generating", errorMessage: undefined, }); @@ -1021,23 +1301,30 @@ export const VideoWorkspace: React.FC = memo( cameraFixed: state.cameraFixed, }); + pushState({ + ...stateRef.current, + status: "generating", + selectedTaskId: created.id, + errorMessage: undefined, + }); setTasks((previous) => { const merged = mergeTaskList(previous, [created]); + syncPrimaryState(merged); return merged; }); toast.success("视频任务已提交,正在生成"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - onStateChange({ - ...state, + pushState({ + ...stateRef.current, status: "error", errorMessage: message, }); toast.error(message); } }, - [ensureReferenceImageUrl, onStateChange, projectId, state], + [ensureReferenceImageUrl, projectId, pushState, state, syncPrimaryState], ); const handlePreviewTask = useCallback( @@ -1045,18 +1332,24 @@ export const VideoWorkspace: React.FC = memo( if (!task.resultUrl) { return; } - onStateChange({ - ...state, + pushState({ + ...stateRef.current, + selectedTaskId: task.id, status: "success", videoUrl: task.resultUrl, errorMessage: undefined, }); }, - [onStateChange, state], + [pushState], ); const isGenerated = tasks.length > 0 || state.status !== "idle"; const latestTask = tasks[0] ?? null; + const focusedTask = useMemo( + () => resolveFocusedTask(tasks, state.selectedTaskId), + [state.selectedTaskId, tasks], + ); + const activeTaskId = focusedTask?.id ?? state.selectedTaskId ?? null; const referenceCount = [state.startImage, state.endImage].filter( Boolean, ).length; @@ -1105,16 +1398,19 @@ export const VideoWorkspace: React.FC = memo( label: "生成中", tone: "processing" as const, detail: - latestTask?.progress != null - ? `当前进度 ${Math.round(latestTask.progress)}%` - : "已提交任务,正在持续轮询状态。", + focusedTask?.progress != null + ? `当前进度 ${Math.round(focusedTask.progress)}%` + : "任务已提交,正在持续刷新当前进度。", }; } if (state.status === "success" && state.videoUrl) { return { label: "已生成", tone: "success" as const, - detail: "可在右侧切换历史结果,最新成功结果会自动成为主预览。", + detail: + focusedTask && latestTask && focusedTask.id !== latestTask.id + ? "当前正在查看一条历史结果,可在右侧继续切换不同任务做比较。" + : "当前结果已就绪,可继续调整提示词并发起下一轮生成。", }; } if (state.status === "error") { @@ -1130,12 +1426,61 @@ export const VideoWorkspace: React.FC = memo( detail: "先在提示框描述画面,再提交一次生成任务。", }; }, [ - latestTask?.progress, + focusedTask, + latestTask, state.errorMessage, state.status, state.videoUrl, ]); + const focusedTaskSummary = useMemo(() => { + if (!focusedTask) { + return null; + } + + const payload = parseTaskRequestPayload(focusedTask); + const providerLabel = + focusedTask.providerId?.trim() || + readTaskPayloadString(payload, ["providerId", "provider_id"]) || + state.providerId || + "待选择服务"; + const modelLabel = + focusedTask.model?.trim() || + readTaskPayloadString(payload, ["model"]) || + state.model || + "待选择模型"; + const aspectRatioLabel = + readTaskPayloadString(payload, ["aspectRatio", "aspect_ratio"]) || + state.aspectRatio; + const resolutionLabel = + readTaskPayloadString(payload, ["resolution"]) || state.resolution; + const durationLabel = + readTaskPayloadPositiveNumber(payload, ["duration"]) || state.duration; + const promptLabel = focusedTask.prompt?.trim() || state.prompt.trim(); + const syncCopy = resolveTaskSyncCopy(focusedTask); + const isLatestFocused = latestTask?.id === focusedTask.id; + + return { + prompt: promptLabel || "当前任务没有返回明确提示词。", + sourceValue: `${providerLabel} · ${modelLabel}`, + sourceHint: isLatestFocused + ? "当前查看的是最近一次任务。" + : "当前查看的是一条历史任务结果。", + specValue: `${aspectRatioLabel} · ${resolutionLabel} · ${durationLabel} 秒`, + specHint: "规格优先从任务请求恢复,缺失时回退到当前工作台状态。", + syncValue: syncCopy.label, + syncHint: syncCopy.hint, + timelineValue: `创建 ${formatTaskTime(focusedTask.createdAt)}`, + timelineHint: `最近更新 ${formatTaskTime( + focusedTask.updatedAt ?? focusedTask.createdAt, + )} · 任务 ID ${focusedTask.id}`, + badgeLabel: isLatestFocused ? "最新结果" : "历史结果", + description: isLatestFocused + ? "这里固定展示当前聚焦任务的上下文,切换任务后会一起更新。" + : "你正在查看一条历史任务,下面的信息与主预览都跟随该任务同步。", + }; + }, [focusedTask, latestTask?.id, state.aspectRatio, state.duration, state.model, state.prompt, state.providerId, state.resolution]); + return ( @@ -1282,7 +1627,7 @@ export const VideoWorkspace: React.FC = memo( {state.status === "generating" ? "任务已提交,右侧卡片会持续刷新进度。你可以继续优化提示词,准备下一轮迭代。" - : "提交任务后,最新成功结果会自动显示在这里。"} + : "任务完成后,结果会自动显示在这里。"} )} @@ -1299,12 +1644,75 @@ export const VideoWorkspace: React.FC = memo( content="成功结果会自动同步到项目素材;切换历史任务预览不会覆盖你当前输入的提示词。" /> - {latestTask ? ( + {focusedTask ? ( - 最近更新于 {formatTaskTime(latestTask.createdAt)} + 当前任务更新于{" "} + {formatTaskTime( + focusedTask.updatedAt ?? focusedTask.createdAt, + )} ) : null} + + {focusedTaskSummary ? ( + + + + 当前查看任务 + + {focusedTaskSummary.description} + + + {focusedTaskSummary.badgeLabel} + + + + {focusedTaskSummary.prompt} + + + + + 模型链路 + + {focusedTaskSummary.sourceValue} + + + {focusedTaskSummary.sourceHint} + + + + + 生成规格 + + {focusedTaskSummary.specValue} + + + {focusedTaskSummary.specHint} + + + + + 结果同步 + + {focusedTaskSummary.syncValue} + + + {focusedTaskSummary.syncHint} + + + + + 任务时间 + + {focusedTaskSummary.timelineValue} + + + {focusedTaskSummary.timelineHint} + + + + + ) : null} @@ -1349,10 +1757,8 @@ export const VideoWorkspace: React.FC = memo( return ( @@ -1395,10 +1801,11 @@ export const VideoWorkspace: React.FC = memo( {task.resultUrl ? ( handlePreviewTask(task)} > - {task.resultUrl === state.videoUrl + {task.id === activeTaskId ? "当前预览" : "切换预览"} diff --git a/src/components/workspace/video/VideoWorkspaceSelection.test.tsx b/src/components/workspace/video/VideoWorkspaceSelection.test.tsx new file mode 100644 index 000000000..ae2b9129d --- /dev/null +++ b/src/components/workspace/video/VideoWorkspaceSelection.test.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { VideoWorkspace } from "./VideoWorkspace"; +import { + cleanupMountedRoots, + clickElement, + flushEffects, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "@/components/workspace/hooks/testUtils"; +import { + createInitialVideoState, + type VideoCanvasState, +} from "@/components/workspace/video/types"; + +const { mockGetAll, mockListTasks } = vi.hoisted(() => ({ + mockGetAll: vi.fn(), + mockListTasks: vi.fn(), +})); + +vi.mock("@/lib/api/skills", async () => { + const actual = + await vi.importActual( + "@/lib/api/skills", + ); + + return { + ...actual, + skillsApi: { + ...actual.skillsApi, + getAll: (...args: unknown[]) => mockGetAll(...args), + }, + }; +}); + +vi.mock("@/lib/api/videoGeneration", async () => { + const actual = + await vi.importActual( + "@/lib/api/videoGeneration", + ); + + return { + ...actual, + videoGenerationApi: { + ...actual.videoGenerationApi, + listTasks: (...args: unknown[]) => mockListTasks(...args), + }, + }; +}); + +const mountedRoots: MountedRoot[] = []; + +function buildInitialState(): VideoCanvasState { + return { + ...createInitialVideoState("旧任务视频"), + providerId: "doubao", + model: "seedance-1-5-pro-251215", + status: "success", + selectedTaskId: "task-old", + videoUrl: "https://example.com/old.mp4", + }; +} + +function ControlledVideoWorkspace({ + onObservedStateChange, +}: { + onObservedStateChange?: (state: VideoCanvasState) => void; +}) { + const [state, setState] = useState(() => buildInitialState()); + + return ( + { + onObservedStateChange?.(nextState); + setState(nextState); + }} + /> + ); +} + +describe("VideoWorkspace 任务聚焦", () => { + beforeEach(() => { + setupReactActEnvironment(); + vi.clearAllMocks(); + mockGetAll.mockResolvedValue([]); + mockListTasks.mockResolvedValue([ + { + id: "task-latest", + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "最新任务视频", + status: "success", + progress: 100, + resultUrl: "https://example.com/latest.mp4", + requestPayload: JSON.stringify({ + aspectRatio: "16:9", + resolution: "720p", + duration: 6, + }), + createdAt: 2_000, + updatedAt: 2_100, + }, + { + id: "task-old", + projectId: "project-video-1", + providerId: "doubao", + model: "seedance-1-5-pro-251215", + prompt: "旧任务视频", + status: "success", + progress: 100, + resultUrl: "https://example.com/old.mp4", + requestPayload: JSON.stringify({ + aspectRatio: "9:16", + resolution: "1080p", + duration: 12, + }), + createdAt: 1_000, + updatedAt: 1_100, + }, + ]); + }); + + afterEach(() => { + cleanupMountedRoots(mountedRoots); + }); + + it("应优先保持聊天区传入的 selectedTaskId,不被最新任务自动抢焦点", async () => { + const observedStateChanges: VideoCanvasState[] = []; + + const mounted = mountHarness( + ControlledVideoWorkspace, + { + onObservedStateChange: (nextState: VideoCanvasState) => { + observedStateChanges.push(nextState); + }, + }, + mountedRoots, + ); + + await flushEffects(8); + + expect(mockGetAll).toHaveBeenCalledWith("lime"); + expect(mockListTasks).toHaveBeenCalledWith("project-video-1", { + limit: 50, + }); + + const oldPreviewButton = mounted.container.querySelector( + "[data-testid='video-task-preview-task-old']", + ); + const latestPreviewButton = mounted.container.querySelector( + "[data-testid='video-task-preview-task-latest']", + ); + + expect(oldPreviewButton?.textContent).toContain("当前预览"); + expect(latestPreviewButton?.textContent).toContain("切换预览"); + expect(observedStateChanges).toEqual([]); + expect( + mounted.container.querySelector("[data-testid='video-focused-task-prompt']") + ?.textContent, + ).toContain("旧任务视频"); + expect( + mounted.container.querySelector("[data-testid='video-focused-task-spec']") + ?.textContent, + ).toContain("9:16 · 1080p · 12 秒"); + + clickElement(latestPreviewButton); + await flushEffects(2); + + expect( + observedStateChanges[observedStateChanges.length - 1], + ).toMatchObject({ + selectedTaskId: "task-latest", + status: "success", + videoUrl: "https://example.com/latest.mp4", + }); + expect(latestPreviewButton?.textContent).toContain("当前预览"); + expect( + mounted.container.querySelector("[data-testid='video-focused-task-prompt']") + ?.textContent, + ).toContain("最新任务视频"); + expect( + mounted.container.querySelector("[data-testid='video-focused-task-spec']") + ?.textContent, + ).toContain("16:9 · 720p · 6 秒"); + }); +}); diff --git a/src/components/workspace/video/types.ts b/src/components/workspace/video/types.ts index 68a264617..5d836091e 100644 --- a/src/components/workspace/video/types.ts +++ b/src/components/workspace/video/types.ts @@ -23,6 +23,7 @@ export interface VideoCanvasState { aspectRatio: VideoAspectRatio; resolution: VideoResolution; status: VideoStatus; + selectedTaskId?: string; videoUrl?: string; errorMessage?: string; } @@ -50,4 +51,5 @@ export const createInitialVideoState = ( aspectRatio: "adaptive", resolution: "720p", status: "idle", + selectedTaskId: undefined, }); diff --git a/src/hooks/useAppStartupEffects.ts b/src/hooks/useAppStartupEffects.ts index 5e473f5ec..66b2c383f 100644 --- a/src/hooks/useAppStartupEffects.ts +++ b/src/hooks/useAppStartupEffects.ts @@ -69,6 +69,10 @@ export function useAppStartupEffects({ }, []); useEffect(() => { + if (!isTauriDesktopEnvironment()) { + return; + } + void ensureDefaultWorkspaceReady() .then((result) => { if (result?.repaired) { diff --git a/src/hooks/useGlobalTrayModelSync.ts b/src/hooks/useGlobalTrayModelSync.ts index 6c06df2f3..287730c07 100644 --- a/src/hooks/useGlobalTrayModelSync.ts +++ b/src/hooks/useGlobalTrayModelSync.ts @@ -23,6 +23,7 @@ import { type Page, type PageParams, } from "@/types/page"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; const LAST_PROJECT_ID_KEY = "agent_last_project_id"; @@ -119,6 +120,7 @@ export function useGlobalTrayModelSync({ currentPage, pageParams, }: UseGlobalTrayModelSyncOptions) { + const traySyncEnabled = hasTauriInvokeCapability(); const currentPageRef = useRef(currentPage); const pageParamsRef = useRef(pageParams); @@ -126,6 +128,10 @@ export function useGlobalTrayModelSync({ pageParamsRef.current = pageParams; useEffect(() => { + if (!traySyncEnabled) { + return; + } + let cancelled = false; const retryTimerIds: number[] = []; const idleCallbackIds: number[] = []; @@ -269,5 +275,5 @@ export function useGlobalTrayModelSync({ dispose(); } }; - }, [currentPage]); + }, [currentPage, traySyncEnabled]); } diff --git a/src/hooks/useOemLimeHubProviderSync.test.tsx b/src/hooks/useOemLimeHubProviderSync.test.tsx index 2bdaf588b..6abe8232a 100644 --- a/src/hooks/useOemLimeHubProviderSync.test.tsx +++ b/src/hooks/useOemLimeHubProviderSync.test.tsx @@ -28,6 +28,10 @@ vi.mock("@/lib/api/oemCloudControlPlane", () => ({ listClientProviderOfferModels: controlPlaneMocks.listClientProviderOfferModels, })); +vi.mock("@/lib/tauri-runtime", () => ({ + hasTauriInvokeCapability: () => true, +})); + import { useOemLimeHubProviderSync } from "./useOemLimeHubProviderSync"; interface MountedHarness { diff --git a/src/hooks/useOemLimeHubProviderSync.ts b/src/hooks/useOemLimeHubProviderSync.ts index f7048629d..a1825c2ae 100644 --- a/src/hooks/useOemLimeHubProviderSync.ts +++ b/src/hooks/useOemLimeHubProviderSync.ts @@ -15,6 +15,7 @@ import { OEM_LIME_HUB_PROVIDER_ID, resolveOemLimeHubProviderName, } from "@/lib/oemLimeHubProvider"; +import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; function buildSyncSignature( runtime: ReturnType, @@ -69,14 +70,24 @@ async function resolveSyncedCustomModels( } export function useOemLimeHubProviderSync() { - const runtime = resolveOemCloudRuntimeContext(); + const syncEnabled = hasTauriInvokeCapability(); const lastAppliedSignatureRef = useRef(""); useEffect(() => { + if (!syncEnabled) { + return; + } + let disposed = false; async function syncProviders() { try { + const runtime = resolveOemCloudRuntimeContext(); + if (!runtime) { + lastAppliedSignatureRef.current = buildSyncSignature(null, []); + return; + } + const providers = await apiKeyProviderApi.getProviders({ forceRefresh: true, }); @@ -84,11 +95,6 @@ export function useOemLimeHubProviderSync() { return; } - if (!runtime) { - lastAppliedSignatureRef.current = buildSyncSignature(null, []); - return; - } - const limeHubProvider = providers.find( (provider) => provider.id === OEM_LIME_HUB_PROVIDER_ID, ); @@ -170,5 +176,5 @@ export function useOemLimeHubProviderSync() { unsubscribeSession(); unsubscribeBootstrap(); }; - }, [runtime]); + }, [syncEnabled]); } diff --git a/src/hooks/useProjects.test.tsx b/src/hooks/useProjects.test.tsx new file mode 100644 index 000000000..cb3531ba6 --- /dev/null +++ b/src/hooks/useProjects.test.tsx @@ -0,0 +1,219 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const projectApiMocks = vi.hoisted(() => ({ + createProject: vi.fn(), + deleteProject: vi.fn(), + ensureWorkspaceReady: vi.fn(), + getDefaultProject: vi.fn(), + getOrCreateDefaultProject: vi.fn(), + listProjects: vi.fn(), + resolveProjectRootPath: vi.fn(), + updateProject: vi.fn(), +})); + +const telemetryMocks = vi.hoisted(() => ({ + recordWorkspaceRepair: vi.fn(), +})); + +vi.mock("@/lib/api/project", () => ({ + createProject: projectApiMocks.createProject, + deleteProject: projectApiMocks.deleteProject, + ensureWorkspaceReady: projectApiMocks.ensureWorkspaceReady, + getDefaultProject: projectApiMocks.getDefaultProject, + getOrCreateDefaultProject: projectApiMocks.getOrCreateDefaultProject, + listProjects: projectApiMocks.listProjects, + resolveProjectRootPath: projectApiMocks.resolveProjectRootPath, + updateProject: projectApiMocks.updateProject, +})); + +vi.mock("@/lib/workspaceHealthTelemetry", () => ({ + recordWorkspaceRepair: telemetryMocks.recordWorkspaceRepair, +})); + +import { useProjects } from "./useProjects"; + +interface HookHarness { + getValue: () => ReturnType; + unmount: () => void; +} + +function createProject(overrides: Partial> = {}) { + return buildProject(overrides); +} + +function buildProject(overrides: Partial<{ + id: string; + name: string; + workspaceType: "general"; + rootPath: string; + isDefault: boolean; + settings: Record; + createdAt: number; + updatedAt: number; + icon: string; + color: string; + isFavorite: boolean; + isArchived: boolean; + tags: string[]; + defaultPersonaId?: string; +}> = {}) { + return { + id: "project-default", + name: "默认项目", + workspaceType: "general" as const, + rootPath: "/tmp/default-project", + isDefault: true, + settings: {}, + createdAt: 1, + updatedAt: 1, + icon: "📁", + color: "#00aa88", + isFavorite: false, + isArchived: false, + tags: [], + ...overrides, + }; +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function mountHook(): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useProjects(); + return null; + } + + act(() => { + root.render(); + }); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("useProjects", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("不应等待默认项目目录健康检查完成才暴露项目列表", async () => { + const defaultProject = createProject(); + let resolveEnsure: ((value: { + workspaceId: string; + rootPath: string; + existed: boolean; + created: boolean; + repaired: boolean; + }) => void) | null = null; + + projectApiMocks.listProjects.mockResolvedValueOnce([defaultProject]); + projectApiMocks.getDefaultProject.mockResolvedValueOnce(defaultProject); + projectApiMocks.ensureWorkspaceReady.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveEnsure = resolve; + }), + ); + + const harness = mountHook(); + + try { + await flushMicrotasks(); + + expect(projectApiMocks.ensureWorkspaceReady).toHaveBeenCalledWith( + defaultProject.id, + ); + expect(harness.getValue().loading).toBe(false); + expect(harness.getValue().projects).toHaveLength(1); + expect(harness.getValue().defaultProject?.id).toBe(defaultProject.id); + + resolveEnsure?.({ + workspaceId: defaultProject.id, + rootPath: defaultProject.rootPath, + existed: true, + created: false, + repaired: false, + }); + await flushMicrotasks(); + } finally { + harness.unmount(); + } + }); + + it("DevBridge 瞬时不可用时应自动重试并恢复项目列表", async () => { + const defaultProject = createProject(); + const bridgeError = new Error( + "[DevBridge] 浏览器模式无法连接后端桥接,命令 \"workspace_list\" 执行失败。", + ); + + projectApiMocks.listProjects + .mockRejectedValueOnce(bridgeError) + .mockResolvedValueOnce([defaultProject]); + projectApiMocks.getDefaultProject + .mockRejectedValueOnce(bridgeError) + .mockResolvedValueOnce(defaultProject); + projectApiMocks.ensureWorkspaceReady.mockResolvedValue({ + workspaceId: defaultProject.id, + rootPath: defaultProject.rootPath, + existed: true, + created: false, + repaired: false, + }); + + const harness = mountHook(); + + try { + await flushMicrotasks(); + + expect(harness.getValue().error).toContain("DevBridge"); + expect(harness.getValue().loading).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1200); + }); + await flushMicrotasks(); + + expect(harness.getValue().error).toBeNull(); + expect(harness.getValue().projects).toHaveLength(1); + expect(harness.getValue().defaultProject?.id).toBe(defaultProject.id); + expect(projectApiMocks.listProjects).toHaveBeenCalledTimes(2); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index 5ec7701aa..caebcd2d1 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -5,7 +5,7 @@ * @requirements 12.1, 12.2, 12.3, 12.4 */ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import type { Project, CreateProjectRequest, @@ -67,6 +67,18 @@ interface UseProjectsOptions { autoLoad?: boolean; } +const DEV_BRIDGE_RETRY_BASE_DELAY_MS = 1200; +const DEV_BRIDGE_RETRY_MAX_DELAY_MS = 5000; +const DEV_BRIDGE_RETRY_MAX_ATTEMPTS = 4; + +function isDevBridgeUnavailableErrorMessage(message: string): boolean { + return ( + message.includes("[DevBridge] 浏览器模式无法连接后端桥接") || + message.includes("Failed to fetch") || + message.includes("ERR_CONNECTION_REFUSED") + ); +} + /** * 项目管理 Hook */ @@ -79,6 +91,17 @@ export function useProjects( const [loading, setLoading] = useState(autoLoad); const [error, setError] = useState(null); const [filter, setFilter] = useState({}); + const retryTimerRef = useRef | null>(null); + const retryAttemptRef = useRef(0); + + const clearRetryTimer = useCallback(() => { + if (!retryTimerRef.current) { + return; + } + + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + }, []); /** 刷新项目列表 */ const refresh = useCallback(async () => { @@ -91,29 +114,41 @@ export function useProjects( getDefaultApiProject(), ]); - if (!skipDefaultWorkspaceReadyCheck && defaultProj?.id) { - const ensureResult = await ensureWorkspaceReady(defaultProj.id); - if (ensureResult.repaired) { - recordWorkspaceRepair({ - workspaceId: ensureResult.workspaceId, - rootPath: ensureResult.rootPath, - source: "projects_refresh", - }); - console.info( - "[Projects] 默认项目目录缺失,已自动修复:", - ensureResult.rootPath, - ); - } - } - setProjects(list.map(toProjectView)); setDefaultProject(defaultProj ? toProjectView(defaultProj) : null); + clearRetryTimer(); + retryAttemptRef.current = 0; + + if (!skipDefaultWorkspaceReadyCheck && defaultProj?.id) { + void ensureWorkspaceReady(defaultProj.id) + .then((ensureResult) => { + if (!ensureResult.repaired) { + return; + } + + recordWorkspaceRepair({ + workspaceId: ensureResult.workspaceId, + rootPath: ensureResult.rootPath, + source: "projects_refresh", + }); + console.info( + "[Projects] 默认项目目录缺失,已自动修复:", + ensureResult.rootPath, + ); + }) + .catch((ensureError) => { + console.warn( + "[Projects] 默认项目目录健康检查失败:", + ensureError, + ); + }); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [skipDefaultWorkspaceReadyCheck]); + }, [clearRetryTimer, skipDefaultWorkspaceReadyCheck]); /** 筛选后的项目列表 */ const filteredProjects = useMemo(() => { @@ -218,6 +253,38 @@ export function useProjects( refresh(); }, [autoLoad, refresh]); + useEffect(() => { + if ( + !autoLoad || + !error || + !isDevBridgeUnavailableErrorMessage(error) || + retryAttemptRef.current >= DEV_BRIDGE_RETRY_MAX_ATTEMPTS || + retryTimerRef.current + ) { + return; + } + + const delay = Math.min( + DEV_BRIDGE_RETRY_MAX_DELAY_MS, + DEV_BRIDGE_RETRY_BASE_DELAY_MS * 2 ** retryAttemptRef.current, + ); + retryAttemptRef.current += 1; + retryTimerRef.current = setTimeout(() => { + retryTimerRef.current = null; + void refresh(); + }, delay); + + return () => { + clearRetryTimer(); + }; + }, [autoLoad, clearRetryTimer, error, refresh]); + + useEffect(() => { + return () => { + clearRetryTimer(); + }; + }, [clearRetryTimer]); + return { projects, generalProjects, diff --git a/src/hooks/useRelayRegistry.ts b/src/hooks/useRelayRegistry.ts index 8ab51f45f..13e9ecf03 100644 --- a/src/hooks/useRelayRegistry.ts +++ b/src/hooks/useRelayRegistry.ts @@ -14,6 +14,10 @@ import { showRegistryNoCacheError, } from "@/lib/utils/connectError"; +interface UseRelayRegistryOptions { + autoLoad?: boolean; +} + /** * Registry 错误 */ @@ -66,7 +70,10 @@ export interface UseRelayRegistryReturn { * * @returns Hook 返回值 */ -export function useRelayRegistry(): UseRelayRegistryReturn { +export function useRelayRegistry( + options: UseRelayRegistryOptions = {}, +): UseRelayRegistryReturn { + const { autoLoad = true } = options; const [providers, setProviders] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -138,13 +145,18 @@ export function useRelayRegistry(): UseRelayRegistryReturn { // 初始加载 // _Requirements: 2.1_ useEffect(() => { + if (!autoLoad) { + setIsLoading(false); + return; + } + // 延迟加载,等待 Connect 模块初始化 const timer = setTimeout(() => { loadProviders(); }, 1000); return () => clearTimeout(timer); - }, [loadProviders]); + }, [autoLoad, loadProviders]); return { providers, diff --git a/src/lib/api/agentProtocol.ts b/src/lib/api/agentProtocol.ts index 4169f64e1..0be8897ba 100644 --- a/src/lib/api/agentProtocol.ts +++ b/src/lib/api/agentProtocol.ts @@ -91,6 +91,7 @@ export interface AgentMessage { role: string; content: AgentMessageContent[]; timestamp: number; + usage?: AgentTokenUsage; } export interface AgentArtifactSignal { diff --git a/src/lib/api/frontendDebug.test.ts b/src/lib/api/frontendDebug.test.ts index e9227dcf5..c34d8ae41 100644 --- a/src/lib/api/frontendDebug.test.ts +++ b/src/lib/api/frontendDebug.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { safeInvoke } from "@/lib/dev-bridge"; +import { isDevBridgeAvailable, safeInvoke } from "@/lib/dev-bridge"; import { reportFrontendDebugLog } from "./frontendDebug"; vi.mock("@/lib/dev-bridge", () => ({ + isDevBridgeAvailable: vi.fn(() => false), safeInvoke: vi.fn(), })); @@ -12,6 +13,7 @@ describe("frontendDebug API", () => { }); it("应代理前端调试日志上报命令", async () => { + vi.mocked(isDevBridgeAvailable).mockReturnValue(false); vi.mocked(safeInvoke).mockResolvedValueOnce(undefined); await expect( @@ -21,4 +23,17 @@ describe("frontendDebug API", () => { }), ).resolves.toBeUndefined(); }); + + it("浏览器 dev shell 下应跳过远端调试日志上报", async () => { + vi.mocked(isDevBridgeAvailable).mockReturnValue(true); + + await expect( + reportFrontendDebugLog({ + message: "AgentChatPage.loadData.start", + category: "agent", + }), + ).resolves.toBeUndefined(); + + expect(safeInvoke).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/api/frontendDebug.ts b/src/lib/api/frontendDebug.ts index 507474245..a4a943989 100644 --- a/src/lib/api/frontendDebug.ts +++ b/src/lib/api/frontendDebug.ts @@ -1,4 +1,4 @@ -import { safeInvoke } from "@/lib/dev-bridge"; +import { isDevBridgeAvailable, safeInvoke } from "@/lib/dev-bridge"; export interface FrontendDebugLogReport { message: string; @@ -10,5 +10,9 @@ export interface FrontendDebugLogReport { export async function reportFrontendDebugLog( report: FrontendDebugLogReport, ): Promise { + // 浏览器 dev shell 仅保留本地 console 调试,不再占用 DevBridge 主链。 + if (isDevBridgeAvailable()) { + return; + } await safeInvoke("report_frontend_debug_log", { report }); } diff --git a/src/lib/api/serviceSkills.test.ts b/src/lib/api/serviceSkills.test.ts index 41f604fac..571bdc707 100644 --- a/src/lib/api/serviceSkills.test.ts +++ b/src/lib/api/serviceSkills.test.ts @@ -64,13 +64,6 @@ describe("serviceSkills API", () => { expect(catalog.tenantId).toBe("local-seeded"); expect(catalog.items.length).toBeGreaterThan(0); - expect( - catalog.items.some( - (item) => - item.defaultExecutorBinding === "browser_assist" || - Boolean(item.siteCapabilityBinding), - ), - ).toBe(false); expect( catalog.items.find((item) => item.id === "carousel-post-replication"), ).toEqual( @@ -95,6 +88,22 @@ describe("serviceSkills API", () => { }), }), ); + expect( + catalog.items.find((item) => item.id === "x-article-export"), + ).toEqual( + expect.objectContaining({ + source: "local_custom", + defaultExecutorBinding: "browser_assist", + sceneBinding: expect.objectContaining({ + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + }), + siteCapabilityBinding: expect.objectContaining({ + adapterName: "x/article-export", + saveMode: "project_resource", + }), + }), + ); const cached = window.localStorage.getItem("lime:service-skill-catalog:v1"); expect(cached).toContain('"tenantId":"local-seeded"'); @@ -110,8 +119,9 @@ describe("serviceSkills API", () => { expect(catalog.tenantId).toBe("tenant-demo"); expect(catalog.version).toBe("tenant-2026-03-24"); - expect(skills).toHaveLength(1); - expect(skills[0]?.id).toBe("tenant-remote-skill"); + expect(skills.map((item) => item.id)).toEqual( + expect.arrayContaining(["tenant-remote-skill", "x-article-export"]), + ); }); it("旧格式远端目录缺少 skillBundle 时应自动补齐标准摘要", async () => { @@ -146,7 +156,7 @@ describe("serviceSkills API", () => { const catalog = await getServiceSkillCatalog(); expect(catalog.tenantId).toBe("local-seeded"); - expect(catalog.version).toBe("client-seed-2026-04-04"); + expect(catalog.version).toBe("client-seed-2026-04-07"); }); it("当前 OEM 租户不匹配时不应读取其他租户的缓存目录", async () => { @@ -159,7 +169,7 @@ describe("serviceSkills API", () => { const catalog = await getServiceSkillCatalog(); expect(catalog.tenantId).toBe("local-seeded"); - expect(catalog.version).toBe("client-seed-2026-04-04"); + expect(catalog.version).toBe("client-seed-2026-04-07"); }); it("旧的 seeded 本地缓存应自动升级到当前 seeded 目录", async () => { diff --git a/src/lib/api/serviceSkills.ts b/src/lib/api/serviceSkills.ts index 5b6eb1a0f..682887b33 100644 --- a/src/lib/api/serviceSkills.ts +++ b/src/lib/api/serviceSkills.ts @@ -82,6 +82,14 @@ export interface ServiceSkillSiteCapabilityBinding { suggestedTitleTemplate?: string; } +export interface ServiceSkillSceneBinding { + sceneKey: string; + commandPrefix: string; + title?: string; + summary?: string; + aliases?: string[]; +} + export interface ServiceSkillBundleResourceSummary { hasScripts: boolean; hasReferences: boolean; @@ -127,6 +135,7 @@ export interface ServiceSkillItem { examples?: string[]; outputDestination?: string; siteCapabilityBinding?: ServiceSkillSiteCapabilityBinding; + sceneBinding?: ServiceSkillSceneBinding; slotSchema: ServiceSkillSlotDefinition[]; surfaceScopes?: ServiceSkillSurfaceScope[]; promptTemplateKey?: ServiceSkillPromptTemplateKey; @@ -157,7 +166,7 @@ interface ServiceSkillCatalogResponseEnvelope { const SERVICE_SKILL_CATALOG_STORAGE_KEY = "lime:service-skill-catalog:v1"; const SERVICE_SKILL_CATALOG_CHANGED_EVENT = "lime:service-skill-catalog-changed"; -const SEEDED_SERVICE_SKILL_CATALOG_VERSION = "client-seed-2026-04-04"; +const SEEDED_SERVICE_SKILL_CATALOG_VERSION = "client-seed-2026-04-07"; const PLATFORM_OPTIONS: ServiceSkillSlotOption[] = [ { value: "xiaohongshu", label: "小红书" }, @@ -826,6 +835,85 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, ], }, + { + id: "x-article-export", + skillKey: "x-article-export", + skillType: "site", + title: "X 文章转存", + summary: + "复用已连接的 X / Twitter 浏览器登录态,导出长文为 Markdown,并把文内图片一并落到项目目录。", + entryHint: + "给我 X 文章链接,我会复用现有浏览器上下文抓正文、代码块和图片,并沉淀到项目导出目录。", + aliases: [ + "x文章转存", + "x 长文转存", + "twitter article export", + "twitter-article-export", + ], + category: "站点采集", + outputHint: "Markdown 正文 + 图片目录 + 元信息", + triggerHints: [ + "需要把 X 长文沉淀到本地项目目录,方便继续改写或引用时使用。", + "需要保留文内图片、代码块和标题结构,转换成 Markdown 时使用。", + ], + source: "local_custom", + runnerType: "instant", + defaultExecutorBinding: "browser_assist", + executionLocation: "client_default", + readinessRequirements: { + requiresBrowser: true, + requiresProject: true, + }, + usageGuidelines: [ + "优先在已连接浏览器上下文中执行,避免丢失 X 的登录态和长文阅读权限。", + "结果会落到当前项目目录,适合后续继续整理、引用和版本管理。", + ], + setupRequirements: [ + "需要先在浏览器工作台连接目标浏览器。", + "需要当前会话已经选择项目,方便把 Markdown 和图片写入项目目录。", + ], + examples: [ + "把这篇 X 长文导出成 Markdown,并把图片一起保存到项目里。", + "请转存这条 Twitter Article,我后面还要继续改写和引用里面的代码示例。", + ], + outputDestination: + "结果会写入当前项目目录下的导出文件夹,并在工作区生成一个结果入口文档。", + siteCapabilityBinding: { + adapterName: "x/article-export", + autoRun: true, + requireAttachedSession: true, + saveMode: "project_resource", + slotArgMap: { + article_url: "url", + }, + }, + sceneBinding: { + sceneKey: "x-article-export", + commandPrefix: "/x文章转存", + title: "X文章转存", + summary: + "复用 X / Twitter 登录态,把长文导出成 Markdown,并把文内图片一起写入项目目录。", + aliases: [ + "x文章转存", + "x转存", + "twitter-article-export", + "twitter文章转存", + ], + }, + surfaceScopes: ["workspace"], + themeTarget: "general", + version: SEEDED_SERVICE_SKILL_CATALOG_VERSION, + slotSchema: [ + { + key: "article_url", + label: "X 文章链接", + type: "url", + required: true, + placeholder: "https://x.com/<账号>/article/<文章ID>", + helpText: "支持 x.com 和 twitter.com 的 Article 链接。", + }, + ], + }, ], }; @@ -971,6 +1059,25 @@ function isServiceSkillSiteCapabilityBinding( ); } +function isServiceSkillSceneBinding( + value: unknown, +): value is ServiceSkillSceneBinding { + if (!value || typeof value !== "object") { + return false; + } + + const binding = value as Partial; + return ( + typeof binding.sceneKey === "string" && + binding.sceneKey.trim().length > 0 && + typeof binding.commandPrefix === "string" && + binding.commandPrefix.trim().length > 0 && + (binding.title === undefined || typeof binding.title === "string") && + (binding.summary === undefined || typeof binding.summary === "string") && + (binding.aliases === undefined || isStringArray(binding.aliases)) + ); +} + function isServiceSkillItem(value: unknown): value is ServiceSkillItem { if (!value || typeof value !== "object") { return false; @@ -1020,6 +1127,8 @@ function isServiceSkillItem(value: unknown): value is ServiceSkillItem { typeof item.outputDestination === "string") && (item.siteCapabilityBinding === undefined || isServiceSkillSiteCapabilityBinding(item.siteCapabilityBinding)) && + (item.sceneBinding === undefined || + isServiceSkillSceneBinding(item.sceneBinding)) && Array.isArray(item.slotSchema) && item.slotSchema.every(isServiceSkillSlotDefinition) && (item.surfaceScopes === undefined || @@ -1080,6 +1189,14 @@ function cloneServiceSkillCatalog( : undefined, } : undefined, + sceneBinding: item.sceneBinding + ? { + ...item.sceneBinding, + aliases: item.sceneBinding.aliases + ? [...item.sceneBinding.aliases] + : undefined, + } + : undefined, skillBundle: item.skillBundle ? { ...item.skillBundle, @@ -1218,6 +1335,28 @@ function shouldIgnoreServerSyncedCatalog( return compareCatalogVersion(incoming.version, current.version) < 0; } +function mergeSeededLocalCustomServiceSkillItems( + catalog: ServiceSkillCatalog, +): ServiceSkillCatalog { + const localCustomItems = SEEDED_SERVICE_SKILL_CATALOG.items.filter( + (item) => item.source === "local_custom", + ); + if (localCustomItems.length === 0) { + return catalog; + } + + const localCustomIds = new Set(localCustomItems.map((item) => item.id)); + return { + ...catalog, + items: [ + ...catalog.items.filter((item) => !localCustomIds.has(item.id)), + ...localCustomItems.map( + (item) => JSON.parse(JSON.stringify(item)) as ServiceSkillItem, + ), + ], + }; +} + export function parseServiceSkillCatalog( value: unknown, ): ServiceSkillCatalog | null { @@ -1225,7 +1364,7 @@ export function parseServiceSkillCatalog( return null; } - return cloneServiceSkillCatalog(value); + return cloneServiceSkillCatalog(mergeSeededLocalCustomServiceSkillItems(value)); } function persistServiceSkillCatalog(catalog: ServiceSkillCatalog): void { @@ -1421,5 +1560,5 @@ export async function refreshServiceSkillCatalogFromRemote(): Promise { const catalog = await getServiceSkillCatalog(); - return catalog.items.filter((item) => item.source === "cloud_catalog"); + return catalog.items; } diff --git a/src/lib/api/skillCatalog.test.ts b/src/lib/api/skillCatalog.test.ts index 1e6522c90..4627a879d 100644 --- a/src/lib/api/skillCatalog.test.ts +++ b/src/lib/api/skillCatalog.test.ts @@ -120,6 +120,15 @@ describe("skillCatalog", () => { it("应从统一目录中暴露 command 与 scene 扩展入口", async () => { const seeded = await getSkillCatalog(); + const formEntry = listSkillCatalogCommandEntries(seeded).find( + (entry) => entry.commandKey === "form_generate", + ); + const codeEntry = listSkillCatalogCommandEntries(seeded).find( + (entry) => entry.commandKey === "code_runtime", + ); + const publishEntry = listSkillCatalogCommandEntries(seeded).find( + (entry) => entry.commandKey === "publish_runtime", + ); expect( listSkillCatalogCommandEntries(seeded).map((entry) => entry.commandKey), @@ -141,8 +150,29 @@ describe("skillCatalog", () => { "transcription_generate", "url_parse", "typesetting", + "form_generate", + "code_runtime", + "publish_runtime", ]), ); + expect(formEntry?.renderContract).toMatchObject({ + resultKind: "form", + detailKind: "json", + supportsStreaming: true, + supportsTimeline: true, + }); + expect(codeEntry?.renderContract).toMatchObject({ + resultKind: "tool_timeline", + detailKind: "json", + supportsStreaming: true, + supportsTimeline: true, + }); + expect(publishEntry?.renderContract).toMatchObject({ + resultKind: "artifact", + detailKind: "artifact_detail", + supportsStreaming: true, + supportsTimeline: true, + }); const remoteCatalog: SkillCatalog = { ...buildLegacyCatalogWithSiteEntries(), @@ -163,6 +193,22 @@ describe("skillCatalog", () => { supportsTimeline: true, }, }, + { + id: "scene:legacy-site-export", + kind: "scene", + title: "旧版站点导出", + summary: "把站点技能包装成 slash scene。", + sceneKey: "legacy-site-export", + commandPrefix: "/legacy-site-export", + linkedSkillId: "legacy-site-skill", + executionKind: "site_adapter", + renderContract: { + resultKind: "tool_timeline", + detailKind: "scene_detail", + supportsStreaming: true, + supportsTimeline: true, + }, + }, ], }; @@ -171,6 +217,12 @@ describe("skillCatalog", () => { expect( listSkillCatalogSceneEntries(catalog).map((entry) => entry.sceneKey), - ).toContain("campaign-launch"); + ).toEqual( + expect.arrayContaining([ + "campaign-launch", + "legacy-site-export", + "x-article-export", + ]), + ); }); }); diff --git a/src/lib/api/skillCatalog.ts b/src/lib/api/skillCatalog.ts index 4762cface..99c9c6e36 100644 --- a/src/lib/api/skillCatalog.ts +++ b/src/lib/api/skillCatalog.ts @@ -207,7 +207,7 @@ const SEEDED_SKILL_GROUP_PRESETS = [ }, ] as const; const SEEDED_SKILL_CATALOG_VERSION = - "client-seed-skill-catalog-2026-04-06-report"; + "client-seed-skill-catalog-2026-04-07-z-code-runtime"; const SEEDED_COMMAND_ENTRY_DEFINITIONS = [ { id: "command:image_generate", @@ -299,6 +299,140 @@ const SEEDED_COMMAND_ENTRY_DEFINITIONS = [ supportsTimeline: true, }, }, + { + id: "command:presentation_generate", + title: "PPT", + summary: "根据目标说明生成一份可直接讲述和继续导出的演示稿草稿。", + commandKey: "presentation_generate", + aliases: [ + "ppt", + "presentation", + "slides", + "deck", + "yanjiang", + "演示", + "演示稿", + "路演", + ], + triggers: [{ mode: "mention", prefix: "@PPT" }], + binding: { + skillId: "presentation_generate", + executionKind: "agent_turn" as const, + }, + renderContract: { + resultKind: "artifact" as const, + detailKind: "artifact_detail" as const, + supportsStreaming: true, + supportsTimeline: true, + }, + }, + { + id: "command:form_generate", + title: "表单", + summary: "根据目标说明生成可直接在聊天区渲染的 A2UI 表单。", + commandKey: "form_generate", + aliases: [ + "form", + "survey", + "biaodan", + "wenjuan", + "表单", + "问卷", + "报名表", + ], + triggers: [{ mode: "mention", prefix: "@表单" }], + binding: { + skillId: "form_generate", + executionKind: "agent_turn" as const, + }, + renderContract: { + resultKind: "form" as const, + detailKind: "json" as const, + supportsStreaming: true, + supportsTimeline: true, + }, + }, + { + id: "command:webpage_generate", + title: "网页", + summary: "根据目标说明生成可直接预览的单文件网页。", + commandKey: "webpage_generate", + aliases: [ + "webpage", + "web", + "wangye", + "网页", + "落地页", + "landing", + "官网", + "活动页", + ], + triggers: [{ mode: "mention", prefix: "@网页" }], + binding: { + skillId: "webpage_generate", + executionKind: "agent_turn" as const, + }, + renderContract: { + resultKind: "artifact" as const, + detailKind: "artifact_detail" as const, + supportsStreaming: true, + supportsTimeline: true, + }, + }, + { + id: "command:code_runtime", + title: "代码", + summary: "把本次输入切到代码编排主链,优先调度工具、子代理与代码团队协作。", + commandKey: "code_runtime", + aliases: [ + "code", + "coding", + "kaifa", + "daima", + "代码", + "开发", + "代码评审", + "修复", + "重构", + ], + triggers: [{ mode: "mention", prefix: "@代码" }], + binding: { + executionKind: "agent_turn" as const, + }, + renderContract: { + resultKind: "tool_timeline" as const, + detailKind: "json" as const, + supportsStreaming: true, + supportsTimeline: true, + }, + }, + { + id: "command:publish_runtime", + title: "发布", + summary: "把当前内容导入发布工作流,继续整理发布稿、发布包与平台检查。", + commandKey: "publish_runtime", + aliases: [ + "publish", + "fabu", + "fawen", + "投稿", + "发文", + "发布", + "发布稿", + "发布前检查", + ], + triggers: [{ mode: "mention", prefix: "@发布" }], + binding: { + skillId: "content_post_with_cover", + executionKind: "native_skill" as const, + }, + renderContract: { + resultKind: "artifact" as const, + detailKind: "artifact_detail" as const, + supportsStreaming: true, + supportsTimeline: true, + }, + }, { id: "command:broadcast_generate", title: "播报", @@ -813,11 +947,15 @@ function buildSkillEntryFromCatalogItem(item: SkillCatalogItem): SkillCatalogSki function buildSceneEntryFromCatalogItem( item: SkillCatalogItem, ): SkillCatalogSceneEntry | null { - if (item.execution.kind !== "cloud_scene") { + const explicitSceneBinding = item.sceneBinding; + if (!explicitSceneBinding && item.execution.kind !== "cloud_scene") { return null; } - const sceneKey = normalizeText(item.skillKey) ?? normalizeText(item.id); + const sceneKey = + normalizeText(explicitSceneBinding?.sceneKey) ?? + normalizeText(item.skillKey) ?? + normalizeText(item.id); if (!sceneKey) { return null; } @@ -825,11 +963,15 @@ function buildSceneEntryFromCatalogItem( return { id: `scene:${sceneKey}`, kind: "scene", - title: item.title, - summary: item.summary, + title: normalizeText(explicitSceneBinding?.title) ?? item.title, + summary: normalizeText(explicitSceneBinding?.summary) ?? item.summary, sceneKey, - commandPrefix: `/${sceneKey}`, - aliases: item.aliases, + commandPrefix: + normalizeText(explicitSceneBinding?.commandPrefix) ?? `/${sceneKey}`, + aliases: + explicitSceneBinding?.aliases && explicitSceneBinding.aliases.length > 0 + ? explicitSceneBinding.aliases + : item.aliases, surfaceScopes: item.surfaceScopes, linkedSkillId: item.id, executionKind: item.execution.kind, @@ -859,7 +1001,40 @@ function buildSeededCommandEntries(): SkillCatalogCommandEntry[] { })); } -function buildLegacyCatalogEntries(items: SkillCatalogItem[]): SkillCatalogEntry[] { +function buildSceneEntriesFromCatalogItems( + items: SkillCatalogItem[], +): SkillCatalogSceneEntry[] { + return items + .map((item) => buildSceneEntryFromCatalogItem(item)) + .filter((entry): entry is SkillCatalogSceneEntry => Boolean(entry)); +} + +function mergeSeededCatalogEntries( + entries: SkillCatalogEntry[], + sceneSourceItems: SkillCatalogItem[], +): SkillCatalogEntry[] { + const merged: SkillCatalogEntry[] = []; + const seenIds = new Set(); + + [ + ...buildSeededCommandEntries(), + ...buildSceneEntriesFromCatalogItems(sceneSourceItems), + ...entries, + ].forEach((entry) => { + if (seenIds.has(entry.id)) { + return; + } + seenIds.add(entry.id); + merged.push(entry); + }); + + return merged; +} + +function buildLegacyCatalogEntries( + items: SkillCatalogItem[], + sceneSourceItems: SkillCatalogItem[] = items, +): SkillCatalogEntry[] { const entries: SkillCatalogEntry[] = []; const seenIds = new Set(); @@ -877,12 +1052,14 @@ function buildLegacyCatalogEntries(items: SkillCatalogItem[]): SkillCatalogEntry seenIds.add(skillEntry.id); entries.push(skillEntry); } + }); - const sceneEntry = buildSceneEntryFromCatalogItem(item); - if (sceneEntry && !seenIds.has(sceneEntry.id)) { - seenIds.add(sceneEntry.id); - entries.push(sceneEntry); + buildSceneEntriesFromCatalogItems(sceneSourceItems).forEach((entry) => { + if (seenIds.has(entry.id)) { + return; } + seenIds.add(entry.id); + entries.push(entry); }); return entries; @@ -892,7 +1069,7 @@ function resolveSeededSkillGroupKey(item: ServiceSkillItem): string { return resolveAdapterGroupKey(item.siteCapabilityBinding?.adapterName); } -function buildSeededSkillCatalog(): SkillCatalog { +function buildRawSeededSkillCatalog(): SkillCatalog { const seeded = getSeededServiceSkillCatalog(); const items: SkillCatalogItem[] = seeded.items.map((item) => { const clonedItem = cloneJsonValue(item); @@ -936,7 +1113,8 @@ function buildSeededSkillCatalog(): SkillCatalog { }; } -const SEEDED_SKILL_CATALOG = buildSeededSkillCatalog(); +const RAW_SEEDED_SKILL_CATALOG = buildRawSeededSkillCatalog(); +const SEEDED_SKILL_CATALOG = normalizeSkillCatalog(RAW_SEEDED_SKILL_CATALOG); function resolveAdapterGroupKey(adapterName?: string | null): string { const normalized = normalizeText(adapterName)?.toLowerCase(); @@ -1035,21 +1213,30 @@ function shouldExposeSkillCatalogItem(item: SkillCatalogItem): boolean { function normalizeSkillCatalog(catalog: SkillCatalog): SkillCatalog { const filteredItems = catalog.items.filter(shouldExposeSkillCatalogItem); const normalizedGroups = mergeCatalogGroups(catalog.groups, filteredItems); - const normalizedEntries = + const normalizedEntries = mergeSeededCatalogEntries( catalog.entries.length > 0 ? catalog.entries.filter((entry) => { if (entry.kind === "skill") { return filteredItems.some((item) => entry.skillId === item.id); } if (entry.kind === "scene") { + const linkedSkillId = entry.linkedSkillId?.trim(); + if (!linkedSkillId) { + return true; + } + if (catalog.items.some((item) => item.id === linkedSkillId)) { + return true; + } return ( - !entry.linkedSkillId || - filteredItems.some((item) => item.id === entry.linkedSkillId) + entry.executionKind === "site_adapter" || + entry.executionKind === "scene" ); } return true; }) - : buildLegacyCatalogEntries(filteredItems); + : buildLegacyCatalogEntries(filteredItems, catalog.items), + catalog.items, + ); return cloneSkillCatalog({ ...catalog, @@ -1286,6 +1473,26 @@ function parseSkillCatalogEntry(value: unknown): SkillCatalogEntry | null { return null; } +function mergeSeededLocalCustomSkillCatalogItems( + catalog: SkillCatalog, +): SkillCatalog { + const localCustomItems = RAW_SEEDED_SKILL_CATALOG.items.filter( + (item) => item.source === "local_custom", + ); + if (localCustomItems.length === 0) { + return catalog; + } + + const localCustomIds = new Set(localCustomItems.map((item) => item.id)); + return { + ...catalog, + items: [ + ...catalog.items.filter((item) => !localCustomIds.has(item.id)), + ...localCustomItems.map((item) => cloneJsonValue(item)), + ], + }; +} + export function parseSkillCatalog(value: unknown): SkillCatalog | null { if (!isPlainRecord(value)) { return null; @@ -1329,14 +1536,16 @@ export function parseSkillCatalog(value: unknown): SkillCatalog | null { entries.push(parsed); } - return normalizeSkillCatalog({ - version, - tenantId, - syncedAt, - groups, - items, - entries, - }); + return normalizeSkillCatalog( + mergeSeededLocalCustomSkillCatalogItems({ + version, + tenantId, + syncedAt, + groups, + items, + entries, + }), + ); } function persistSkillCatalog(catalog: SkillCatalog): void { diff --git a/src/lib/artifact-protocol/index.test.ts b/src/lib/artifact-protocol/index.test.ts index 1e75fa3e8..f5d968017 100644 --- a/src/lib/artifact-protocol/index.test.ts +++ b/src/lib/artifact-protocol/index.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { ARTIFACT_DOCUMENT_SCHEMA_VERSION } from "@/lib/artifact-document"; import { + areArtifactProtocolPathsEquivalent, extractArtifactProtocolPaths, extractArtifactProtocolPathsFromRecord, extractArtifactProtocolPathsFromValue, hasArtifactProtocolDocumentMetadata, hasArtifactProtocolMetadata, + isArtifactProtocolImagePath, + normalizeArtifactProtocolPath, resolveArtifactProtocolDocumentPayload, resolveArtifactProtocolFilePath, resolveArtifactProtocolPreviewText, @@ -105,6 +108,35 @@ describe("artifact-protocol", () => { expect(hasArtifactProtocolMetadata(undefined)).toBe(false); }); + it("应把文件名、相对路径与绝对路径识别为同一路径", () => { + expect( + areArtifactProtocolPathsEquivalent( + "output_image.jpg", + "/Users/coso/.lime/tasks/image/output_image.jpg", + ), + ).toBe(true); + expect( + areArtifactProtocolPathsEquivalent( + "./.lime/tasks/image/output_image.jpg", + ".lime/tasks/image/output_image.jpg", + ), + ).toBe(true); + expect( + areArtifactProtocolPathsEquivalent( + "content-posts/cover.png", + "assets/cover.png", + ), + ).toBe(false); + }); + + it("应规范化协议路径并识别二进制图片文件", () => { + expect(normalizeArtifactProtocolPath(" .\\.lime\\tasks\\image\\cover.jpg ")).toBe( + "./.lime/tasks/image/cover.jpg", + ); + expect(isArtifactProtocolImagePath("output_image.jpg")).toBe(true); + expect(isArtifactProtocolImagePath("diagram.svg")).toBe(false); + }); + it("应统一处理 artifact 的文件路径回退顺序", () => { expect( resolveArtifactProtocolFilePath({ diff --git a/src/lib/artifact-protocol/index.ts b/src/lib/artifact-protocol/index.ts index 6357c014f..b41e89316 100644 --- a/src/lib/artifact-protocol/index.ts +++ b/src/lib/artifact-protocol/index.ts @@ -38,6 +38,39 @@ function normalizePath(value: string): string { return value.replace(/\\/g, "/").trim(); } +function splitArtifactProtocolPath(value: string): { + normalized: string; + isAbsolute: boolean; + segments: string[]; +} { + const normalized = normalizePath(value); + const isAbsolute = + normalized.startsWith("/") || + normalized.startsWith("~/") || + /^[A-Za-z]:\//.test(normalized) || + normalized.startsWith("//"); + const segments = normalized + .split("/") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0 && segment !== "."); + + return { + normalized, + isAbsolute, + segments, + }; +} + +function hasSameTrailingSegments(left: string[], right: string[]): boolean { + const minLength = Math.min(left.length, right.length); + for (let index = 1; index <= minLength; index += 1) { + if (left[left.length - index] !== right[right.length - index]) { + return false; + } + } + return minLength > 0; +} + function appendArtifactProtocolPath( paths: Set, value: unknown, @@ -161,20 +194,82 @@ export function extractArtifactProtocolPathsFromValue( return Array.from(paths); } +export function normalizeArtifactProtocolPath( + value?: string | null, +): string { + return typeof value === "string" ? splitArtifactProtocolPath(value).normalized : ""; +} + +export function areArtifactProtocolPathsEquivalent( + left?: string | null, + right?: string | null, +): boolean { + const leftPath = typeof left === "string" ? splitArtifactProtocolPath(left) : null; + const rightPath = + typeof right === "string" ? splitArtifactProtocolPath(right) : null; + + if ( + !leftPath || + !rightPath || + leftPath.segments.length === 0 || + rightPath.segments.length === 0 + ) { + return false; + } + + if (leftPath.normalized === rightPath.normalized) { + return true; + } + + if ( + leftPath.segments.length === rightPath.segments.length && + leftPath.segments.every((segment, index) => segment === rightPath.segments[index]) + ) { + return true; + } + + const leftFileName = leftPath.segments[leftPath.segments.length - 1]; + const rightFileName = rightPath.segments[rightPath.segments.length - 1]; + if (leftFileName !== rightFileName) { + return false; + } + + if (leftPath.segments.length === 1 || rightPath.segments.length === 1) { + return true; + } + + if (leftPath.isAbsolute !== rightPath.isAbsolute) { + return hasSameTrailingSegments(leftPath.segments, rightPath.segments); + } + + return false; +} + +export function isArtifactProtocolImagePath( + value?: string | null, +): boolean { + const normalized = normalizeArtifactProtocolPath(value); + if (!normalized) { + return false; + } + + return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i.test(normalized); +} + export function resolveArtifactProtocolFilePath( artifact: ArtifactProtocolFileTarget, ): string { const filePath = normalizeText(artifact.meta.filePath); if (filePath) { - return filePath; + return normalizeArtifactProtocolPath(filePath); } const filename = normalizeText(artifact.meta.filename); if (filename) { - return filename; + return normalizeArtifactProtocolPath(filename); } - return artifact.title; + return normalizeArtifactProtocolPath(artifact.title); } export function hasArtifactProtocolMetadata( diff --git a/src/lib/dev-bridge/http-client.test.ts b/src/lib/dev-bridge/http-client.test.ts index 0e9c8090e..36285b7c5 100644 --- a/src/lib/dev-bridge/http-client.test.ts +++ b/src/lib/dev-bridge/http-client.test.ts @@ -1,92 +1,111 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -type MockMessageEvent = { - data: string; -}; +vi.mock("@/lib/tauri-runtime", () => ({ + hasTauriInvokeCapability: vi.fn(() => false), + hasTauriRuntimeMarkers: vi.fn(() => false), +})); -class MockEventSource { - static instances: MockEventSource[] = []; +import { + __resetDevBridgeHttpStateForTests, + healthCheck, + invokeViaHttp, + listenViaHttpEvent, +} from "./http-client"; - readonly url: string; - readonly withCredentials = false; - readyState = 0; - onopen: ((event: Event) => void) | null = null; - onmessage: ((event: MockMessageEvent) => void) | null = null; - onerror: ((event: Event | unknown) => void) | null = null; - closed = false; +type FetchInput = Parameters[0]; +type FetchOptions = Parameters[1]; - constructor(url: string) { - this.url = url; - MockEventSource.instances.push(this); - } - - close() { - this.closed = true; - this.readyState = 2; - } - - emitOpen() { - this.readyState = 1; - this.onopen?.(new Event("open")); - } - - emitMessage(data: string) { - this.onmessage?.({ data }); - } +function createAbortablePendingFetch() { + return vi.fn((_input: FetchInput, init?: FetchOptions) => { + return new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }); + }); } -describe("http-client listenViaHttpEvent", () => { +describe("http-client", () => { beforeEach(() => { vi.useFakeTimers(); - vi.resetModules(); - MockEventSource.instances = []; - vi.stubEnv("MODE", "development"); - vi.stubEnv("DEV", true); - vi.stubEnv("VITEST", ""); - vi.stubGlobal("EventSource", MockEventSource as unknown as typeof EventSource); + __resetDevBridgeHttpStateForTests(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => { - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + __resetDevBridgeHttpStateForTests(); }); - it("会等待事件流真正连上后再返回订阅", async () => { - const { listenViaHttpEvent } = await import("./http-client"); - const handler = vi.fn(); - let resolved = false; + it("桥健康探测失败后,后续检查会在短退避窗口内快速失败", async () => { + const fetchMock = createAbortablePendingFetch(); + vi.stubGlobal("fetch", fetchMock); - const promise = listenViaHttpEvent("agent_stream", handler).then((unlisten) => { - resolved = true; - return unlisten; - }); + const firstCheck = healthCheck(); + await vi.advanceTimersByTimeAsync(1000); + await expect(firstCheck).resolves.toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); - await Promise.resolve(); - expect(resolved).toBe(false); - expect(MockEventSource.instances).toHaveLength(1); - - const source = MockEventSource.instances[0]; - source.emitOpen(); - - const unlisten = await promise; - expect(resolved).toBe(true); - - source.emitMessage(JSON.stringify({ payload: { delta: "hello" } })); - expect(handler).toHaveBeenCalledWith({ payload: { delta: "hello" } }); - - unlisten(); - expect(source.closed).toBe(true); + await expect(invokeViaHttp("workspace_list")).rejects.toThrow( + "Failed to fetch", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("连接超时会拒绝订阅并关闭事件流", async () => { - const { listenViaHttpEvent } = await import("./http-client"); + it("桥健康时会复用短期健康缓存,避免每次调用都重复探测", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: ["project-a"] }), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: { id: "default-project" } }), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); - const promise = listenViaHttpEvent("agent_stream", vi.fn()); - const rejection = expect(promise).rejects.toThrow("事件流连接超时"); - await vi.advanceTimersByTimeAsync(1500); + await expect(invokeViaHttp("workspace_list")).resolves.toEqual([ + "project-a", + ]); + await expect( + invokeViaHttp<{ id: string }>("workspace_get_default"), + ).resolves.toEqual({ id: "default-project" }); - await rejection; - expect(MockEventSource.instances[0]?.closed).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://127.0.0.1:3030/health"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://127.0.0.1:3030/invoke"); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://127.0.0.1:3030/invoke"); + }); + + it("桥失败短退避期间,事件监听不应继续创建 EventSource 连接", async () => { + const fetchMock = createAbortablePendingFetch(); + const eventSourceMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("EventSource", eventSourceMock as unknown as typeof EventSource); + + const firstCheck = healthCheck(); + await vi.advanceTimersByTimeAsync(1000); + await expect(firstCheck).resolves.toBe(false); + + await expect( + listenViaHttpEvent("config-changed", vi.fn()), + ).rejects.toThrow("Failed to fetch"); + + expect(eventSourceMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/dev-bridge/http-client.ts b/src/lib/dev-bridge/http-client.ts index a9a600482..2b1e24b58 100644 --- a/src/lib/dev-bridge/http-client.ts +++ b/src/lib/dev-bridge/http-client.ts @@ -14,6 +14,10 @@ const BRIDGE_URL = "http://127.0.0.1:3030/invoke"; const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health"; const BRIDGE_EVENTS_URL = "http://127.0.0.1:3030/events"; const DEV_BRIDGE_EVENT_CONNECT_TIMEOUT_MS = 1500; +const DEV_BRIDGE_REQUEST_TIMEOUT_MS = 1800; +const DEV_BRIDGE_HEALTH_TIMEOUT_MS = 800; +const DEV_BRIDGE_HEALTH_CACHE_MS = 2500; +const DEV_BRIDGE_FAILURE_COOLDOWN_MS = 3000; export interface InvokeRequest { cmd: string; @@ -33,7 +37,13 @@ interface DevBridgeEventHub { openPromise: Promise; } +type FetchInput = Parameters[0]; +type FetchOptions = NonNullable[1]>; + const bridgeEventHubs = new Map(); +let bridgeLastHealthyAt = 0; +let bridgeConnectionBackoffUntil = 0; +let bridgeHealthProbePromise: Promise | null = null; function resolveEventSourceConstructor(): typeof EventSource | null { if (typeof window !== "undefined" && typeof window.EventSource === "function") { @@ -63,16 +73,109 @@ function isBridgeCommandError(message: string): boolean { } function isBridgeConnectionError(message: string): boolean { + const normalizedMessage = message.toLowerCase(); return ( message.includes("Failed to fetch") || message.includes("fetch failed") || message.includes("NetworkError") || message.includes("ERR_CONNECTION_REFUSED") || message.includes("Load failed") || - message.includes("ECONNREFUSED") + message.includes("ECONNREFUSED") || + normalizedMessage.includes("timeout") || + normalizedMessage.includes("aborterror") ); } +function markBridgeHealthy(now = Date.now()): void { + bridgeLastHealthyAt = now; + bridgeConnectionBackoffUntil = 0; +} + +function markBridgeUnavailable(now = Date.now()): void { + bridgeLastHealthyAt = 0; + bridgeConnectionBackoffUntil = Math.max( + bridgeConnectionBackoffUntil, + now + DEV_BRIDGE_FAILURE_COOLDOWN_MS, + ); +} + +function isBridgeCooldownActive(now = Date.now()): boolean { + return bridgeConnectionBackoffUntil > now; +} + +function createBridgeConnectionFailureError(reason: string): Error { + return new Error(`Failed to fetch (${reason})`); +} + +async function fetchWithTimeout( + input: FetchInput, + init: FetchOptions, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeoutId = globalThis.setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted) { + throw createBridgeConnectionFailureError(`timeout after ${timeoutMs}ms`); + } + throw error; + } finally { + globalThis.clearTimeout(timeoutId); + } +} + +async function ensureBridgeReachable(): Promise { + const now = Date.now(); + if (bridgeLastHealthyAt > 0 && now - bridgeLastHealthyAt < DEV_BRIDGE_HEALTH_CACHE_MS) { + return; + } + + if (isBridgeCooldownActive(now)) { + throw createBridgeConnectionFailureError("bridge cooldown active"); + } + + if (!bridgeHealthProbePromise) { + bridgeHealthProbePromise = (async () => { + try { + const response = await fetchWithTimeout( + BRIDGE_HEALTH_URL, + { + method: "GET", + }, + DEV_BRIDGE_HEALTH_TIMEOUT_MS, + ); + if (!response.ok) { + markBridgeUnavailable(); + return false; + } + markBridgeHealthy(); + return true; + } catch (error) { + if (isBridgeConnectionError(toErrorMessage(error))) { + markBridgeUnavailable(); + return false; + } + throw error; + } finally { + bridgeHealthProbePromise = null; + } + })(); + } + + const reachable = await bridgeHealthProbePromise; + if (!reachable) { + throw createBridgeConnectionFailureError("bridge health check failed"); + } +} + function isTestEnvironment(): boolean { return Boolean(import.meta.env?.MODE === "test" || import.meta.env?.VITEST); } @@ -177,6 +280,8 @@ export async function listenViaHttpEvent( let hub = bridgeEventHubs.get(normalizedEvent); if (!hub) { + await ensureBridgeReachable(); + const EventSourceConstructor = resolveEventSourceConstructor(); if (!EventSourceConstructor) { throw new Error(`[DevBridge] 浏览器模式事件桥不可用: ${event}`); @@ -199,6 +304,7 @@ export async function listenViaHttpEvent( if (hasOpened) { return; } + markBridgeUnavailable(); bridgeEventHubs.delete(normalizedEvent); source.close(); settleOpenError?.( @@ -237,6 +343,7 @@ export async function listenViaHttpEvent( return; } window.clearTimeout(connectTimeout); + markBridgeUnavailable(); bridgeEventHubs.delete(normalizedEvent); source.close(); settleOpenError?.( @@ -287,13 +394,19 @@ export async function invokeViaHttp( console.log(`[DevBridge] HTTP 调用: ${cmd}`, args); try { - const response = await fetch(BRIDGE_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", + await ensureBridgeReachable(); + + const response = await fetchWithTimeout( + BRIDGE_URL, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ cmd, args } satisfies InvokeRequest), }, - body: JSON.stringify({ cmd, args } satisfies InvokeRequest), - }); + DEV_BRIDGE_REQUEST_TIMEOUT_MS, + ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); @@ -305,8 +418,12 @@ export async function invokeViaHttp( throw new Error(data.error); } + markBridgeHealthy(); return data.result as T; } catch (e) { + if (isBridgeConnectionError(toErrorMessage(e))) { + markBridgeUnavailable(); + } console.error(`[DevBridge] HTTP 调用失败: ${cmd}`, e); throw e; } @@ -319,15 +436,20 @@ export async function invokeViaHttp( */ export async function healthCheck(): Promise { try { - const response = await fetch(BRIDGE_HEALTH_URL, { - method: "GET", - }); - return response.ok; + await ensureBridgeReachable(); + return true; } catch { return false; } } +/** @internal 仅供测试重置 DevBridge HTTP 状态 */ +export function __resetDevBridgeHttpStateForTests(): void { + bridgeLastHealthyAt = 0; + bridgeConnectionBackoffUntil = 0; + bridgeHealthProbePromise = null; +} + /** * 获取桥接状态信息 */ diff --git a/src/lib/governance/legacySurfaceCatalog.json b/src/lib/governance/legacySurfaceCatalog.json index 45284b31c..41bfda324 100644 --- a/src/lib/governance/legacySurfaceCatalog.json +++ b/src/lib/governance/legacySurfaceCatalog.json @@ -1687,6 +1687,31 @@ "stopAsterSession(" ] }, + { + "id": "frontend-browser-preflight-legacy-tokens", + "classification": "dead-candidate", + "description": "前端不允许恢复旧浏览器 preflight 状态机、状态原因与顶栏兼容 props,当前统一收口到 browser requirement metadata + browser-runtime handoff", + "allowedPaths": [], + "patterns": [ + "BrowserTaskPreflight", + "browserTaskPreflight", + "useWorkspaceBrowserPreflightRuntime", + "browser_preflight", + "browserPrepState", + "allowCapabilityFallback", + "browser_launching", + "browser_awaiting_user", + "browser_failed", + "showBrowserAssistEntry", + "onOpenBrowserAssist", + "browserAssistAttentionLevel", + "browserAssistEntryLabel", + "isBrowserAssistCanvasVisible", + "onPrepareBrowserTaskPreflight", + "maybeStartBrowserTaskPreflight", + "handlePermissionResponseWithBrowserPreflight" + ] + }, { "id": "aster-session-helper-direct-usage", "classification": "dead-candidate", diff --git a/src/lib/navigation/sidebarNav.ts b/src/lib/navigation/sidebarNav.ts index 118c83385..090942d8d 100644 --- a/src/lib/navigation/sidebarNav.ts +++ b/src/lib/navigation/sidebarNav.ts @@ -91,11 +91,10 @@ const CAPABILITY_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ icon: CalendarRange, page: "automation", isActive: (currentPage) => currentPage === "automation", - configurable: false, }, { id: "channels", - label: "IM 配置", + label: "消息渠道", icon: Send, page: "channels", isActive: (currentPage) => currentPage === "channels", @@ -110,6 +109,15 @@ const LIBRARY_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ icon: Library, page: "resources", isActive: (currentPage) => currentPage === "resources", + configurable: false, + }, + { + id: "memory", + label: "灵感库", + icon: BrainCircuit, + page: "memory", + isActive: (currentPage) => currentPage === "memory", + configurable: false, }, ]; @@ -117,13 +125,6 @@ const SYSTEM_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ { id: "terminal", label: "终端", icon: Terminal, page: "terminal" }, { id: "tools", label: "工具箱", icon: Wrench, page: "tools" }, { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, - { - id: "memory", - label: "记忆", - icon: BrainCircuit, - page: "memory", - isActive: (currentPage) => currentPage === "memory", - }, { id: "openclaw", label: "OpenClaw", @@ -167,7 +168,7 @@ export const MAIN_SIDEBAR_NAV_SECTIONS: SidebarNavSectionDefinition[] = [ { id: "tasks", title: "任务", items: TASK_SIDEBAR_NAV_ITEMS }, { id: "workspace", title: "工作台", items: WORKSPACE_SIDEBAR_NAV_ITEMS }, { id: "capability", title: "能力", items: CAPABILITY_SIDEBAR_NAV_ITEMS }, - { id: "library", title: "资料库", items: LIBRARY_SIDEBAR_NAV_ITEMS }, + { id: "library", title: "资料", items: LIBRARY_SIDEBAR_NAV_ITEMS }, ]; export const FOOTER_SIDEBAR_NAV_SECTIONS: SidebarNavSectionDefinition[] = [ @@ -198,9 +199,6 @@ const CONFIGURABLE_SIDEBAR_NAV_ITEMS: SidebarNavItemDefinition[] = [ ]; export const DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS = [ - "video", - "image-gen", - "resources", "terminal", ]; diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 068dbd476..ac346ea19 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -2537,6 +2537,10 @@ const defaultMocks: Record = { : targetContentId ? "当前主稿" : `站点采集 ${adapterName || "github/search"} 2026-03-25 12:00:00`; + const bundleRootDir = + adapterName === "x/article-export" + ? "exports/x-article-export/mock-article" + : undefined; return { ok: true, adapter: adapterName || "github/search", @@ -2565,6 +2569,18 @@ const defaultMocks: Record = { content_id: targetContentId || "mock-site-content-1", project_id: targetProjectId || "mock-current-project", title, + project_root_path: "/mock/projects/current", + bundle_relative_dir: bundleRootDir, + markdown_relative_path: bundleRootDir + ? `${bundleRootDir}/index.md` + : undefined, + images_relative_dir: bundleRootDir + ? `${bundleRootDir}/images` + : undefined, + meta_relative_path: bundleRootDir + ? `${bundleRootDir}/meta.json` + : undefined, + image_count: bundleRootDir ? 3 : undefined, } : undefined, saved_project_id: diff --git a/src/lib/utils/scheduleMinimumDelayIdleTask.test.ts b/src/lib/utils/scheduleMinimumDelayIdleTask.test.ts new file mode 100644 index 000000000..b53339f73 --- /dev/null +++ b/src/lib/utils/scheduleMinimumDelayIdleTask.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { scheduleMinimumDelayIdleTask } from "./scheduleMinimumDelayIdleTask"; + +describe("scheduleMinimumDelayIdleTask", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("应先等待最小延迟,再进入 idle 阶段执行任务", () => { + const task = vi.fn(); + const requestIdleCallback = vi.fn((callback: (deadline: IdleDeadline) => void) => { + callback({ + didTimeout: false, + timeRemaining: () => 50, + } as IdleDeadline); + return 1; + }); + + vi.stubGlobal("requestIdleCallback", requestIdleCallback); + vi.stubGlobal("cancelIdleCallback", vi.fn()); + + scheduleMinimumDelayIdleTask(task, { + minimumDelayMs: 6_000, + idleTimeoutMs: 1_500, + }); + + vi.advanceTimersByTime(5_999); + expect(requestIdleCallback).not.toHaveBeenCalled(); + expect(task).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(requestIdleCallback).toHaveBeenCalledTimes(1); + expect(task).toHaveBeenCalledTimes(1); + }); + + it("在最小延迟前取消时不应执行任务", () => { + const task = vi.fn(); + const cancel = scheduleMinimumDelayIdleTask(task, { + minimumDelayMs: 1_500, + }); + + cancel(); + vi.advanceTimersByTime(1_500); + + expect(task).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/utils/scheduleMinimumDelayIdleTask.ts b/src/lib/utils/scheduleMinimumDelayIdleTask.ts new file mode 100644 index 000000000..4e32e7ea2 --- /dev/null +++ b/src/lib/utils/scheduleMinimumDelayIdleTask.ts @@ -0,0 +1,52 @@ +interface ScheduleMinimumDelayIdleTaskOptions { + minimumDelayMs?: number; + idleTimeoutMs?: number; +} + +const DEFAULT_IDLE_TIMEOUT_MS = 1_500; + +export function scheduleMinimumDelayIdleTask( + task: () => void, + options: ScheduleMinimumDelayIdleTaskOptions = {}, +): () => void { + if (typeof window === "undefined") { + task(); + return () => undefined; + } + + const minimumDelayMs = Math.max(0, options.minimumDelayMs ?? 0); + const idleTimeoutMs = Math.max( + 0, + options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + ); + + let cancelled = false; + let idleId: number | null = null; + const delayId = window.setTimeout(() => { + if (cancelled) { + return; + } + + if (typeof window.requestIdleCallback === "function") { + idleId = window.requestIdleCallback( + () => { + if (!cancelled) { + task(); + } + }, + { timeout: idleTimeoutMs }, + ); + return; + } + + task(); + }, minimumDelayMs); + + return () => { + cancelled = true; + window.clearTimeout(delayId); + if (idleId !== null && typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(idleId); + } + }; +} diff --git a/src/lib/videoWorkbenchEvents.ts b/src/lib/videoWorkbenchEvents.ts new file mode 100644 index 000000000..9928d5099 --- /dev/null +++ b/src/lib/videoWorkbenchEvents.ts @@ -0,0 +1,62 @@ +const VIDEO_WORKBENCH_TASK_ACTION_EVENT = + "lime:video-workbench-task-action"; + +export type VideoWorkbenchTaskAction = "retry" | "cancel"; + +export interface VideoWorkbenchTaskActionDetail { + action: VideoWorkbenchTaskAction; + taskId: string; + projectId?: string | null; + contentId?: string | null; +} + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +export function emitVideoWorkbenchTaskAction( + detail: VideoWorkbenchTaskActionDetail, +): void { + if (!hasWindow()) { + return; + } + + window.dispatchEvent( + new CustomEvent( + VIDEO_WORKBENCH_TASK_ACTION_EVENT, + { + detail, + }, + ), + ); +} + +export function onVideoWorkbenchTaskAction( + listener: (detail: VideoWorkbenchTaskActionDetail) => void, +): () => void { + if (!hasWindow()) { + return () => undefined; + } + + const handler = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + + const detail = (event as CustomEvent) + .detail; + if ( + !detail || + (detail.action !== "retry" && detail.action !== "cancel") || + typeof detail.taskId !== "string" + ) { + return; + } + listener(detail); + }; + + window.addEventListener(VIDEO_WORKBENCH_TASK_ACTION_EVENT, handler); + return () => { + window.removeEventListener(VIDEO_WORKBENCH_TASK_ACTION_EVENT, handler); + }; +} diff --git a/src/lib/webview-api.ts b/src/lib/webview-api.ts index 6f24e68c1..940182d15 100644 --- a/src/lib/webview-api.ts +++ b/src/lib/webview-api.ts @@ -521,6 +521,12 @@ export interface SavedSiteAdapterContent { content_id: string; project_id: string; title: string; + project_root_path?: string; + bundle_relative_dir?: string; + markdown_relative_path?: string; + images_relative_dir?: string; + meta_relative_path?: string; + image_count?: number; } export type BrowserRuntimeAuditKind = "action" | "launch";