mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.5.0
This commit is contained in:
+24
-24
@@ -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`
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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 相对路径和图片数量,避免用户只能看到“已保存”却不知道文件实际落点
|
||||
|
||||
### 开发者页站点来源导入验证
|
||||
|
||||
|
||||
@@ -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` 行为符合任务协议。
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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;
|
||||
});
|
||||
Generated
+26
-21
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "素材检索任务技能。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ``;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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}。"),
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
@@ -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,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,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
@@ -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 |
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user