chore: release v1.5.0

This commit is contained in:
coso
2026-04-08 00:38:05 +08:00
parent 7c06f84e16
commit d2c56ba418
238 changed files with 22104 additions and 2467 deletions
+24 -24
View File
@@ -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`
+16 -1
View File
@@ -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 只消费后端真实运行态,不伪造“已完成”
`@素材` 在这个分型里是一个混合分流特例:
+44 -17
View File
@@ -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,并通过 `<write_file>` 落到工作区;当前上下文缺少明确网页目标时,允许 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,并通过 `<write_file>` 落到工作区;当前上下文缺少明确演示目标时,允许 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`
+40 -1
View File
@@ -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 相对路径和图片数量,避免用户只能看到“已保存”却不知道文件实际落点
### 开发者页站点来源导入验证
+86 -27
View File
@@ -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 会明确提示“模型真相源异常”
@@ -295,7 +295,8 @@ skill 输出的语义是:
标准 CLI 主链:
- `lime task create image --json`
- `lime media image generate --json`
- `lime task create image --json`(兼容入口,但必须复用同一条执行链)
- `lime task status <task-ref>`
- `lime task result <task-ref>`
- `lime task retry <task-ref>`
@@ -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` 行为符合任务协议。
+1 -1
View File
@@ -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"],
});
}
+13 -1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -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",
+6 -4
View File
@@ -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"
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.4.0",
"version": "1.5.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
@@ -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;
});
+26 -21
View File
@@ -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",
+4 -4
View File
@@ -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"
@@ -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,
}
}
+2
View File
@@ -68,6 +68,8 @@ pub struct AgentMessage {
pub role: String,
pub content: Vec<AgentMessageContent>,
pub timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<AgentTokenUsage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+169
View File
@@ -989,6 +989,38 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
})
}
fn resolve_runtime_usage_from_aster_session(
session: &AsterSession,
) -> Option<crate::protocol::AgentTokenUsage> {
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<crate::protocol::AgentTokenUsage> {
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");
@@ -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,
},
];
@@ -76,12 +76,15 @@ pub enum TurnPromptAugmentationStageKind {
DeepSearchSkillLaunch,
SiteSearchSkillLaunch,
PdfReadSkillLaunch,
PresentationSkillLaunch,
FormSkillLaunch,
SummarySkillLaunch,
TranslationSkillLaunch,
AnalysisSkillLaunch,
TranscriptionSkillLaunch,
UrlParseSkillLaunch,
TypesettingSkillLaunch,
WebpageSkillLaunch,
ServiceSkillLaunch,
ServiceSkillLaunchPreload,
Elicitation,
+3
View File
@@ -204,6 +204,9 @@ pub struct AgentMessage {
/// DeepSeek Reasoner 在 Tool Calls 场景下要求此字段
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
/// 当前消息的 token 使用量
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
/// 消息内容类型
@@ -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<bool, String> {
AgentDao::update_latest_assistant_message_usage(conn, session_id, input_tokens, output_tokens)
.map_err(|error| format!("更新最新 assistant 消息 usage 失败: {error}"))
}
@@ -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<Vec<AgentMessage>, 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<String> = row.get(3)?;
let tool_call_id: Option<String> = row.get(4)?;
let reasoning_content: Option<String> = row.get(5)?;
let input_tokens: Option<u32> = row.get(6)?;
let output_tokens: Option<u32> = 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<bool, rusqlite::Error> {
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))
);
}
}
@@ -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
);
",
)
@@ -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(
+4 -2
View File
@@ -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};
@@ -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
));
+2
View File
@@ -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
+26 -5
View File
@@ -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: "素材检索任务技能。",
+384 -130
View File
@@ -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<Value, MediaRun
fn run_media_command(command: MediaCommand) -> Result<Value, MediaRuntimeError> {
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<Value, MediaRuntimeError> {
}))
}
fn create_image_task(args: ImageGenerateArgs) -> Result<Value, MediaRuntimeError> {
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<lime_media_runtime::MediaTaskOutput, MediaRuntimeError> {
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<Value, MediaRuntimeError> {
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<String> {
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<Option<u16>, String> {
let Some(raw) = read_non_empty_env(name) else {
return Ok(None);
};
raw.parse::<u16>()
.map(Some)
.map_err(|error| format!("{name} 不是合法端口: {error}"))
}
fn resolve_cli_image_generation_runner_config() -> Result<ImageGenerationRunnerConfig, String> {
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<String>,
percent: Option<u32>,
) -> 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<String>,
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<String>,
) -> Result<Value, MediaRuntimeError> {
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<Value, MediaRuntimeError> {
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<Value, MediaRuntimeError> {
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");
@@ -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
+626 -1
View File
@@ -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<String>,
count: u32,
style: Option<String>,
provider_id: Option<String>,
}
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<String> {
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<u32> {
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::<u32>().ok().filter(|parsed| *parsed > 0))
})
}
fn prepare_image_task_input(task: &MediaTaskOutput) -> Result<PreparedImageTaskInput, String> {
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<Value> {
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<u32>) -> 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<String>,
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<MediaTaskOutput, MediaRuntimeError> {
load_task_output(workspace_root, task_id, None)
}
fn patch_image_task(
workspace_root: &Path,
task_id: &str,
patch: TaskArtifactPatch,
) -> Result<MediaTaskOutput, MediaRuntimeError> {
patch_task_artifact(workspace_root, task_id, None, patch)
}
fn mark_image_task_failed<F>(
workspace_root: &Path,
task_id: &str,
error: TaskErrorRecord,
on_update: &mut F,
) -> Result<MediaTaskOutput, MediaRuntimeError>
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<MediaTaskOutput, MediaRuntimeError> {
execute_image_generation_task_with_hook(workspace_root, task_id, runner_config, |_| {}).await
}
pub async fn execute_image_generation_task_with_hook<F>(
workspace_root: &Path,
task_id: &str,
runner_config: &ImageGenerationRunnerConfig,
mut on_update: F,
) -> Result<MediaTaskOutput, MediaRuntimeError>
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::<String>
} 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<MediaTaskOutput> {
#[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::<String>));
let captured_response_format = Arc::new(Mutex::new(None::<String>));
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<Value>| {
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();
}
}
@@ -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
)",
[],
@@ -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 个关键问题;除非真的缺失目标,否则不要停在追问。
- 不要输出 `<write_file>`、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": "提交报名"
}
```
@@ -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}
@@ -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 演示稿,并通过 `<write_file>` 落到工作区。
## 执行规则
- 优先使用 `presentation_request.prompt`、`presentation_request.content` 与当前对话里最近的相关上下文。
- 默认产出一个自包含的 Markdown 演示稿,不依赖额外构建工具才能查看。
- 如果用户提供了 `deck_type`、`style`、`audience`、`slide_count`,必须显式遵循;未指定时默认按高完成度通用演示稿执行。
- 演示稿必须可讲述,每一页都要给出明确标题、核心要点与讲述重点,不要只写空泛标题。
- 页面结构至少包含:封面页、目录页、问题/背景页、方案/核心论点页、案例/证明页、结论/行动页。
- 如果目标页数已给出,尽量贴近;未给出时默认控制在 8-12 页。
- 页面文案必须贴合用户主题,不要输出 lorem ipsum、占位 bullet 或泛泛模板词。
- 如信息不足,最多追问 1 个关键问题;除非真的缺失目标,否则不要停在追问。
- 最终必须输出且只输出一个 `<write_file>`,文件扩展名必须是 `.md`。
- `<write_file>` 内只能放最终 Markdown,不要再包 Markdown 代码块。
- 如果需要补充说明,把说明放在 `</write_file>` 之后,且不要重复整份文稿。
## Markdown 结构要求
- 使用 Markdown 标题和分隔线组织页面。
- 文件开头先给出演示名称、演示类型、受众与建议讲述时长。
- 每一页统一使用以下结构:
`## 01 封面`
`- 页面目标:...`
`- 标题:...`
`- 要点:...`
`- 讲述备注:...`
- 页面之间使用 `---` 分隔,方便后续导出或转换。
- 如果适合加入数据、案例或图示建议,可以在对应页添加 `- 可视化建议:...`。
## 输出格式(固定)
<write_file path="presentations/{yyyyMMdd-HHmmss}-{slug}.md">
# 演示稿标题
- 演示类型:...
- 目标受众:...
- 建议讲述时长:...
---
## 01 封面
- 页面目标:...
- 标题:...
- 要点:...
- 讲述备注:...
</write_file>
## 收尾要求
- 如果用户明确要求“融资路演”“销售方案”“培训课件”“周报汇报”等类型,页面结构必须体现对应逻辑。
- 如果用户没有指定文件名,使用与主题相关的 slug。
- 若用户还需要一句摘要,可在文件后补 2-4 行简短总结:适用场景、页数结构、建议下一步。
@@ -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` 执行
@@ -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`
## 使用提醒
@@ -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 页面,并通过 `<write_file>` 落到工作区。
## 执行规则
- 优先使用 `webpage_request.prompt`、`webpage_request.content` 与当前对话里最近的相关上下文。
- 默认产出一个自包含 HTML 文件,不依赖 npm、构建工具、React/Vite 或外部资源才能预览。
- 页面必须同时兼顾桌面与移动端,包含清晰的信息层级、主 CTA、核心卖点区块与结尾收口。
- 如果用户提供了 `page_type`、`style`、`tech_stack`,必须显式遵循;未指定时默认按高完成度落地页执行。
- 视觉方向要明确,不要默认紫色玻璃态模板感;优先使用稳定的排版、间距、层次和少量必要动效。
- 页面文案必须贴合用户主题,不要输出 lorem ipsum 或空泛占位文案。
- 如信息不足,最多追问 1 个关键问题;除非真的缺失目标,否则不要停在追问。
- 最终必须输出且只输出一个 `<write_file>`,文件扩展名必须是 `.html`。
- `<write_file>` 内只能放最终 HTML,不要再包 Markdown 代码块。
- 如果需要补充说明,把说明放在 `</write_file>` 之后,且不要重复整页源码。
## HTML 要求
- 使用语义化 HTML 结构。
- 包含 `<!DOCTYPE html>`、`<html>`、`<head>`、`<meta charset="UTF-8">` 与 `viewport`。
- 使用 CSS variables 管理颜色、间距与阴影。
- 默认使用内联 CSS,必要时可加入少量原生 JS 交互。
- 页面至少包含:首屏、卖点/能力区、证明/案例区、CTA 区、页脚。
- CTA 按钮、卡片、间距与背景层必须可读,不要出现贴边、溢出或移动端布局断裂。
## 输出格式(固定)
<write_file path="landing-pages/{yyyyMMdd-HHmmss}-{slug}.html">
<!DOCTYPE html>
<html lang="zh-CN">
...
</html>
</write_file>
## 收尾要求
- 如果用户明确要求“官网”“活动页”“产品页”等页面类型,文件内容必须体现对应结构。
- 如果用户没有指定文件名,使用与主题相关的 slug。
- 若用户还需要页面说明,可在文件后补 2-4 行简短总结:页面定位、结构亮点、建议下一步。
@@ -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",
@@ -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,
},
};
};
+2 -3
View File
@@ -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(),
@@ -1,6 +1,13 @@
use super::*;
const ANALYSIS_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_ANALYSIS_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}。"),
@@ -1,6 +1,17 @@
use super::*;
const BROADCAST_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_BROADCAST_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}"),
@@ -1,6 +1,17 @@
use super::*;
const COVER_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_COVER_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}"),
@@ -1,6 +1,15 @@
use super::*;
const DEEP_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_DEEP_SEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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(),
@@ -0,0 +1,274 @@
use super::*;
const FORM_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_FORM_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn extract_object_u64(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<u64> {
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::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_form_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(&mut metadata, &["form_skill_launch", "formSkillLaunch"]);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_form_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
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<ToolPermission>,
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<String> {
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("- 不要输出 `<write_file>`、HTML、Markdown 表格或伪代码来替代 A2UI;这次需要直接复用现有 A2UI 渲染链。".to_string());
Some(lines.join("\n"))
}
@@ -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 = "<<LIME_IMAGE_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}。"),
+63 -7
View File
@@ -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,
@@ -1,6 +1,13 @@
use super::*;
const PDF_READ_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_PDF_READ_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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(),
@@ -0,0 +1,280 @@
use super::*;
const PRESENTATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_PRESENTATION_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn extract_object_u64(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<u64> {
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::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_presentation_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["presentation_skill_launch", "presentationSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_presentation_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
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<ToolPermission>,
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<String> {
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 执行时必须产出一个可预览、可继续导出的单文件演示稿,并通过 <write_file> 落到工作区。".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"))
}
@@ -1,6 +1,15 @@
use super::*;
const REPORT_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_REPORT_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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(),
@@ -1,6 +1,15 @@
use super::*;
const RESEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_RESEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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(),
@@ -2,6 +2,17 @@ use super::*;
const RESOURCE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str =
"<<LIME_RESOURCE_SEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}。"),
];
@@ -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<lime_agent::AgentTokenUsage> {
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<lime_agent::AgentTokenUsage> {
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,
@@ -1,6 +1,17 @@
use super::*;
const SITE_SEARCH_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_SITE_SEARCH_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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(),
@@ -1,6 +1,13 @@
use super::*;
const SUMMARY_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_SUMMARY_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}。"),
File diff suppressed because it is too large Load Diff
@@ -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)
}
@@ -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<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
project_id: Option<String>,
@@ -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<ToolResult, ToolError> {
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")
);
}
}
@@ -48,6 +48,16 @@ fn read_output_slot_id(output: &MediaTaskOutput) -> Option<String> {
.or_else(|| read_payload_string(&output.record.payload, &["slot_id", "slotId"]))
}
fn read_output_result_string(output: &MediaTaskOutput, keys: &[&str]) -> Option<String> {
let result = output.record.result.as_ref()?;
read_payload_string(result, keys)
}
fn read_output_result_u64(output: &MediaTaskOutput, keys: &[&str]) -> Option<u64> {
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()))
}
@@ -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()),
@@ -1,6 +1,17 @@
use super::*;
const TRANSCRIPTION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TRANSCRIPTION_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}"),
@@ -1,6 +1,13 @@
use super::*;
const TRANSLATION_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TRANSLATION_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}。"),
@@ -1,6 +1,17 @@
use super::*;
const TYPESETTING_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_TYPESETTING_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}"),
@@ -1,6 +1,17 @@
use super::*;
const URL_PARSE_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_URL_PARSE_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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(),
@@ -1,6 +1,17 @@
use super::*;
const VIDEO_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_VIDEO_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
@@ -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<ToolPermission>,
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<String> {
@@ -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}"),
@@ -0,0 +1,260 @@
use super::*;
const WEBPAGE_SKILL_LAUNCH_PROMPT_MARKER: &str = "<<LIME_WEBPAGE_SKILL_LAUNCH_HINT>>";
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<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter()
.filter_map(|key| object.get(*key))
.find_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn ensure_harness_workbench_chat_mode(value: &mut serde_json::Value, launch_keys: &[&str]) {
let Some(root) = value.as_object_mut() else {
return;
};
let harness = if root.contains_key("harness") {
match root
.get_mut("harness")
.and_then(serde_json::Value::as_object_mut)
{
Some(harness) => harness,
None => return,
}
} else {
root
};
let has_launch = launch_keys.iter().any(|key| {
harness
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
harness.insert(
"chat_mode".to_string(),
serde_json::Value::String("workbench".to_string()),
);
}
fn truncate_prompt_text(value: String, max_chars: usize) -> String {
let total_chars = value.chars().count();
if total_chars <= max_chars {
return value;
}
let truncated = value.chars().take(max_chars).collect::<String>();
format!("{truncated}...(已截断,原始长度 {total_chars} 字)")
}
pub(crate) fn prepare_webpage_skill_launch_request_metadata(
request_metadata: Option<&serde_json::Value>,
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_harness_workbench_chat_mode(
&mut metadata,
&["webpage_skill_launch", "webpageSkillLaunch"],
);
Some(metadata)
}
pub(crate) fn merge_system_prompt_with_webpage_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
) -> Option<String> {
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<ToolPermission>,
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<String> {
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,并通过 <write_file> 落到工作区。".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"))
}
+23 -303
View File
@@ -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<Mutex<HashSet<String>>> =
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<String>,
count: u32,
style: Option<String>,
provider_id: Option<String>,
}
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<String> {
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<u32> {
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::<u32>().ok().filter(|parsed| *parsed > 0))
})
}
fn prepare_image_task_input(task: &MediaTaskOutput) -> Result<PreparedImageTaskInput, String> {
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<Value> {
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<MediaTaskOutput, String> {
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::<String>
} 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<MediaTaskOutput, String> {
@@ -870,8 +590,7 @@ pub fn create_image_generation_task_artifact(
) -> Result<MediaTaskOutput, String> {
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;
@@ -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" => {
@@ -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<Pat
Ok(path)
}
fn detect_extension_sync_drift(
bundled_extension_dir: &Path,
install_dir: &Path,
) -> Result<Option<String>, 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() {
@@ -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,
@@ -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,
@@ -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<String, Value>) -> Result<St
fn build_entry_url_with_builder(id: &str, args: &Map<String, Value>) -> Result<String, String> {
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<String, Value>) -> Result<String, String>
}
}
fn build_x_article_export_url(args: &Map<String, Value>) -> Result<String, String> {
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<String, Value>, key: &str) -> Result<String, String> {
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");
File diff suppressed because it is too large Load Diff
+40 -8
View File
@@ -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"));
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+5 -1
View File
@@ -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,
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

+8 -5
View File
@@ -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();
});
+27 -36
View File
@@ -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;
+340 -81
View File
@@ -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<MessagePreviewTarget, { kind: "task" }>,
): 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<CanvasStateUnion | null>(
() => {
@@ -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,
@@ -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;
@@ -45,4 +45,5 @@ export interface ExecuteCodexSlashCommandParams {
appendAssistantMessage: (content: string) => void;
notifyInfo: (message: string) => void;
notifySuccess: (message: string) => void;
onExecutedCommand?: (command: ParsedCodexSlashCommand) => void;
}
@@ -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(
[
@@ -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 (
<AgentThreadTimelineArtifactCard
item={item}
@@ -1508,7 +1513,10 @@ export const AgentThreadTimeline: React.FC<AgentThreadTimelineProps> = ({
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],
);
@@ -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);
@@ -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<ChatNavbarProps> = ({
isRunning: _isRunning,
chrome = "full",
entryContextLabel,
entryContextHint,
onToggleHistory,
showHistoryToggle = true,
onToggleFullscreen: _onToggleFullscreen,
@@ -111,6 +115,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
const compactProjectSelectorClassName = isWorkspaceCompact
? "min-w-[184px] max-w-[248px]"
: "min-w-[196px] max-w-[280px]";
const showEntryContext = Boolean(entryContextLabel);
return (
<Navbar $compact={isWorkspaceCompact}>
@@ -194,6 +199,26 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
) : null}
</div>
) : null}
{showEntryContext ? (
<div
className={cn(
"ml-1 min-w-0",
isWorkspaceCompact ? "max-w-[180px]" : "max-w-[320px]",
)}
>
<div className="flex min-w-0 flex-col gap-1">
<span className="inline-flex w-fit items-center rounded-full border border-slate-200/80 bg-white px-3 py-1 text-[11px] font-medium text-slate-600 shadow-sm shadow-slate-950/5">
{entryContextLabel}
</span>
{!isWorkspaceCompact && entryContextHint ? (
<p className="truncate text-xs text-slate-500">
{entryContextHint}
</p>
) : null}
</div>
</div>
) : null}
</div>
<div className="flex-1" />
@@ -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: [
@@ -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<ChatSidebarProps> = ({
contextVariant = "default",
onNewChat,
topics,
currentTopicId,
@@ -602,11 +624,25 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
}, [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<ChatSidebarProps> = ({
<div
ref={taskSectionAnchorRef}
className="flex items-center justify-between px-1"
className="px-1"
data-testid="task-section-heading"
>
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
任务
</div>
<div className="text-xs text-slate-400">
{searchKeyword.trim()
? `${filteredTaskItems.length} 条结果`
: `${topics.length} 条`}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
{taskHeadingLabel}
</div>
{taskHeadingHint ? (
<p className="mt-1 text-[11px] leading-5 text-slate-500 dark:text-slate-400">
{taskHeadingHint}
</p>
) : null}
</div>
<div className="shrink-0 pt-0.5 text-xs text-slate-400">
{searchKeyword.trim()
? `${filteredTaskItems.length} 个结果`
: `${topics.length} 个任务`}
</div>
</div>
</div>
@@ -1043,10 +1088,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
<Clock3 className="h-5 w-5" />
</div>
<div className="mt-4 text-sm font-semibold text-slate-800 dark:text-slate-100">
还没有任务
{emptyStateTitle}
</div>
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
从“新建任务”开始输入需求,创建后会出现在这里。
{emptyStateDescription}
</p>
</div>
) : !hasFilteredResults ? (
@@ -1309,7 +1354,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
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}
</button>
) : null}
</div>
@@ -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(() => {
@@ -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<EmptyStateProps> = ({
};
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<EmptyStateProps> = ({
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<EmptyStateProps> = ({
badges.push({
key: "creation-mode",
label: CREATION_MODE_CONFIG[creationMode].name,
tone: "emerald",
tone: "lime",
});
}
@@ -506,7 +593,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
badges.push({
key: "web-search",
label: "联网搜索已开启",
tone: "sky",
tone: "lime",
});
}
@@ -514,7 +601,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
badges.push({
key: "skill",
label: activeSkillDisplayLabel,
tone: "emerald",
tone: "lime",
});
}
@@ -537,72 +624,70 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
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: <Lightbulb className="h-5 w-5" />,
imageSrc: capabilitySkillsPlaceholder,
imageAlt: "技能能力卡占位图",
tone: "emerald",
tone: "lime",
},
{
key: "automation",
eyebrow: "能力层",
eyebrow: "支撑能力",
title: "自动化",
value: planEnabled ? "Plan 编排已开启" : "按当前对话直接执行",
value: planEnabled ? "当前会按步骤推进" : "重复流程可持续跑起来",
description:
"支持把复杂任务按步骤推进,适合长链路处理、批量执行和需要持续产出的工作流。",
"适合长链路处理、批量任务和持续产出,让重复动作不再每次都从头手动重做。",
icon: <ListChecks className="h-5 w-5" />,
imageSrc: capabilityAutomationsPlaceholder,
imageAlt: "自动化能力卡占位图",
tone: "sky",
tone: "lime",
},
{
key: "agent-teams",
eyebrow: "能力层",
eyebrow: "支撑能力",
title: "多代理",
value: subagentEnabled ? "协作模式已开启" : "支持分工协作",
value: subagentEnabled ? "当前任务支持并行协作" : "复杂任务可拆成并行分工",
description:
"需要并行研究、拆解方案或多角色协同时,可让任务由多个代理分工处理并回收结论。",
"当研究、方案和执行需要同时推进时,可把任务拆给多个代理并行处理,再统一回收结论。",
icon: <Workflow className="h-5 w-5" />,
imageSrc: capabilityAgentTeamsPlaceholder,
imageAlt: "多代理协作能力卡占位图",
tone: "amber",
tone: "lime",
},
];
cards.push({
key: "browser",
eyebrow: "能力层",
title: "浏览器工作台",
eyebrow: "支撑能力",
title: "浏览器接入",
value: browserAssistLoading
? "正在准备浏览器会话"
: "网页登录 / 人工接管",
? "正在检查连接状态"
: "CDP / 浏览器插件复用",
description:
"需要处理登录、验证码或复杂网页操作时,可切到浏览器工作台接管真实浏览器。",
"登录、验证和网页动作可直接复用 CDP 或浏览器插件连接,不必再切到单独工作台。",
icon: <Globe className="h-5 w-5" />,
imageSrc: capabilityBrowserAssistPlaceholder,
imageAlt: "浏览器工作台能力卡占位图",
tone: "slate",
action: onLaunchBrowserAssist ? (
<Button
type="button"
variant="outline"
onClick={() => void onLaunchBrowserAssist()}
disabled={browserAssistLoading}
className={EMPTY_STATE_SECONDARY_ACTION_BUTTON_CLASSNAME}
>
<Globe className="mr-2 h-4 w-4" />
{browserAssistLoading ? "启动中..." : "打开浏览器工作台"}
</Button>
) : null,
imageAlt: "浏览器接入能力卡占位图",
tone: "lime",
onMediaAction: onLaunchBrowserAssist
? () => {
void onLaunchBrowserAssist();
}
: undefined,
mediaActionLabel: browserAssistLoading ? "浏览器连接准备中" : "连接浏览器",
mediaActionDisabled: browserAssistLoading,
});
return cards;
@@ -614,48 +699,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
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<EmptyStateProps> = ({
);
const generalRecommendedSolutionsPanel = (
<EmptyStateQuickActions
title="推荐方案"
description="先选一个方案,Claw 会自动进入对应工作模式并带好起始动作。"
items={entryRecommendedSolutions.map((solution) => ({
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);
}
}}
/>
<RecommendationShelf>
<RecommendationShelfHeader>
<div className="text-[11px] font-semibold tracking-[0.02em] text-slate-500">
推荐方案
</div>
{selectedTextPreview ? (
<span className="truncate text-[10px] text-slate-400">
当前会带上选中内容
</span>
) : null}
</RecommendationShelfHeader>
<RecommendationShelfList>
{entryRecommendedSolutions.map((solution) => (
<RecommendationShelfRow key={solution.id}>
<RecommendationShelfButton
type="button"
data-testid={`entry-recommended-${solution.id}`}
onClick={() => {
handleApplyEntryRecommendedSolution(solution);
}}
>
<RecommendationShelfTitle>{solution.title}</RecommendationShelfTitle>
{solution.isRecent ? (
<RecommendationShelfMeta>{solution.badge}</RecommendationShelfMeta>
) : null}
</RecommendationShelfButton>
</RecommendationShelfRow>
))}
</RecommendationShelfList>
</RecommendationShelf>
);
const headerControls = onProjectChange ? (
@@ -851,13 +900,13 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<PageContainer>
<ContentWrapper>
<EmptyStateHero
eyebrow="新建任务"
eyebrow="创作"
title={workbenchCopy.title}
slogan="青柠一下,灵感即来"
description={workbenchCopy.description}
supportingDescription={workbenchCopy.supportingDescription}
badges={workspaceBadges}
cards={workspaceCards}
features={workspaceFeatures}
prioritySlot={composerPanel}
supportingSlot={
isGeneralTheme
@@ -1,11 +1,11 @@
import type { ReactNode } from "react";
import styled, { keyframes } from "styled-components";
import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip";
import {
EMPTY_STATE_BADGE_BASE_CLASSNAME,
EMPTY_STATE_BADGE_TONE_CLASSNAMES,
EMPTY_STATE_CARD_SURFACE_CLASSNAME,
EMPTY_STATE_ICON_TONE_CLASSNAMES,
EMPTY_STATE_META_PILL_CLASSNAME,
} from "./emptyStateSurfaceTokens";
const heroReveal = keyframes`
@@ -32,6 +32,9 @@ const cardReveal = keyframes`
const HeroSection = styled.section`
position: relative;
display: flex;
flex: 1 1 auto;
min-height: 100%;
overflow: visible;
animation: ${heroReveal} 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
@@ -43,13 +46,15 @@ const HeroSection = styled.section`
const HeroContent = styled.div`
position: relative;
display: flex;
flex: 1 1 auto;
min-height: clamp(560px, calc(100dvh - 190px), 820px);
flex-direction: column;
gap: 0.75rem;
padding: 0.875rem;
gap: 0.95rem;
padding: 0.35rem 0.45rem 0.55rem;
@media (min-width: 1024px) {
gap: 0.875rem;
padding: 1rem;
gap: 1.1rem;
padding: 0.45rem 0.6rem 0.7rem;
}
@media (max-height: 940px) {
@@ -57,6 +62,18 @@ const HeroContent = styled.div`
}
`;
const StageGrid = styled.div`
display: block;
`;
const LeadColumn = styled.div`
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.875rem;
max-width: min(1180px, 100%);
`;
const LeadBlock = styled.div`
min-width: 0;
animation: ${cardReveal} 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
@@ -74,34 +91,101 @@ const LeadTopRow = styled.div`
flex-wrap: wrap;
`;
const IntroGrid = styled.div`
display: grid;
gap: 0.75rem;
align-items: start;
const EyebrowText = styled.div`
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.08em;
color: rgb(5 150 105 / 0.82);
`;
@media (min-width: 1240px) {
grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr);
gap: 0.875rem;
}
const LeadBody = styled.div`
display: flex;
min-height: 0;
flex-direction: column;
gap: 0.8rem;
`;
const LeadTextGroup = styled.div`
display: flex;
width: 100%;
flex-direction: column;
gap: 0.625rem;
gap: 0.6rem;
max-width: 58rem;
`;
const shimmer = keyframes`
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
`;
const SloganLine = styled.p`
margin: 0;
display: inline-flex;
width: fit-content;
align-items: center;
gap: 1.3rem;
/* lime gradient with scan effect */
background: linear-gradient(
90deg,
#10b981 0%,
#65a30d 25%,
#bef264 50%,
#65a30d 75%,
#10b981 100%
);
background-size: 200% auto;
color: transparent;
-webkit-background-clip: text;
background-clip: text;
animation: ${shimmer} 3.5s ease-in-out infinite;
font-size: clamp(44px, 5.3vw, 80px);
line-height: 0.94;
font-weight: 800;
letter-spacing: -0.03em;
text-shadow: 0 14px 28px rgba(101, 163, 13, 0.12);
&::before {
content: "";
width: 0.92rem;
height: 0.92rem;
flex-shrink: 0;
border-radius: 9999px;
background: linear-gradient(135deg, rgba(132, 204, 22, 0.9), rgba(16, 185, 129, 0.75));
box-shadow: 0 0 0 12px rgba(132, 204, 22, 0.12);
}
`;
const LeadDescriptionText = styled.p`
margin: 0;
max-width: 42rem;
font-size: 15px;
font-weight: 600;
line-height: 1.72;
color: rgb(51 65 85);
@media (min-width: 768px) {
font-size: 16px;
}
`;
const LeadSupportingText = styled.p`
margin: 0;
max-width: 48rem;
font-size: 12px;
line-height: 1.65;
max-width: 50rem;
font-size: 12.5px;
line-height: 1.72;
color: rgb(100 116 139);
@media (min-width: 768px) {
font-size: 13px;
}
`;
const PriorityShell = styled.div<{ $delay: number }>`
@@ -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 (
<HeroSection>
<HeroContent>
<IntroGrid>
<LeadBlock className="flex w-full min-w-0 flex-col gap-3 rounded-[28px] border border-slate-200/80 bg-white px-4 py-4 text-left shadow-sm shadow-slate-950/5 md:px-5 md:py-[18px]">
<LeadTopRow>
<div className="inline-flex w-fit items-center rounded-full border border-emerald-200/80 bg-white/92 px-3 py-1 text-[10px] font-semibold tracking-[0.14em] text-emerald-700 shadow-sm shadow-slate-950/5">
{eyebrow}
</div>
{headerControls}
</LeadTopRow>
<StageGrid>
<LeadColumn>
<LeadBlock className="flex w-full min-w-0 flex-col gap-2.5 px-2.5 py-2.5 text-left md:gap-3 md:px-4 md:py-3.5">
<LeadTopRow>
<EyebrowText>{eyebrow}</EyebrowText>
{headerControls}
</LeadTopRow>
<LeadTextGroup>
<h1 className="max-w-[14ch] text-[28px] font-semibold leading-[1.05] tracking-tight text-slate-900 md:text-[38px]">
{title}
</h1>
<p className="max-w-[40rem] text-[14px] font-medium leading-7 text-slate-700 md:text-[15px]">
{description}
</p>
{supportingDescription ? (
<LeadSupportingText>{supportingDescription}</LeadSupportingText>
) : null}
</LeadTextGroup>
<LeadBody>
<LeadTextGroup>
{slogan ? <SloganLine>{slogan}</SloganLine> : null}
{title ? (
<h1 className="max-w-[18ch] text-[28px] font-semibold leading-[1.04] tracking-tight text-slate-900 md:text-[38px]">
{title}
</h1>
) : null}
<LeadDescriptionText>{description}</LeadDescriptionText>
{supportingDescription ? (
<LeadSupportingText>{supportingDescription}</LeadSupportingText>
) : null}
</LeadTextGroup>
{badges.length > 0 ? (
<div className="flex max-w-[44rem] flex-wrap gap-2">
{badges.map((badge) => (
<span
key={badge.key}
className={`${EMPTY_STATE_BADGE_BASE_CLASSNAME} ${
EMPTY_STATE_BADGE_TONE_CLASSNAMES[badge.tone || "slate"]
}`}
>
{badge.label}
</span>
))}
</div>
) : null}
</LeadBlock>
<CardsShell>
{cards.map((card, index) => (
<HeroCard key={card.key} $index={index}>
<div className="flex items-start justify-between gap-3">
<div
className={`card-icon flex h-9 w-9 items-center justify-center rounded-2xl border ${
EMPTY_STATE_ICON_TONE_CLASSNAMES[card.tone || "slate"]
}`}
>
{card.icon}
</div>
<span className={EMPTY_STATE_META_PILL_CLASSNAME}>
{card.eyebrow}
</span>
</div>
<div className="card-content mt-2.5 space-y-1">
<div className="card-title text-sm font-semibold text-slate-900">
{card.title}
</div>
<div className="card-value line-clamp-1 text-[11px] font-medium text-slate-500">
{card.value}
</div>
<p className="card-description line-clamp-3 text-[12px] leading-5 text-slate-500">
{card.description}
</p>
</div>
{card.imageSrc ? (
<div className="card-preview mt-3 overflow-hidden rounded-[18px] border border-slate-200/70 bg-slate-50">
<img
src={card.imageSrc}
alt={card.imageAlt || card.title}
className="h-[74px] w-full object-cover md:h-[82px] xl:h-[74px] 2xl:h-[86px]"
/>
{badges.length > 0 ? (
<div className="flex max-w-[44rem] flex-wrap gap-2">
{badges.map((badge) => (
<span
key={badge.key}
className={`${EMPTY_STATE_BADGE_BASE_CLASSNAME} ${
EMPTY_STATE_BADGE_TONE_CLASSNAMES[badge.tone || "slate"]
}`}
>
{badge.label}
</span>
))}
</div>
) : null}
{card.action ? (
<div className="mt-2.5">{card.action}</div>
) : null}
</HeroCard>
))}
</CardsShell>
</IntroGrid>
</LeadBody>
</LeadBlock>
</LeadColumn>
</StageGrid>
{prioritySlot ? (
<PriorityShell $delay={120} className="mx-auto w-full max-w-[1120px]">
<PriorityShell $delay={120} className="w-full">
{prioritySlot}
</PriorityShell>
) : null}
{supportingSlot ? (
<SupportingShell
$delay={180}
className="mx-auto w-full max-w-[1120px]"
>
<SupportingShell $delay={180} className="w-full">
{supportingSlot}
</SupportingShell>
) : null}
{features.length > 0 ? (
<FeaturePanel className="hidden rounded-[22px] border border-slate-200/80 bg-white px-4 py-3.5 shadow-sm shadow-slate-950/5 md:block">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{features.map((feature) => (
<CardsShell>
{cards.map((card, index) => (
<HeroCard key={card.key} $index={index}>
<div className="flex items-start justify-between gap-3">
<div
key={feature.key}
title={feature.description}
className="min-w-0 rounded-2xl border border-slate-200/80 bg-slate-50 px-3 py-2.5"
className={`card-icon flex h-9 w-9 items-center justify-center rounded-2xl border ${
EMPTY_STATE_ICON_TONE_CLASSNAMES[card.tone || "slate"]
}`}
>
<div className="text-[11px] font-semibold text-slate-700">
{feature.title}
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
{feature.description}
</p>
{card.icon}
</div>
))}
</div>
</FeaturePanel>
) : null}
<CardEyebrow>{card.eyebrow}</CardEyebrow>
</div>
<div className="card-content mt-2.5">
<CardTitleRow>
<div className="card-title text-sm font-semibold text-slate-900">
{card.title}
</div>
<WorkbenchInfoTip
ariaLabel={`${card.title}说明`}
variant="icon"
tone={card.tone === "emerald" ? "mint" : "slate"}
side="top"
align="end"
content={
<div style={{ width: "220px" }} className="space-y-1">
<p className="m-0">{card.value}</p>
<p className="m-0">{card.description}</p>
</div>
}
/>
</CardTitleRow>
<CardValueText>{card.value}</CardValueText>
</div>
{card.imageSrc || card.action ? (
<div className="card-media mt-3 space-y-2.5">
{card.imageSrc ? (
card.onMediaAction ? (
<button
type="button"
onClick={card.onMediaAction}
disabled={card.mediaActionDisabled}
aria-label={card.mediaActionLabel || card.title}
className="card-preview block overflow-hidden rounded-[16px] border border-slate-200/70 bg-slate-50/88 text-left transition hover:border-slate-300/90 disabled:cursor-default disabled:opacity-70"
>
<img
src={card.imageSrc}
alt={card.imageAlt || card.title}
className="block h-[82px] w-full object-cover object-center opacity-95 md:h-[90px] xl:h-[86px]"
/>
</button>
) : (
<div className="card-preview overflow-hidden rounded-[16px] border border-slate-200/70 bg-slate-50/88">
<img
src={card.imageSrc}
alt={card.imageAlt || card.title}
className="block h-[82px] w-full object-cover object-center opacity-95 md:h-[90px] xl:h-[86px]"
/>
</div>
)
) : null}
{card.action ? <div>{card.action}</div> : null}
</div>
) : null}
</HeroCard>
))}
</CardsShell>
</HeroContent>
</HeroSection>
);
@@ -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();
@@ -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({
</div>
<div className="flex min-h-0 flex-1 flex-col px-5 py-5">
<div className="flex-1 overflow-hidden rounded-[20px] border border-slate-200 bg-slate-50">
{selectedOutput ? (
<RenderableTaskImage
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
className="h-full w-full object-contain bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))]"
renderImage={(imageProps) => (
<button
type="button"
className="group relative block h-full w-full overflow-hidden"
onClick={() => onOpenImage?.(selectedOutput.url)}
data-testid="image-task-viewer-open-image"
>
<img {...imageProps} />
<div className="pointer-events-none absolute right-4 top-4 inline-flex items-center gap-1 rounded-full border border-white/80 bg-white/92 px-2.5 py-1 text-xs font-medium text-slate-600 shadow-sm shadow-slate-950/5">
<span>打开原图</span>
<ArrowUpRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</div>
</button>
)}
renderFallback={(reason) => (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
{reason === "empty" &&
(selectedTask?.status === "running" ||
selectedTask?.status === "routing" ||
selectedTask?.status === "queued") ? (
<LoaderCircle className="mx-auto h-8 w-8 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
)}
<div className="text-sm font-semibold text-slate-900">
{reason === "error"
? resolveImageUnavailableTitle(selectedTask?.status)
: statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{reason === "error"
? resolveImageUnavailableDescription(selectedTask?.mode)
: resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
)}
</div>
</div>
</div>
)}
/>
) : (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
{selectedTask?.status === "running" ||
selectedTask?.status === "routing" ||
selectedTask?.status === "queued" ? (
<LoaderCircle className="mx-auto h-8 w-8 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
<div
data-testid="image-task-viewer-stage"
className="flex-1 overflow-hidden rounded-[20px] border border-slate-200 bg-slate-50"
>
<div className="h-full w-full p-4 pt-5">
{selectedOutput ? (
<RenderableTaskImage
src={selectedOutput.url}
alt={selectedOutput.prompt || "图片任务结果"}
className="h-full w-full object-contain"
renderImage={(imageProps) => (
<button
type="button"
className="group relative flex h-full w-full items-center justify-center overflow-hidden rounded-[18px] border border-slate-200/80 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.12),transparent_42%),linear-gradient(180deg,rgba(248,250,252,0.96),rgba(241,245,249,0.98))] p-4"
onClick={() => onOpenImage?.(selectedOutput.url)}
data-testid="image-task-viewer-open-image"
>
<img
{...imageProps}
className={cn(
"h-full w-full rounded-[14px] object-contain",
imageProps.className,
)}
/>
</button>
)}
<div className="text-sm font-semibold text-slate-900">
{statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
renderFallback={(reason) => (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
{reason === "empty" &&
(selectedTask?.status === "running" ||
selectedTask?.status === "routing" ||
selectedTask?.status === "queued") ? (
<LoaderCircle className="mx-auto h-8 w-8 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
)}
<div className="text-sm font-semibold text-slate-900">
{reason === "error"
? resolveImageUnavailableTitle(selectedTask?.status)
: statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{reason === "error"
? resolveImageUnavailableDescription(selectedTask?.mode)
: resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
)}
</div>
</div>
</div>
)}
/>
) : (
<div className="flex h-full min-h-[320px] items-center justify-center px-6 text-center">
<div className="max-w-sm space-y-3">
{selectedTask?.status === "running" ||
selectedTask?.status === "routing" ||
selectedTask?.status === "queued" ? (
<LoaderCircle className="mx-auto h-8 w-8 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-8 w-8 text-slate-400" />
)}
<div className="text-sm font-semibold text-slate-900">
{statusLabel}
</div>
<div className="text-sm leading-6 text-slate-500">
{resolveEmptyStateDescription(
selectedTask?.status,
selectedTask?.failureMessage,
selectedTask?.mode,
)}
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
{showSourcePanel ? (
@@ -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 (
<div className="flex aspect-[16/10] items-center justify-center bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-7 w-7 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-7 w-7 text-slate-400" />
)}
<div className="text-sm font-medium text-slate-700">
{reason === "error"
? resolveImageUnavailableLabel(preview)
: resolvePlaceholderLabel(preview)}
</div>
</div>
</div>
);
}
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 (
<button
@@ -226,134 +254,47 @@ export const ImageWorkbenchMessagePreview: React.FC<
});
}}
data-testid={`image-workbench-message-preview-${preview.taskId}`}
className="mt-3 block w-full max-w-[560px] text-left"
className="group block w-full max-w-[360px] text-left sm:max-w-[400px] lg:max-w-[440px]"
>
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:shadow-slate-950/10">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium",
resolveStatusTone(preview),
)}
>
{preview.status === "running" ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{resolveStatusLabel(preview)}
</span>
<span className="truncate text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
{resolveModeEyebrow(preview.mode)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-500">
<span>打开查看</span>
<ArrowUpRight className="h-3.5 w-3.5" />
<div
className={cn(
"overflow-hidden rounded-[24px] border border-slate-200 bg-slate-50 transition group-hover:border-slate-300",
preview.imageUrl
? "shadow-[0_18px_42px_-34px_rgba(15,23,42,0.45)]"
: "shadow-[0_16px_38px_-34px_rgba(15,23,42,0.28)]",
)}
>
<RenderableTaskImage
src={preview.imageUrl}
alt={preview.prompt || "图片任务结果"}
className="aspect-[16/10] h-full w-full object-cover"
renderFallback={(reason) => renderPlaceholder(preview, reason)}
/>
</div>
<div className="space-y-1.5 px-0.5 pt-3">
<div className="line-clamp-2 text-[15px] font-medium leading-6 text-slate-900">
{preview.prompt || "当前任务未提供提示词。"}
</div>
<div className="flex items-start gap-2 text-[13px] leading-5 text-slate-500">
<span
className={cn(
"mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full",
resolveStatusAccentClass(preview),
)}
/>
<span>
<span className="font-medium text-slate-700">{statusPrefix}</span>
<span>{` · ${statusDescription}`}</span>
</span>
</div>
<div className="px-4 pb-4">
<div className="grid gap-3 sm:grid-cols-[220px_minmax(0,1fr)]">
<div className="overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50">
<RenderableTaskImage
src={preview.imageUrl}
alt={preview.prompt || "图片任务结果"}
className="aspect-[16/10] h-full w-full object-cover"
renderFallback={(reason) => (
<div className="flex aspect-[16/10] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_46%),linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.98))] px-6 text-center">
<div className="space-y-2">
{reason === "empty" && preview.status === "running" ? (
<LoaderCircle className="mx-auto h-7 w-7 animate-spin text-sky-500" />
) : (
<Sparkles className="mx-auto h-7 w-7 text-slate-400" />
)}
<div className="text-sm font-medium text-slate-700">
{reason === "error"
? resolveImageUnavailableLabel(preview)
: resolvePlaceholderLabel(preview)}
</div>
</div>
</div>
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-sm font-medium leading-6 text-slate-900">
{preview.prompt || "当前任务未提供提示词。"}
</div>
<div className="mt-1 text-xs leading-5 text-slate-500">
{resolveDescription(preview)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
{preview.size ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5">
{preview.size}
</span>
) : null}
{preview.imageCount && preview.imageCount > 0 ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5">
{preview.imageCount} 张
</span>
) : null}
{preview.attemptCount && preview.attemptCount > 1 ? (
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5">
第 {preview.attemptCount} 次
</span>
) : null}
</div>
{showSourcePanel ? (
<div
data-testid={`image-workbench-message-preview-source-${preview.taskId}`}
className="mt-3 rounded-[18px] border border-slate-200 bg-slate-50 p-3"
>
<div className="text-[11px] font-medium text-slate-500">
{resolveSourceLabel(preview.mode)}
</div>
<div className="mt-2 flex items-center gap-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white">
<RenderableTaskImage
src={preview.sourceImageUrl}
alt={
preview.sourceImagePrompt ||
resolveSourceLabel(preview.mode)
}
className="h-full w-full object-cover"
renderFallback={(reason) => (
<span className="px-2 text-center text-[11px] font-medium text-slate-400">
{reason === "error"
? `${resolveSourceLabel(preview.mode)}暂时无法显示`
: resolveSourcePlaceholderLabel(preview)}
</span>
)}
/>
</div>
<div className="min-w-0">
<div className="line-clamp-2 text-xs font-medium leading-5 text-slate-700">
{resolveSourceSummary(preview)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
{preview.sourceImageRef ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{preview.sourceImageRef}
</span>
) : null}
{preview.sourceImageCount &&
preview.sourceImageCount > 0 ? (
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5">
{preview.sourceImageCount} 张
</span>
) : null}
</div>
</div>
</div>
</div>
) : null}
</div>
{sourceFootnote ? (
<div className="line-clamp-2 text-[12px] leading-5 text-slate-400">
{sourceFootnote}
</div>
</div>
) : null}
</div>
</button>
);

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